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 2s

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,87 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}

View File

@@ -0,0 +1,110 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { describe, expect, it } from 'vitest';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { CALL_RECORDING_REQUEST_STATUS } from 'src/logic-functions/constants/call-recording-request-status.constant';
import { CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant';
describe('App installation', () => {
it('should find the installed Fireflies app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const matchingApplication = result.findManyApplications.find(
(application: { universalIdentifier: string }) =>
application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(matchingApplication).toBeDefined();
});
});
// The published twenty-client-sdk types only export CoreApiClient/CoreSchema;
// the enum exports appear at runtime after dev:generate-client regenerates the
// client against the server, hence the cast and the runtime guard.
type GeneratedCoreSchemaRuntime = {
enumCallRecordingStatusEnum?: Record<string, string>;
};
const getServerCallRecordingStatuses = async (): Promise<string[]> => {
const generatedCoreSchema =
(await import('twenty-client-sdk/core')) as unknown as GeneratedCoreSchemaRuntime;
const statusEnum = generatedCoreSchema.enumCallRecordingStatusEnum;
if (statusEnum === undefined) {
throw new Error(
'enumCallRecordingStatusEnum is missing from twenty-client-sdk/core; regenerate the client against a running server (yarn twenty dev) before the integration suite',
);
}
return Object.values(statusEnum);
};
describe('CallRecording status contract', () => {
it('should accept every status and request status value the app mirrors', async () => {
const client = new CoreApiClient();
const serverCallRecordingStatuses = await getServerCallRecordingStatuses();
expect(serverCallRecordingStatuses).toEqual(
expect.arrayContaining(Object.values(CALL_RECORDING_STATUS)),
);
const created = await client.mutation({
createCallRecording: {
__args: {
data: {
title: 'Fireflies integration test recording',
status: CALL_RECORDING_STATUS.PROCESSING,
recordingRequestStatus: CALL_RECORDING_REQUEST_STATUS.REQUESTED,
},
},
id: true,
status: true,
recordingRequestStatus: true,
},
});
const callRecordingId = created.createCallRecording?.id;
expect(callRecordingId).toBeDefined();
expect(created.createCallRecording?.status).toBe(
CALL_RECORDING_STATUS.PROCESSING,
);
expect(created.createCallRecording?.recordingRequestStatus).toBe(
CALL_RECORDING_REQUEST_STATUS.REQUESTED,
);
if (callRecordingId === undefined) {
throw new Error('Expected call recording creation to return an id');
}
const updated = await client.mutation({
updateCallRecording: {
__args: {
id: callRecordingId,
data: { status: CALL_RECORDING_STATUS.COMPLETED },
},
status: true,
},
});
expect(updated.updateCallRecording?.status).toBe(
CALL_RECORDING_STATUS.COMPLETED,
);
await client.mutation({
destroyCallRecording: {
__args: { id: callRecordingId },
id: true,
},
});
});
});

View File

@@ -0,0 +1,41 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APPLICATION_UNIVERSAL_IDENTIFIER,
FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER,
FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Fireflies',
description:
'Sync Fireflies call transcripts and AI summaries into CallRecording records linked to matching CalendarEvents in Twenty, and trigger sync / list / search of Fireflies calls from workflows and the AI chat.',
logoUrl: 'public/twenty-fireflies.svg',
author: 'Twenty',
category: 'Productivity',
galleryImages: [
'public/gallery/fireflies-cover-image-1.png',
'public/gallery/fireflies-cover-image-2.png',
],
websiteUrl: 'https://docs.twenty.com/developers/extend/apps/getting-started',
termsUrl: 'https://www.twenty.com/terms',
emailSupport: 'contact@twenty.com',
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
applicationVariables: {
FIREFLIES_API_KEY: {
universalIdentifier: FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Fireflies API key',
description:
'Your Fireflies API key (Fireflies → Integrations → Fireflies API).',
isSecret: true,
},
FIREFLIES_WEBHOOK_SECRET: {
universalIdentifier:
FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Fireflies webhook secret',
description: 'Signing secret from the Fireflies Webhooks V2 setup page.',
isSecret: true,
},
},
});

View File

@@ -0,0 +1 @@
export const FIREFLIES_API_KEY_VARIABLE_KEY = 'FIREFLIES_API_KEY';

View File

@@ -0,0 +1 @@
export const FIREFLIES_BACKFILL_MAX_WINDOW_DAYS = 3_650;

View File

@@ -0,0 +1,6 @@
export const FIREFLIES_BACKFILL_OUTCOME = {
INVALID_REQUEST: 'invalid-request',
NOT_CONFIGURED: 'not-configured',
COMPLETED: 'completed',
STARTED: 'started',
} as const;

View File

@@ -0,0 +1 @@
export const FIREFLIES_BACKFILL_ROUTE_PATH = '/fireflies/backfill';

View File

@@ -0,0 +1,38 @@
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'97d24431-ebc7-4156-9705-b6900e73edc8';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'ed0a61a4-7638-4fd8-a2c1-982b50aca4ff';
export const FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER =
'876ebb9f-0fb1-48b3-bd6f-f4138f999ba7';
export const FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER =
'a1ef34d9-f0e7-483e-9909-fce2757bdd23';
export const FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'e0c2d033-0ef4-4b92-9e64-2d8f2e4c497e';
export const FIREFLIES_BACKFILL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'4b138241-0059-485b-8b7f-31f2d1ae645a';
export const FIREFLIES_BACKFILL_WORKER_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'd9477b08-0abb-479e-b944-41cbfec72e2b';
export const FIREFLIES_BACKFILL_BATCH_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'0f5c21b6-a58c-4170-abe1-4631e0e0e28d';
export const FIREFLIES_DAILY_HEALER_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'abe764da-3eeb-42d7-921c-7b967328e053';
export const FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER =
'c715a476-929e-4e5b-b692-6350fc0adb73';
export const FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER =
'3148f5c7-8bcb-48d3-a7b9-aa811608e256';
export const FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER =
'cb2dd01d-8dca-4222-acce-1d1dbdef9146';
export const SETTINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'0a78863d-069e-4333-97a3-58b11e9c603d';

View File

@@ -0,0 +1,34 @@
import styled from '@emotion/styled';
import { Pill } from 'twenty-ui/data-display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledLabelRow = styled.div`
align-items: center;
display: flex;
gap: ${() => themeCssVariables.spacing[1]};
margin-bottom: ${() => themeCssVariables.spacing[1]};
`;
const StyledLabel = styled.label`
color: ${() => themeCssVariables.font.color.light};
font-family: ${() => themeCssVariables.font.family};
font-size: 11px;
font-weight: ${() => themeCssVariables.font.weight.semiBold};
`;
type ApplicationVariableLabelRowProps = {
variableKey: string;
isDeprecated: boolean;
inputId: string;
};
export const ApplicationVariableLabelRow = ({
variableKey,
isDeprecated,
inputId,
}: ApplicationVariableLabelRowProps) => (
<StyledLabelRow>
<StyledLabel htmlFor={inputId}>{variableKey}</StyledLabel>
{isDeprecated && <Pill label="Deprecated" />}
</StyledLabelRow>
);

View File

@@ -0,0 +1,89 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useId } from 'react';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useDebouncedCallback } from 'use-debounce';
import { ApplicationVariableLabelRow } from 'src/front-components/components/ApplicationVariableLabelRow';
import { StyledSettingsTextInput } from 'src/front-components/components/StyledSettingsTextInput';
import { useUpdateApplicationVariable } from 'src/front-components/hooks/use-update-application-variable';
import { type FirefliesApplicationVariable } from 'src/front-components/types/fireflies-application-variable.type';
const APPLICATION_VARIABLE_SAVE_DEBOUNCE_MILLISECONDS = 250;
const StyledRow = styled.div`
display: flex;
flex-direction: column;
`;
const StyledDescription = styled.span`
color: ${() => themeCssVariables.font.color.tertiary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.xs};
margin-bottom: ${() => themeCssVariables.spacing[1]};
`;
type ApplicationVariableRowProps = {
variable: FirefliesApplicationVariable;
applicationId: string;
value: string | undefined;
onValueChange: (params: { variableKey: string; value: string }) => void;
};
export const ApplicationVariableRow = ({
variable,
applicationId,
value,
onValueChange,
}: ApplicationVariableRowProps) => {
const inputId = useId();
const { updateApplicationVariable } =
useUpdateApplicationVariable(applicationId);
const isSecretStored = variable.isSecret && isNonEmptyString(variable.value);
const draftValue = value ?? (isSecretStored ? '' : variable.value);
const saveDebounced = useDebouncedCallback(async (newValue: string) => {
const isUpdated = await updateApplicationVariable({
variableKey: variable.key,
value: newValue,
});
if (!isUpdated) {
enqueueSnackbar({
message: `Could not save ${variable.key}.`,
variant: 'error',
});
}
}, APPLICATION_VARIABLE_SAVE_DEBOUNCE_MILLISECONDS);
return (
<StyledRow>
<ApplicationVariableLabelRow
variableKey={variable.key}
isDeprecated={variable.isDeprecated}
inputId={inputId}
/>
{isNonEmptyString(variable.description) && (
<StyledDescription>{variable.description}</StyledDescription>
)}
<StyledSettingsTextInput
id={inputId}
type={variable.isSecret ? 'password' : 'text'}
autoComplete="off"
placeholder={isSecretStored ? variable.value : 'Value'}
value={draftValue}
onChange={(event) => {
onValueChange({
variableKey: variable.key,
value: event.target.value,
});
saveDebounced(event.target.value);
}}
onBlur={() => saveDebounced.flush()}
/>
</StyledRow>
);
};

View File

@@ -0,0 +1,107 @@
import styled from '@emotion/styled';
import { isUndefined } from '@sniptt/guards';
import { useState } from 'react';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { H2Title } from 'twenty-ui/typography';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { FIREFLIES_BACKFILL_MAX_WINDOW_DAYS } from 'src/constants/fireflies-backfill-max-window-days.constant';
import { StyledSettingsTextInput } from 'src/front-components/components/StyledSettingsTextInput';
import { DEFAULT_FIREFLIES_BACKFILL_DAYS } from 'src/front-components/constants/default-fireflies-backfill-days.constant';
import { useRequestFirefliesBackfill } from 'src/front-components/hooks/use-request-fireflies-backfill';
import { getFirefliesBackfillFeedback } from 'src/front-components/utils/get-fireflies-backfill-feedback.util';
import { parseBackfillDays } from 'src/front-components/utils/parse-backfill-days.util';
const StyledRow = styled.div`
align-items: center;
display: flex;
gap: ${() => themeCssVariables.spacing[2]};
`;
const StyledDaysInputContainer = styled.div`
display: flex;
width: ${() => themeCssVariables.spacing[20]};
`;
const StyledDaysUnit = styled.span`
color: ${() => themeCssVariables.font.color.secondary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
`;
const StyledHint = styled.div`
color: ${() => themeCssVariables.font.color.tertiary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.xs};
margin-top: ${() => themeCssVariables.spacing[2]};
`;
type FirefliesBackfillSectionProps = {
isApiKeyConfigured: boolean;
};
export const FirefliesBackfillSection = ({
isApiKeyConfigured,
}: FirefliesBackfillSectionProps) => {
const [daysDraftValue, setDaysDraftValue] = useState(
String(DEFAULT_FIREFLIES_BACKFILL_DAYS),
);
const { requestFirefliesBackfill, isRequestingFirefliesBackfill } =
useRequestFirefliesBackfill();
const parsedDays = parseBackfillDays(daysDraftValue);
const isStartDisabled =
!isApiKeyConfigured ||
isUndefined(parsedDays) ||
isRequestingFirefliesBackfill;
const handleStartClick = async () => {
if (isUndefined(parsedDays)) {
return;
}
const outcome = await requestFirefliesBackfill(parsedDays);
const feedback = getFirefliesBackfillFeedback(outcome);
enqueueSnackbar({ message: feedback.message, variant: feedback.variant });
};
return (
<Section>
<H2Title
title="Import call history"
description="Imports past Fireflies calls as call recordings. Already-imported calls are skipped."
/>
<StyledRow>
<StyledDaysInputContainer>
<StyledSettingsTextInput
aria-label="Days of call history to import"
type="number"
min={1}
max={FIREFLIES_BACKFILL_MAX_WINDOW_DAYS}
step={1}
value={daysDraftValue}
onChange={(event) => setDaysDraftValue(event.target.value)}
/>
</StyledDaysInputContainer>
<StyledDaysUnit>days</StyledDaysUnit>
<Button
title={isRequestingFirefliesBackfill ? 'Starting…' : 'Start backfill'}
disabled={isStartDisabled}
onClick={handleStartClick}
/>
</StyledRow>
{!isApiKeyConfigured && (
<StyledHint>Set the Fireflies API key first.</StyledHint>
)}
{isApiKeyConfigured && isUndefined(parsedDays) && (
<StyledHint>
Enter a whole number of days between 1 and{' '}
{FIREFLIES_BACKFILL_MAX_WINDOW_DAYS}.
</StyledHint>
)}
</Section>
);
};

View File

@@ -0,0 +1,101 @@
import 'twenty-ui/style.css';
import styled from '@emotion/styled';
import { isUndefined } from '@sniptt/guards';
import { useState } from 'react';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { H2Title } from 'twenty-ui/typography';
import { FIREFLIES_API_KEY_VARIABLE_KEY } from 'src/constants/fireflies-api-key-variable-key.constant';
import { ApplicationVariableRow } from 'src/front-components/components/ApplicationVariableRow';
import { FirefliesBackfillSection } from 'src/front-components/components/FirefliesBackfillSection';
import { useFirefliesApplicationVariables } from 'src/front-components/hooks/use-fireflies-application-variables';
import { getIsApplicationVariableConfigured } from 'src/front-components/utils/get-is-application-variable-configured.util';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[8]};
width: 100%;
`;
const StyledVariablesList = styled.div`
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[4]};
`;
const StyledCenteredState = styled.div`
align-items: center;
box-sizing: border-box;
color: ${() => themeCssVariables.font.color.tertiary};
display: flex;
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
height: 100%;
justify-content: center;
padding: ${() => themeCssVariables.spacing[4]};
width: 100%;
`;
export const FirefliesSettings = () => {
const {
applicationId,
applicationVariables,
isApplicationVariablesQueryLoading,
errorMessage,
} = useFirefliesApplicationVariables();
const [draftValueByVariableKey, setDraftValueByVariableKey] = useState<
Record<string, string>
>({});
const isApiKeyConfigured = getIsApplicationVariableConfigured({
draftValue: draftValueByVariableKey[FIREFLIES_API_KEY_VARIABLE_KEY],
storedValue: applicationVariables.find(
(variable) => variable.key === FIREFLIES_API_KEY_VARIABLE_KEY,
)?.value,
});
if (isApplicationVariablesQueryLoading) {
return <StyledCenteredState>Loading settings…</StyledCenteredState>;
}
if (isUndefined(applicationId)) {
return <StyledCenteredState>{errorMessage}</StyledCenteredState>;
}
return (
<StyledContainer>
<Section>
<H2Title
title="Configuration"
description={
applicationVariables.length > 0
? 'Set your application configuration variables'
: 'No variables to set for this application'
}
/>
<StyledVariablesList>
{applicationVariables.map((variable) => (
<ApplicationVariableRow
key={variable.key}
variable={variable}
applicationId={applicationId}
value={draftValueByVariableKey[variable.key]}
onValueChange={({ variableKey, value }) =>
setDraftValueByVariableKey((previousDraftValues) => ({
...previousDraftValues,
[variableKey]: value,
}))
}
/>
))}
</StyledVariablesList>
</Section>
<FirefliesBackfillSection isApiKeyConfigured={isApiKeyConfigured} />
</StyledContainer>
);
};

View File

@@ -0,0 +1,39 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledSettingsTextInput = styled.input`
background-color: ${() => themeCssVariables.background.transparent.lighter};
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.md};
box-sizing: border-box;
color: ${() => themeCssVariables.font.color.primary};
display: flex;
flex-grow: 1;
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.md};
font-weight: ${() => themeCssVariables.font.weight.regular};
height: 32px;
outline: none;
padding: ${() => themeCssVariables.spacing[2]};
text-overflow: ellipsis;
width: 100%;
&::placeholder,
&::-webkit-input-placeholder {
color: ${() => themeCssVariables.font.color.light};
font-family: ${() => themeCssVariables.font.family};
font-weight: ${() => themeCssVariables.font.weight.medium};
}
&:disabled {
color: ${() => themeCssVariables.font.color.tertiary};
}
&[readonly] {
pointer-events: none;
}
&:focus {
border-color: ${() => themeCssVariables.color.blue};
}
`;

View File

@@ -0,0 +1 @@
export const DEFAULT_FIREFLIES_BACKFILL_DAYS = 90;

View File

@@ -0,0 +1,115 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useEffect, useState } from 'react';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { useFrontComponentId } from 'twenty-sdk/front-component';
import { type FirefliesApplicationVariable } from 'src/front-components/types/fireflies-application-variable.type';
import { shouldDisplayApplicationVariable } from 'src/front-components/utils/should-display-application-variable.util';
type FirefliesApplicationVariablesState = {
applicationId: string | undefined;
applicationVariables: FirefliesApplicationVariable[];
isApplicationVariablesQueryLoading: boolean;
errorMessage: string | undefined;
};
const APPLICATION_VARIABLES_ERROR_MESSAGE = 'Please try again later.';
const APPLICATION_VARIABLES_LOADING_STATE: FirefliesApplicationVariablesState =
{
applicationId: undefined,
applicationVariables: [],
isApplicationVariablesQueryLoading: true,
errorMessage: undefined,
};
export const useFirefliesApplicationVariables =
(): FirefliesApplicationVariablesState => {
const frontComponentId = useFrontComponentId();
const [state, setState] = useState<FirefliesApplicationVariablesState>(
APPLICATION_VARIABLES_LOADING_STATE,
);
useEffect(() => {
let cancelled = false;
setState(APPLICATION_VARIABLES_LOADING_STATE);
const fetchApplicationVariables = async () => {
try {
const client = new MetadataApiClient();
const frontComponentResult = await client.query({
frontComponent: {
__args: { id: frontComponentId },
applicationId: true,
},
});
const applicationId =
frontComponentResult.frontComponent?.applicationId;
if (!isNonEmptyString(applicationId)) {
if (!cancelled) {
setState({
applicationId: undefined,
applicationVariables: [],
isApplicationVariablesQueryLoading: false,
errorMessage: APPLICATION_VARIABLES_ERROR_MESSAGE,
});
}
return;
}
const applicationResult = await client.query({
findOneApplication: {
__args: { id: applicationId },
applicationVariables: {
key: true,
value: true,
description: true,
isSecret: true,
isDeprecated: true,
},
},
});
if (cancelled) {
return;
}
const applicationVariables = [
...(applicationResult.findOneApplication?.applicationVariables ??
[]),
]
.filter(shouldDisplayApplicationVariable)
.sort((left, right) => left.key.localeCompare(right.key));
setState({
applicationId,
applicationVariables,
isApplicationVariablesQueryLoading: false,
errorMessage: undefined,
});
} catch {
if (cancelled) {
return;
}
setState({
applicationId: undefined,
applicationVariables: [],
isApplicationVariablesQueryLoading: false,
errorMessage: APPLICATION_VARIABLES_ERROR_MESSAGE,
});
}
};
fetchApplicationVariables();
return () => {
cancelled = true;
};
}, [frontComponentId]);
return state;
};

View File

@@ -0,0 +1,41 @@
import { isString } from '@sniptt/guards';
import { useState } from 'react';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { FIREFLIES_BACKFILL_ROUTE_PATH } from 'src/constants/fireflies-backfill-route-path.constant';
import { asRecord } from 'src/front-components/utils/as-record.util';
type RequestFirefliesBackfillState = {
requestFirefliesBackfill: (days: number) => Promise<string | undefined>;
isRequestingFirefliesBackfill: boolean;
};
export const useRequestFirefliesBackfill =
(): RequestFirefliesBackfillState => {
const [isRequestingFirefliesBackfill, setIsRequestingFirefliesBackfill] =
useState(false);
const requestFirefliesBackfill = async (
days: number,
): Promise<string | undefined> => {
setIsRequestingFirefliesBackfill(true);
try {
const client = new RestApiClient();
const backfillResult = await client.post(
`/s${FIREFLIES_BACKFILL_ROUTE_PATH}`,
{ days },
);
const outcome = asRecord(backfillResult)?.outcome;
return isString(outcome) ? outcome : undefined;
} catch {
return undefined;
} finally {
setIsRequestingFirefliesBackfill(false);
}
};
return { requestFirefliesBackfill, isRequestingFirefliesBackfill };
};

View File

@@ -0,0 +1,37 @@
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
type UpdateApplicationVariableParams = {
variableKey: string;
value: string;
};
type UpdateApplicationVariableState = {
updateApplicationVariable: (
params: UpdateApplicationVariableParams,
) => Promise<boolean>;
};
export const useUpdateApplicationVariable = (
applicationId: string,
): UpdateApplicationVariableState => {
const updateApplicationVariable = async ({
variableKey,
value,
}: UpdateApplicationVariableParams): Promise<boolean> => {
try {
const client = new MetadataApiClient();
await client.mutation({
updateOneApplicationVariable: {
__args: { key: variableKey, value, applicationId },
},
});
return true;
} catch {
return false;
}
};
return { updateApplicationVariable };
};

View File

@@ -0,0 +1,12 @@
import { defineSettingsFrontComponent } from 'twenty-sdk/define';
import { SETTINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { FirefliesSettings } from 'src/front-components/components/FirefliesSettings';
export default defineSettingsFrontComponent({
universalIdentifier: SETTINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'fireflies-settings',
description:
'Admin settings panel: Fireflies credentials and call-history backfill.',
component: FirefliesSettings,
});

View File

@@ -0,0 +1,7 @@
export type FirefliesApplicationVariable = {
key: string;
value: string;
description: string;
isSecret: boolean;
isDeprecated: boolean;
};

View File

@@ -0,0 +1,4 @@
export type FirefliesBackfillFeedback = {
variant: 'success' | 'error';
message: string;
};

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { getFirefliesBackfillFeedback } from 'src/front-components/utils/get-fireflies-backfill-feedback.util';
describe('getFirefliesBackfillFeedback', () => {
it.each([
['started', 'success'],
['invalid-request', 'error'],
['not-configured', 'error'],
])('maps the %s outcome to a %s feedback', (outcome, variant) => {
expect(getFirefliesBackfillFeedback(outcome).variant).toBe(variant);
});
it('maps an unknown outcome to an error feedback', () => {
expect(getFirefliesBackfillFeedback('unexpected')).toEqual({
variant: 'error',
message: 'Could not start the backfill. Try again later.',
});
});
it('maps a missing outcome to an error feedback', () => {
expect(getFirefliesBackfillFeedback(undefined).variant).toBe('error');
});
});

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { getIsApplicationVariableConfigured } from 'src/front-components/utils/get-is-application-variable-configured.util';
describe('getIsApplicationVariableConfigured', () => {
it('is configured when untouched and a value is stored', () => {
expect(
getIsApplicationVariableConfigured({
draftValue: undefined,
storedValue: 'dem********',
}),
).toBe(true);
});
it('is not configured when untouched and nothing is stored', () => {
expect(
getIsApplicationVariableConfigured({
draftValue: undefined,
storedValue: '',
}),
).toBe(false);
});
it('is configured as soon as a value is typed', () => {
expect(
getIsApplicationVariableConfigured({
draftValue: 'new-key',
storedValue: '',
}),
).toBe(true);
});
it('is not configured once a stored value is cleared, even while the stored value is still present', () => {
expect(
getIsApplicationVariableConfigured({
draftValue: '',
storedValue: 'dem********',
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { parseBackfillDays } from 'src/front-components/utils/parse-backfill-days.util';
describe('parseBackfillDays', () => {
it('parses a positive integer', () => {
expect(parseBackfillDays('90')).toBe(90);
});
it('parses a padded value', () => {
expect(parseBackfillDays(' 30 ')).toBe(30);
});
it('accepts the maximum window', () => {
expect(parseBackfillDays('3650')).toBe(3650);
});
it.each([['0'], ['-5'], ['1.5'], ['abc'], [''], ['3651']])(
'rejects %s',
(rawValue) => {
expect(parseBackfillDays(rawValue)).toBeUndefined();
},
);
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { type FirefliesApplicationVariable } from 'src/front-components/types/fireflies-application-variable.type';
import { shouldDisplayApplicationVariable } from 'src/front-components/utils/should-display-application-variable.util';
const buildVariable = (
overrides: Partial<FirefliesApplicationVariable>,
): FirefliesApplicationVariable => ({
key: 'FIREFLIES_API_KEY',
value: '',
description: '',
isSecret: true,
isDeprecated: false,
...overrides,
});
describe('shouldDisplayApplicationVariable', () => {
it('displays a supported variable without a value', () => {
expect(shouldDisplayApplicationVariable(buildVariable({}))).toBe(true);
});
it('hides a deprecated variable without a value', () => {
expect(
shouldDisplayApplicationVariable(buildVariable({ isDeprecated: true })),
).toBe(false);
});
it('displays a deprecated variable that still holds a value', () => {
expect(
shouldDisplayApplicationVariable(
buildVariable({ isDeprecated: true, value: 'dem********' }),
),
).toBe(true);
});
});

View File

@@ -0,0 +1,6 @@
import { isArray, isObject } from '@sniptt/guards';
export const asRecord = (value: unknown): Record<string, unknown> | undefined =>
isObject(value) && !isArray(value)
? (value as Record<string, unknown>)
: undefined;

View File

@@ -0,0 +1,35 @@
import { isUndefined } from '@sniptt/guards';
import { FIREFLIES_BACKFILL_OUTCOME } from 'src/constants/fireflies-backfill-outcome.constant';
import { type FirefliesBackfillFeedback } from 'src/front-components/types/fireflies-backfill-feedback.type';
const BACKFILL_FEEDBACK_BY_OUTCOME: Record<string, FirefliesBackfillFeedback> =
{
[FIREFLIES_BACKFILL_OUTCOME.STARTED]: {
variant: 'success',
message: 'Backfill started. Calls will appear as they are imported.',
},
[FIREFLIES_BACKFILL_OUTCOME.INVALID_REQUEST]: {
variant: 'error',
message: 'Enter a valid number of days.',
},
[FIREFLIES_BACKFILL_OUTCOME.NOT_CONFIGURED]: {
variant: 'error',
message: 'Set the Fireflies API key first.',
},
};
const UNKNOWN_OUTCOME_FEEDBACK: FirefliesBackfillFeedback = {
variant: 'error',
message: 'Could not start the backfill. Try again later.',
};
export const getFirefliesBackfillFeedback = (
outcome: string | undefined,
): FirefliesBackfillFeedback => {
if (isUndefined(outcome)) {
return UNKNOWN_OUTCOME_FEEDBACK;
}
return BACKFILL_FEEDBACK_BY_OUTCOME[outcome] ?? UNKNOWN_OUTCOME_FEEDBACK;
};

View File

@@ -0,0 +1,12 @@
import { isNonEmptyString, isUndefined } from '@sniptt/guards';
export const getIsApplicationVariableConfigured = ({
draftValue,
storedValue,
}: {
draftValue: string | undefined;
storedValue: string | undefined;
}): boolean =>
isUndefined(draftValue)
? isNonEmptyString(storedValue)
: isNonEmptyString(draftValue);

View File

@@ -0,0 +1,15 @@
import { FIREFLIES_BACKFILL_MAX_WINDOW_DAYS } from 'src/constants/fireflies-backfill-max-window-days.constant';
export const parseBackfillDays = (rawValue: string): number | undefined => {
const parsedValue = Number(rawValue);
if (
!Number.isInteger(parsedValue) ||
parsedValue <= 0 ||
parsedValue > FIREFLIES_BACKFILL_MAX_WINDOW_DAYS
) {
return undefined;
}
return parsedValue;
};

View File

@@ -0,0 +1,7 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type FirefliesApplicationVariable } from 'src/front-components/types/fireflies-application-variable.type';
export const shouldDisplayApplicationVariable = (
variable: FirefliesApplicationVariable,
): boolean => !variable.isDeprecated || isNonEmptyString(variable.value);

View File

@@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
answerTwentyQueries,
serveFirefliesApi,
setUpImportMissingFirefliesCallsTest,
} from 'src/logic-functions/flows/__tests__/import-missing-fireflies-calls.test-support';
import firefliesBackfillBatchLogicFunction from 'src/logic-functions/fireflies-backfill-batch';
import { FIREFLIES_BACKFILL_BATCH_SIZE } from 'src/logic-functions/constants/fireflies-backfill-batch-size.constant';
import { computeCallRecordingIdForFirefliesMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting';
import { LOGIC_FUNCTION_EXECUTION_CONTEXT } from 'src/logic-functions/__tests__/logic-function-execution-context.test-support';
const queryMock = vi.hoisted(() => vi.fn());
const mutationMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: class {
query = queryMock;
mutation = mutationMock;
},
}));
const fetchMock = vi.fn();
describe('firefliesBackfillBatchLogicFunction', () => {
beforeEach(() => {
setUpImportMissingFirefliesCallsTest({
fetchMock,
queryMock,
mutationMock,
});
vi.stubEnv('FIREFLIES_API_KEY', 'fireflies-api-key');
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it('is configured as an enqueue-only batch importer', () => {
expect(firefliesBackfillBatchLogicFunction.config).toEqual(
expect.objectContaining({
name: 'fireflies-backfill-batch',
timeoutSeconds: 900,
}),
);
expect(firefliesBackfillBatchLogicFunction.config).not.toHaveProperty(
'httpRouteTriggerSettings',
);
expect(firefliesBackfillBatchLogicFunction.config).not.toHaveProperty(
'cronTriggerSettings',
);
});
it('imports the calls listed in the raw job payload', async () => {
serveFirefliesApi([], fetchMock);
vi.useFakeTimers();
const resultPromise = firefliesBackfillBatchLogicFunction.config.handler(
{
transcriptIds: ['call-1', 'call-2'],
},
LOGIC_FUNCTION_EXECUTION_CONTEXT,
);
await vi.runAllTimersAsync();
const result = await resultPromise;
expect(result).toEqual({
status: 'completed',
importedCallCount: 2,
erroredCallCount: 0,
skippedCallCount: 0,
});
expect(mutationMock).toHaveBeenCalledTimes(2);
expect(
mutationMock.mock.calls.map(
([mutation]) =>
mutation.createCallRecording.__args.data.externalRecordingId,
),
).toEqual(['call-1', 'call-2']);
});
it('skips already synced calls without calling Fireflies', async () => {
serveFirefliesApi([], fetchMock);
answerTwentyQueries({
queryMock,
callRecordings: [
{
id: computeCallRecordingIdForFirefliesMeeting('call-1'),
transcript: [{ text: 'already imported' }],
summary: { markdown: 'Already imported' },
},
],
});
const result = await firefliesBackfillBatchLogicFunction.config.handler(
{
transcriptIds: ['call-1'],
},
LOGIC_FUNCTION_EXECUTION_CONTEXT,
);
expect(result).toEqual(
expect.objectContaining({ status: 'completed', skippedCallCount: 1 }),
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('throws on a Fireflies rate limit so the queue retries the job', async () => {
const consoleLogSpy = vi
.spyOn(console, 'log')
.mockImplementation(() => undefined);
vi.useFakeTimers();
fetchMock.mockImplementation(
async () =>
new Response(
JSON.stringify({ errors: [{ message: 'too many requests' }] }),
{ status: 429 },
),
);
vi.stubGlobal('fetch', fetchMock);
const resultPromise = firefliesBackfillBatchLogicFunction.config.handler(
{
transcriptIds: ['call-1'],
},
LOGIC_FUNCTION_EXECUTION_CONTEXT,
);
const resultExpectation = expect(resultPromise).rejects.toThrow(
'transient Fireflies API error',
);
await vi.runAllTimersAsync();
await resultExpectation;
expect(consoleLogSpy).toHaveBeenCalledWith(
'[fireflies] Backfill batch processed',
{
importedCallCount: 0,
erroredCallCount: 0,
skippedCallCount: 0,
},
);
});
it('throws on an invalid job payload', async () => {
await expect(
firefliesBackfillBatchLogicFunction.config.handler(
{ transcriptIds: [] },
LOGIC_FUNCTION_EXECUTION_CONTEXT,
),
).rejects.toThrow('requires 1 to 20 non-empty transcript ids');
});
it('throws when a job payload exceeds the configured batch size', async () => {
await expect(
firefliesBackfillBatchLogicFunction.config.handler(
{
transcriptIds: Array.from(
{ length: FIREFLIES_BACKFILL_BATCH_SIZE + 1 },
(_, transcriptIndex) => `call-${transcriptIndex}`,
),
},
LOGIC_FUNCTION_EXECUTION_CONTEXT,
),
).rejects.toThrow('requires 1 to 20 non-empty transcript ids');
});
it('throws when the Fireflies api key is missing', async () => {
vi.stubEnv('FIREFLIES_API_KEY', '');
await expect(
firefliesBackfillBatchLogicFunction.config.handler(
{
transcriptIds: ['call-1'],
},
LOGIC_FUNCTION_EXECUTION_CONTEXT,
),
).rejects.toThrow('Fireflies is not configured');
});
});

View File

@@ -0,0 +1,152 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
buildListedTranscript,
serveFirefliesApi,
} from 'src/logic-functions/flows/__tests__/import-missing-fireflies-calls.test-support';
import firefliesBackfillWorkerLogicFunction from 'src/logic-functions/fireflies-backfill-worker';
import { LOGIC_FUNCTION_EXECUTION_CONTEXT } from 'src/logic-functions/__tests__/logic-function-execution-context.test-support';
const fetchMock = vi.fn();
const enqueueJobMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-sdk/logic-function', () => ({
enqueueJob: enqueueJobMock,
}));
describe('firefliesBackfillWorkerLogicFunction', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('fetch', fetchMock);
vi.stubEnv('FIREFLIES_API_KEY', 'fireflies-api-key');
vi.spyOn(console, 'log').mockImplementation(() => undefined);
fetchMock.mockImplementation(
async () => new Response('invalid request', { status: 400 }),
);
enqueueJobMock.mockResolvedValue({ enqueued: true });
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it('is configured as an enqueue-only discovery worker', () => {
expect(firefliesBackfillWorkerLogicFunction.config).toEqual(
expect.objectContaining({
name: 'fireflies-backfill-worker',
timeoutSeconds: 900,
}),
);
expect(firefliesBackfillWorkerLogicFunction.config).not.toHaveProperty(
'httpRouteTriggerSettings',
);
expect(firefliesBackfillWorkerLogicFunction.config).not.toHaveProperty(
'cronTriggerSettings',
);
});
it('rejects an invalid worker payload', async () => {
await expect(
firefliesBackfillWorkerLogicFunction.config.handler(
{ days: 0 },
LOGIC_FUNCTION_EXECUTION_CONTEXT,
),
).rejects.toThrow('requires a valid days window');
});
it('lists the requested window and enqueues every transcript batch', async () => {
const transcripts = Array.from({ length: 25 }, (_, callIndex) =>
buildListedTranscript(
`call-${callIndex}`,
Date.parse('2026-06-02T10:00:00.000Z'),
),
);
serveFirefliesApi([transcripts], fetchMock);
const result = await firefliesBackfillWorkerLogicFunction.config.handler(
{
days: 30,
},
LOGIC_FUNCTION_EXECUTION_CONTEXT,
);
expect(result).toEqual(
expect.objectContaining({
outcome: 'completed',
transcriptCount: 25,
batchCount: 2,
enqueuedBatchCount: 2,
}),
);
expect(enqueueJobMock).toHaveBeenCalledTimes(2);
expect(enqueueJobMock.mock.calls[0][0].payload.transcriptIds).toHaveLength(
20,
);
expect(enqueueJobMock.mock.calls[1][0].payload.transcriptIds).toHaveLength(
5,
);
const [, request] = fetchMock.mock.calls[0];
const { variables } = JSON.parse(String((request as RequestInit).body)) as {
variables: { fromDate: string; toDate: string };
};
expect(Date.parse(variables.toDate) - Date.parse(variables.fromDate)).toBe(
30 * 24 * 60 * 60 * 1_000,
);
});
it('fails discovery with partial enqueue progress so the queue retries it', async () => {
const transcripts = Array.from({ length: 25 }, (_, callIndex) =>
buildListedTranscript(
`call-${callIndex}`,
Date.parse('2026-06-02T10:00:00.000Z'),
),
);
serveFirefliesApi([transcripts], fetchMock);
enqueueJobMock
.mockResolvedValueOnce({ enqueued: true })
.mockRejectedValueOnce(new Error('Network failed'));
await expect(
firefliesBackfillWorkerLogicFunction.config.handler(
{ days: 30 },
LOGIC_FUNCTION_EXECUTION_CONTEXT,
),
).rejects.toThrow(
'Fireflies backfill enqueued 1 of 2 batches before enqueue failed: Network failed',
);
expect(enqueueJobMock).toHaveBeenCalledTimes(2);
});
it('fails discovery when Fireflies listing fails', async () => {
await expect(
firefliesBackfillWorkerLogicFunction.config.handler(
{ days: 30 },
LOGIC_FUNCTION_EXECUTION_CONTEXT,
),
).rejects.toThrow('Fireflies backfill listing failed');
expect(enqueueJobMock).not.toHaveBeenCalled();
});
it('skips discovery when the Fireflies api key is missing', async () => {
vi.stubEnv('FIREFLIES_API_KEY', '');
const result = await firefliesBackfillWorkerLogicFunction.config.handler(
{
days: 30,
},
LOGIC_FUNCTION_EXECUTION_CONTEXT,
);
expect(result).toEqual(
expect.objectContaining({ outcome: 'not-configured' }),
);
expect(fetchMock).not.toHaveBeenCalled();
expect(console.log).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type RoutePayload } from 'twenty-sdk/define';
import { FIREFLIES_BACKFILL_WORKER_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import firefliesBackfillLogicFunction from 'src/logic-functions/fireflies-backfill';
import { LOGIC_FUNCTION_EXECUTION_CONTEXT } from 'src/logic-functions/__tests__/logic-function-execution-context.test-support';
const enqueueJobMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-sdk/logic-function', () => ({
enqueueJob: enqueueJobMock,
}));
const buildRoutePayload = (body: object | null): RoutePayload<unknown> => ({
body,
headers: {},
queryStringParameters: {},
pathParameters: {},
isBase64Encoded: false,
rawBody: undefined,
requestContext: { http: { method: 'POST', path: '/' } },
userWorkspaceId: null,
});
describe('firefliesBackfillLogicFunction', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv('FIREFLIES_API_KEY', 'fireflies-api-key');
enqueueJobMock.mockResolvedValue({ enqueued: true });
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('is configured as an authenticated backfill route', () => {
expect(firefliesBackfillLogicFunction.config).toEqual(
expect.objectContaining({
name: 'fireflies-backfill',
timeoutSeconds: 900,
httpRouteTriggerSettings: {
path: '/fireflies/backfill',
httpMethod: 'POST',
isAuthRequired: true,
},
}),
);
});
it('rejects an empty route body', async () => {
const result = await firefliesBackfillLogicFunction.config.handler(
buildRoutePayload(null),
LOGIC_FUNCTION_EXECUTION_CONTEXT,
);
expect(result).toEqual({
outcome: 'invalid-request',
error: 'Fireflies backfill requires a days window between 1 and 3650',
});
expect(enqueueJobMock).not.toHaveBeenCalled();
});
it('returns not-configured without enqueueing when the API key is missing', async () => {
vi.stubEnv('FIREFLIES_API_KEY', '');
const result = await firefliesBackfillLogicFunction.config.handler(
buildRoutePayload({ days: 30 }),
LOGIC_FUNCTION_EXECUTION_CONTEXT,
);
expect(result).toEqual({
outcome: 'not-configured',
error: expect.stringContaining('Fireflies is not configured'),
});
expect(enqueueJobMock).not.toHaveBeenCalled();
});
it('enqueues discovery instead of listing transcripts in the route', async () => {
const result = await firefliesBackfillLogicFunction.config.handler(
buildRoutePayload({ days: 30 }),
LOGIC_FUNCTION_EXECUTION_CONTEXT,
);
expect(result).toEqual({ outcome: 'started' });
expect(enqueueJobMock).toHaveBeenCalledWith({
logicFunctionUniversalIdentifier:
FIREFLIES_BACKFILL_WORKER_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
payload: { days: 30 },
});
});
});

View File

@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import firefliesDailyHealerLogicFunction from 'src/logic-functions/fireflies-daily-healer';
import { LOGIC_FUNCTION_EXECUTION_CONTEXT } from 'src/logic-functions/__tests__/logic-function-execution-context.test-support';
const fetchMock = vi.fn();
const enqueueJobMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: class {},
}));
vi.mock('twenty-sdk/logic-function', () => ({
enqueueJob: enqueueJobMock,
}));
describe('firefliesDailyHealerLogicFunction', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('fetch', fetchMock);
vi.stubEnv('FIREFLIES_API_KEY', 'fireflies-api-key');
fetchMock.mockImplementation(
async () => new Response('invalid request', { status: 400 }),
);
enqueueJobMock.mockResolvedValue({ enqueued: true });
vi.spyOn(console, 'log').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it('is configured as the daily healing trigger', () => {
expect(firefliesDailyHealerLogicFunction.config).toEqual(
expect.objectContaining({
name: 'fireflies-daily-healer',
timeoutSeconds: 900,
cronTriggerSettings: {
pattern: '0 3 * * *',
},
}),
);
});
it('lists a fixed seven-day healing window', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: { transcripts: [] } }), {
status: 200,
}),
);
const result = await firefliesDailyHealerLogicFunction.config.handler(
{},
LOGIC_FUNCTION_EXECUTION_CONTEXT,
);
const [, request] = fetchMock.mock.calls[0];
const { variables } = JSON.parse(String((request as RequestInit).body)) as {
variables: { fromDate: string; toDate: string; skip: number };
};
expect(Date.parse(variables.toDate) - Date.parse(variables.fromDate)).toBe(
7 * 24 * 60 * 60 * 1_000,
);
expect(variables.skip).toBe(0);
expect(console.log).toHaveBeenCalledWith(
'[fireflies] Daily healing discovery finished',
result,
);
});
});

View File

@@ -0,0 +1,9 @@
import { type LogicFunctionExecutionContext } from 'twenty-sdk/logic-function';
export const LOGIC_FUNCTION_EXECUTION_CONTEXT: LogicFunctionExecutionContext = {
retryCount: 0,
maxRetries: 3,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
userWorkspaceId: null,
workspaceMemberId: null,
};

View File

@@ -0,0 +1,3 @@
export const CALL_RECORDING_REQUEST_STATUS = {
REQUESTED: 'REQUESTED',
} as const;

View File

@@ -0,0 +1,5 @@
// TODO: duplicated from core CallRecording select options — import once a shared package exists.
export const CALL_RECORDING_STATUS = {
PROCESSING: 'PROCESSING',
COMPLETED: 'COMPLETED',
} as const;

View File

@@ -0,0 +1 @@
export const FIREFLIES_API_MAX_ATTEMPTS = 3;

View File

@@ -0,0 +1 @@
export const FIREFLIES_API_RETRY_DELAY_MILLISECONDS = 250;

View File

@@ -0,0 +1 @@
export const FIREFLIES_BACKFILL_BATCH_RETRY_LIMIT = 2;

View File

@@ -0,0 +1 @@
export const FIREFLIES_BACKFILL_BATCH_SIZE = 20;

View File

@@ -0,0 +1 @@
export const FIREFLIES_BACKFILL_BATCH_STAGGER_MILLISECONDS = 60_000;

View File

@@ -0,0 +1,2 @@
export const FIREFLIES_BACKFILL_MAX_BATCH_DELAY_MILLISECONDS =
7 * 24 * 60 * 60 * 1_000;

View File

@@ -0,0 +1,2 @@
// Prevent an unbounded loop if Fireflies keeps returning full pages.
export const FIREFLIES_BACKFILL_MAX_PAGE_COUNT = 2_000;

View File

@@ -0,0 +1 @@
export const FIREFLIES_BACKFILL_PACING_MILLISECONDS = 1_000;

View File

@@ -0,0 +1 @@
export const FIREFLIES_BACKFILL_PAGE_SIZE = 50;

View File

@@ -0,0 +1 @@
export const FIREFLIES_BACKFILL_TIMEOUT_SECONDS = 900;

View File

@@ -0,0 +1,6 @@
import { type FirefliesSyncableField } from 'src/logic-functions/types/fireflies-syncable-field.type';
export const FIREFLIES_CALL_RECORDING_FIELDS: FirefliesSyncableField[] = [
'transcript',
'summary',
];

View File

@@ -0,0 +1 @@
export const FIREFLIES_DAILY_HEALER_CRON_PATTERN = '0 3 * * *';

View File

@@ -0,0 +1 @@
export const FIREFLIES_HEALING_WINDOW_DAYS = 7;

View File

@@ -0,0 +1 @@
export const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1_000;

View File

@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FIREFLIES_BACKFILL_BATCH_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { FIREFLIES_BACKFILL_BATCH_RETRY_LIMIT } from 'src/logic-functions/constants/fireflies-backfill-batch-retry-limit.constant';
import { FIREFLIES_BACKFILL_BATCH_STAGGER_MILLISECONDS } from 'src/logic-functions/constants/fireflies-backfill-batch-stagger-milliseconds.constant';
import { enqueueFirefliesBackfillBatches } from 'src/logic-functions/data/enqueue-fireflies-backfill-batches.util';
const enqueueJobMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-sdk/logic-function', () => ({
enqueueJob: enqueueJobMock,
}));
vi.mock(
'src/logic-functions/constants/fireflies-backfill-max-batch-delay-milliseconds.constant',
() => ({
FIREFLIES_BACKFILL_MAX_BATCH_DELAY_MILLISECONDS: 120_000,
}),
);
describe('enqueueFirefliesBackfillBatches', () => {
beforeEach(() => {
vi.clearAllMocks();
enqueueJobMock.mockResolvedValue({
enqueued: true,
logicFunctionUniversalIdentifier:
FIREFLIES_BACKFILL_BATCH_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
});
});
it('enqueues one staggered job per batch', async () => {
await enqueueFirefliesBackfillBatches({
transcriptIdBatches: [
['call-1', 'call-2'],
['call-3'],
['call-4'],
['call-5'],
],
});
expect(enqueueJobMock).toHaveBeenNthCalledWith(1, {
logicFunctionUniversalIdentifier:
FIREFLIES_BACKFILL_BATCH_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
payload: { transcriptIds: ['call-1', 'call-2'] },
retryLimit: FIREFLIES_BACKFILL_BATCH_RETRY_LIMIT,
delayMs: 0,
});
expect(enqueueJobMock).toHaveBeenNthCalledWith(2, {
logicFunctionUniversalIdentifier:
FIREFLIES_BACKFILL_BATCH_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
payload: { transcriptIds: ['call-3'] },
retryLimit: FIREFLIES_BACKFILL_BATCH_RETRY_LIMIT,
delayMs: FIREFLIES_BACKFILL_BATCH_STAGGER_MILLISECONDS,
});
expect(enqueueJobMock).toHaveBeenNthCalledWith(3, {
logicFunctionUniversalIdentifier:
FIREFLIES_BACKFILL_BATCH_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
payload: { transcriptIds: ['call-4'] },
retryLimit: FIREFLIES_BACKFILL_BATCH_RETRY_LIMIT,
delayMs: 2 * FIREFLIES_BACKFILL_BATCH_STAGGER_MILLISECONDS,
});
expect(enqueueJobMock).toHaveBeenNthCalledWith(4, {
logicFunctionUniversalIdentifier:
FIREFLIES_BACKFILL_BATCH_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
payload: { transcriptIds: ['call-5'] },
retryLimit: FIREFLIES_BACKFILL_BATCH_RETRY_LIMIT,
delayMs: 2 * FIREFLIES_BACKFILL_BATCH_STAGGER_MILLISECONDS,
});
});
it('throws partial progress without enqueueing later batches', async () => {
const enqueueError = new Error('Network failed');
enqueueJobMock
.mockResolvedValueOnce({
enqueued: true,
logicFunctionUniversalIdentifier:
FIREFLIES_BACKFILL_BATCH_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
})
.mockRejectedValueOnce(enqueueError);
await expect(
enqueueFirefliesBackfillBatches({
transcriptIdBatches: [['call-1'], ['call-2'], ['call-3']],
}),
).rejects.toMatchObject({
message:
'Fireflies backfill enqueued 1 of 3 batches before enqueue failed: Network failed',
cause: enqueueError,
});
expect(enqueueJobMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,36 @@
import { enqueueJob } from 'twenty-sdk/logic-function';
import { FIREFLIES_BACKFILL_BATCH_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { FIREFLIES_BACKFILL_BATCH_RETRY_LIMIT } from 'src/logic-functions/constants/fireflies-backfill-batch-retry-limit.constant';
import { FIREFLIES_BACKFILL_MAX_BATCH_DELAY_MILLISECONDS } from 'src/logic-functions/constants/fireflies-backfill-max-batch-delay-milliseconds.constant';
import { FIREFLIES_BACKFILL_BATCH_STAGGER_MILLISECONDS } from 'src/logic-functions/constants/fireflies-backfill-batch-stagger-milliseconds.constant';
export const enqueueFirefliesBackfillBatches = async ({
transcriptIdBatches,
}: {
transcriptIdBatches: string[][];
}): Promise<void> => {
for (const [batchIndex, transcriptIds] of transcriptIdBatches.entries()) {
try {
await enqueueJob({
logicFunctionUniversalIdentifier:
FIREFLIES_BACKFILL_BATCH_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
payload: { transcriptIds },
retryLimit: FIREFLIES_BACKFILL_BATCH_RETRY_LIMIT,
delayMs: Math.min(
batchIndex * FIREFLIES_BACKFILL_BATCH_STAGGER_MILLISECONDS,
FIREFLIES_BACKFILL_MAX_BATCH_DELAY_MILLISECONDS,
),
});
} catch (error) {
throw Object.assign(
new Error(
`Fireflies backfill enqueued ${batchIndex} of ${transcriptIdBatches.length} batches before enqueue failed: ${
error instanceof Error ? error.message : String(error)
}`,
),
{ cause: error },
);
}
}
};

View File

@@ -0,0 +1,52 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'src/utils/is-defined';
import { callRecordingFieldStateQueryResultSchema } from 'src/logic-functions/schemas/call-recording-field-state-query-result.schema';
import { type CallRecordingFieldState } from 'src/logic-functions/types/call-recording-field-state.type';
export const findCallRecordingFieldStatesOrThrow = async ({
coreApiClient,
callRecordingIds,
}: {
coreApiClient: CoreApiClient;
callRecordingIds: string[];
}): Promise<Map<string, CallRecordingFieldState>> => {
const fieldStateQueryResult = await coreApiClient.query({
callRecordings: {
__args: {
filter: { id: { in: callRecordingIds } },
first: callRecordingIds.length,
},
edges: {
node: {
id: true,
status: true,
transcript: true,
summary: {
markdown: true,
},
},
},
},
});
const parsedFieldStateQueryResult =
callRecordingFieldStateQueryResultSchema.parse(fieldStateQueryResult);
const callRecordingFieldStates = new Map<string, CallRecordingFieldState>();
for (const edge of parsedFieldStateQueryResult.callRecordings?.edges ?? []) {
const node = edge?.node;
if (!isDefined(node)) {
continue;
}
callRecordingFieldStates.set(node.id, {
isTranscriptFilled: isDefined(node.transcript),
isSummaryFilled: isNonEmptyString(node.summary?.markdown?.trim()),
status: node.status,
});
}
return callRecordingFieldStates;
};

View File

@@ -0,0 +1,15 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { FIREFLIES_BACKFILL_BATCH_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { FIREFLIES_BACKFILL_TIMEOUT_SECONDS } from 'src/logic-functions/constants/fireflies-backfill-timeout-seconds.constant';
import { firefliesBackfillBatchHandler } from 'src/logic-functions/handlers/fireflies-backfill-batch-handler';
export default defineLogicFunction({
universalIdentifier:
FIREFLIES_BACKFILL_BATCH_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'fireflies-backfill-batch',
description:
'Imports one enqueued batch of Fireflies calls into CallRecording records by transcript id.',
timeoutSeconds: FIREFLIES_BACKFILL_TIMEOUT_SECONDS,
handler: firefliesBackfillBatchHandler,
});

View File

@@ -0,0 +1,39 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { FIREFLIES_BACKFILL_WORKER_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { FIREFLIES_BACKFILL_TIMEOUT_SECONDS } from 'src/logic-functions/constants/fireflies-backfill-timeout-seconds.constant';
import { firefliesBackfillHandler } from 'src/logic-functions/handlers/fireflies-backfill-handler';
import { firefliesBackfillRequestBodySchema } from 'src/logic-functions/schemas/fireflies-backfill-request-body.schema';
import { type FirefliesBackfillResult } from 'src/logic-functions/types/fireflies-backfill-result.type';
const firefliesBackfillWorkerHandler = async (
payload: unknown,
): Promise<FirefliesBackfillResult> => {
const payloadParseResult =
firefliesBackfillRequestBodySchema.safeParse(payload);
if (!payloadParseResult.success) {
throw new Error('Fireflies backfill worker requires a valid days window');
}
const firefliesBackfillResult = await firefliesBackfillHandler({
windowDays: payloadParseResult.data.days,
});
console.log(
'[fireflies] Backfill discovery finished',
firefliesBackfillResult,
);
return firefliesBackfillResult;
};
export default defineLogicFunction({
universalIdentifier:
FIREFLIES_BACKFILL_WORKER_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'fireflies-backfill-worker',
description:
'Lists Fireflies transcript ids for a requested window and enqueues one import job per batch.',
timeoutSeconds: FIREFLIES_BACKFILL_TIMEOUT_SECONDS,
handler: firefliesBackfillWorkerHandler,
});

View File

@@ -0,0 +1,59 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { enqueueJob } from 'twenty-sdk/logic-function';
import {
FIREFLIES_BACKFILL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
FIREFLIES_BACKFILL_WORKER_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { FIREFLIES_BACKFILL_ROUTE_PATH } from 'src/constants/fireflies-backfill-route-path.constant';
import { FIREFLIES_BACKFILL_MAX_WINDOW_DAYS } from 'src/constants/fireflies-backfill-max-window-days.constant';
import { FIREFLIES_BACKFILL_OUTCOME } from 'src/constants/fireflies-backfill-outcome.constant';
import { FIREFLIES_BACKFILL_TIMEOUT_SECONDS } from 'src/logic-functions/constants/fireflies-backfill-timeout-seconds.constant';
import { firefliesBackfillRequestBodySchema } from 'src/logic-functions/schemas/fireflies-backfill-request-body.schema';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
const firefliesBackfillRequestHandler = async (
payload: RoutePayload<unknown>,
) => {
const requestBodyParseResult = firefliesBackfillRequestBodySchema.safeParse(
payload.body,
);
if (!requestBodyParseResult.success) {
return {
outcome: FIREFLIES_BACKFILL_OUTCOME.INVALID_REQUEST,
error: `Fireflies backfill requires a days window between 1 and ${FIREFLIES_BACKFILL_MAX_WINDOW_DAYS}`,
};
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return {
outcome: FIREFLIES_BACKFILL_OUTCOME.NOT_CONFIGURED,
error: apiKeyResult.error,
};
}
await enqueueJob({
logicFunctionUniversalIdentifier:
FIREFLIES_BACKFILL_WORKER_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
payload: { days: requestBodyParseResult.data.days },
});
return { outcome: FIREFLIES_BACKFILL_OUTCOME.STARTED };
};
export default defineLogicFunction({
universalIdentifier: FIREFLIES_BACKFILL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'fireflies-backfill',
description:
'Enqueues a background worker that discovers and imports missing Fireflies calls for the requested days window.',
timeoutSeconds: FIREFLIES_BACKFILL_TIMEOUT_SECONDS,
handler: firefliesBackfillRequestHandler,
httpRouteTriggerSettings: {
path: FIREFLIES_BACKFILL_ROUTE_PATH,
httpMethod: 'POST',
isAuthRequired: true,
},
});

View File

@@ -0,0 +1,35 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { FIREFLIES_DAILY_HEALER_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { FIREFLIES_BACKFILL_TIMEOUT_SECONDS } from 'src/logic-functions/constants/fireflies-backfill-timeout-seconds.constant';
import { FIREFLIES_DAILY_HEALER_CRON_PATTERN } from 'src/logic-functions/constants/fireflies-daily-healer-cron-pattern.constant';
import { FIREFLIES_HEALING_WINDOW_DAYS } from 'src/logic-functions/constants/fireflies-healing-window-days.constant';
import { firefliesBackfillHandler } from 'src/logic-functions/handlers/fireflies-backfill-handler';
import { type FirefliesBackfillResult } from 'src/logic-functions/types/fireflies-backfill-result.type';
const firefliesDailyHealerCronHandler =
async (): Promise<FirefliesBackfillResult> => {
const firefliesBackfillResult = await firefliesBackfillHandler({
windowDays: FIREFLIES_HEALING_WINDOW_DAYS,
});
console.log(
'[fireflies] Daily healing discovery finished',
firefliesBackfillResult,
);
return firefliesBackfillResult;
};
export default defineLogicFunction({
universalIdentifier:
FIREFLIES_DAILY_HEALER_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'fireflies-daily-healer',
description:
'Daily sweep that enqueues import batches for Fireflies calls missed by webhooks over the last seven days.',
timeoutSeconds: FIREFLIES_BACKFILL_TIMEOUT_SECONDS,
handler: firefliesDailyHealerCronHandler,
cronTriggerSettings: {
pattern: FIREFLIES_DAILY_HEALER_CRON_PATTERN,
},
});

View File

@@ -0,0 +1,53 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'src/logic-functions/utils/json-schema-to-input-schema';
import { FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesListCallsByParticipantHandler } from 'src/logic-functions/handlers/fireflies-list-calls-by-participant-handler';
import { firefliesListCallsByParticipantInputSchema } from 'src/logic-functions/schemas/fireflies-list-calls-by-participant-input.schema';
const callSummaryProperties = {
id: { type: 'string' },
title: { type: 'string' },
date: { type: 'string' },
durationMinutes: { type: 'number' },
participants: { type: 'array', items: { type: 'string' } },
hostEmail: { type: 'string' },
transcriptUrl: { type: 'string' },
meetingLink: { type: 'string' },
} as const;
export default defineLogicFunction({
universalIdentifier: FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER,
name: 'fireflies-list-calls-by-participant',
description:
'List Fireflies calls that include a given participant email. Returns each call\'s ID, title, date, duration, participants, host, Fireflies transcript URL, and original meeting link. Use this to answer "what calls have we had with this contact?" — for example as the first step of a workflow triggered when a Person record is created.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: firefliesListCallsByParticipantInputSchema,
},
workflowActionTriggerSettings: {
label: 'List Fireflies Calls By Participant',
inputSchema: jsonSchemaToInputSchema(
firefliesListCallsByParticipantInputSchema,
),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
count: { type: 'number' },
calls: {
type: 'array',
items: {
type: 'object',
properties: callSummaryProperties,
},
},
},
},
],
},
handler: firefliesListCallsByParticipantHandler,
});

View File

@@ -0,0 +1,51 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'src/logic-functions/utils/json-schema-to-input-schema';
import { FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesSearchCallsHandler } from 'src/logic-functions/handlers/fireflies-search-calls-handler';
import { firefliesSearchCallsInputSchema } from 'src/logic-functions/schemas/fireflies-search-calls-input.schema';
const callSummaryProperties = {
id: { type: 'string' },
title: { type: 'string' },
date: { type: 'string' },
durationMinutes: { type: 'number' },
participants: { type: 'array', items: { type: 'string' } },
hostEmail: { type: 'string' },
transcriptUrl: { type: 'string' },
meetingLink: { type: 'string' },
} as const;
export default defineLogicFunction({
universalIdentifier: FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER,
name: 'fireflies-search-calls',
description:
'Search Fireflies calls by keyword. Matches the keyword against both meeting titles and the words spoken during meetings (full transcript content). Returns each match\'s ID, title, date, duration, participants, host, Fireflies transcript URL, and meeting link. Use this for AI-chat questions like "find any call where we discussed pricing".',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: firefliesSearchCallsInputSchema,
},
workflowActionTriggerSettings: {
label: 'Search Fireflies Calls',
inputSchema: jsonSchemaToInputSchema(firefliesSearchCallsInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
count: { type: 'number' },
calls: {
type: 'array',
items: {
type: 'object',
properties: callSummaryProperties,
},
},
},
},
],
},
handler: firefliesSearchCallsHandler,
});

View File

@@ -0,0 +1,48 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'src/logic-functions/utils/json-schema-to-input-schema';
import { FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesSyncCallHandler } from 'src/logic-functions/handlers/fireflies-sync-call-handler';
import { firefliesSyncCallInputSchema } from 'src/logic-functions/schemas/fireflies-sync-call-input.schema';
export default defineLogicFunction({
universalIdentifier: FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER,
name: 'fireflies-sync-call',
description:
'Sync a single Fireflies call into a CallRecording record on demand: fetches both transcript and AI summary from Fireflies and upserts them onto one CallRecording linked to the matching CalendarEvent. Same matching rules as the webhook (Fireflies calendar_id / cal_id ↔ Twenty eventExternalId or iCalUid). Useful for backfilling history, recovering from a missed webhook, or syncing on a workflow trigger instead of waiting for Fireflies to push.',
timeoutSeconds: 60,
toolTriggerSettings: {
inputSchema: firefliesSyncCallInputSchema,
},
workflowActionTriggerSettings: {
label: 'Sync Fireflies Call',
inputSchema: jsonSchemaToInputSchema(firefliesSyncCallInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
transcriptId: { type: 'string' },
callRecordingId: { type: 'string' },
calendarEventId: { type: 'string' },
updatedFields: { type: 'array', items: { type: 'string' } },
fieldOutcomes: {
type: 'array',
items: {
type: 'object',
properties: {
field: { type: 'string' },
status: { type: 'string' },
reason: { type: 'string' },
error: { type: 'string' },
},
},
},
},
},
],
},
handler: firefliesSyncCallHandler,
});

View File

@@ -0,0 +1,68 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { isDefined } from 'src/utils/is-defined';
import { FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesWebhookHandler } from 'src/logic-functions/handlers/fireflies-webhook-handler';
import {
type FirefliesWebhookPayload,
type FirefliesWebhookResult,
} from 'src/logic-functions/types/fireflies-webhook-payload.type';
import { getFirefliesWebhookSecret } from 'src/logic-functions/utils/get-fireflies-webhook-secret';
import { verifyFirefliesWebhookSignature } from 'src/logic-functions/utils/verify-fireflies-webhook-signature';
const firefliesWebhookRouteHandler = async (
routePayload: RoutePayload<FirefliesWebhookPayload>,
): Promise<FirefliesWebhookResult> => {
const secretResult = getFirefliesWebhookSecret();
if (!secretResult.success) {
return { error: secretResult.error };
}
const { rawBody } = routePayload;
if (!isDefined(rawBody)) {
return {
error:
'Invalid webhook signature: raw request body was not forwarded by the server, cannot verify HMAC',
};
}
const signatureHeader = routePayload.headers['x-hub-signature'];
const signatureCheck = verifyFirefliesWebhookSignature({
rawBody,
signatureHeader,
secret: secretResult.secret,
});
if (!signatureCheck.valid) {
return { error: `Invalid webhook signature: ${signatureCheck.error}` };
}
const body = routePayload.body;
if (!isDefined(body)) {
return { error: 'Webhook payload was empty' };
}
return firefliesWebhookHandler({
meetingId: body.meeting_id,
eventType: body.event,
});
};
export default defineLogicFunction({
universalIdentifier: FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'fireflies-webhook',
description:
'Receives Fireflies webhook events when a transcript or summary is ready, then upserts a CallRecording record linked to the matching CalendarEvent.',
timeoutSeconds: 60,
handler: firefliesWebhookRouteHandler,
httpRouteTriggerSettings: {
path: '/webhook/fireflies',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: ['x-hub-signature'],
},
});

View File

@@ -0,0 +1,160 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant';
import {
answerTwentyQueries,
buildGraphqlResponse,
FIREFLIES_API_KEY,
FIREFLIES_BACKFILL_FROM_DATE,
setUpImportMissingFirefliesCallsTest,
skipSleep,
} from 'src/logic-functions/flows/__tests__/import-missing-fireflies-calls.test-support';
import { importMissingFirefliesCalls } from 'src/logic-functions/flows/import-missing-fireflies-calls.util';
import { computeCallRecordingIdForFirefliesMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting';
const queryMock = vi.hoisted(() => vi.fn());
const mutationMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: class {
query = queryMock;
mutation = mutationMock;
},
}));
const fetchMock = vi.fn();
describe('importMissingFirefliesCalls retries', () => {
beforeEach(() => {
setUpImportMissingFirefliesCallsTest({
fetchMock,
queryMock,
mutationMock,
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('aborts the batch on a Fireflies rate limit', async () => {
fetchMock.mockImplementation(
async () =>
new Response(
JSON.stringify({ errors: [{ message: 'too many requests' }] }),
{ status: 429 },
),
);
vi.stubGlobal('fetch', fetchMock);
const { CoreApiClient } = await import('twenty-client-sdk/core');
const result = await importMissingFirefliesCalls({
apiKey: FIREFLIES_API_KEY,
coreApiClient: new CoreApiClient(),
transcriptIds: ['call-newer', 'call-older'],
sleep: skipSleep,
});
expect(result).toEqual({
status: 'retryable-error',
importedCallCount: 0,
erroredCallCount: 0,
skippedCallCount: 0,
});
});
it('keeps the counts of calls synced before a rate limit aborts the batch', async () => {
let isFirstCall = true;
fetchMock.mockImplementation(async () => {
if (isFirstCall) {
isFirstCall = false;
return buildGraphqlResponse({
transcript: {
id: 'call-1',
title: 'Call 1',
date: Date.parse(FIREFLIES_BACKFILL_FROM_DATE),
duration: 30,
meeting_link: null,
participants: ['a@example.com'],
organizer_email: 'a@example.com',
calendar_id: null,
cal_id: null,
calendar_type: null,
sentences: [
{ speaker_name: 'A', text: 'hello', start_time: 0, end_time: 1 },
],
summary: { overview: 'Summary' },
},
});
}
return new Response(
JSON.stringify({ errors: [{ message: 'too many requests' }] }),
{ status: 429 },
);
});
vi.stubGlobal('fetch', fetchMock);
answerTwentyQueries({
queryMock,
callRecordings: [
{
id: computeCallRecordingIdForFirefliesMeeting('call-1'),
status: CALL_RECORDING_STATUS.PROCESSING,
transcript: null,
summary: { markdown: 'Already imported' },
},
],
});
const { CoreApiClient } = await import('twenty-client-sdk/core');
const result = await importMissingFirefliesCalls({
apiKey: FIREFLIES_API_KEY,
coreApiClient: new CoreApiClient(),
transcriptIds: ['call-1', 'call-2'],
sleep: skipSleep,
});
expect(result).toEqual({
status: 'retryable-error',
importedCallCount: 1,
erroredCallCount: 0,
skippedCallCount: 0,
});
});
it('aborts the batch on a persistent Fireflies server error', async () => {
fetchMock.mockImplementation(
async () =>
new Response(
JSON.stringify({
errors: [
{
message: 'Temporary Fireflies failure',
extensions: { code: 'invariant_violation' },
},
],
}),
{ status: 200 },
),
);
vi.stubGlobal('fetch', fetchMock);
const { CoreApiClient } = await import('twenty-client-sdk/core');
const result = await importMissingFirefliesCalls({
apiKey: FIREFLIES_API_KEY,
coreApiClient: new CoreApiClient(),
transcriptIds: ['call-with-server-error'],
sleep: skipSleep,
});
expect(result).toEqual({
status: 'retryable-error',
importedCallCount: 0,
erroredCallCount: 0,
skippedCallCount: 0,
});
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});

View File

@@ -0,0 +1,171 @@
import { type Mock, vi } from 'vitest';
export const FIREFLIES_API_KEY = 'fireflies-api-key';
export const FIREFLIES_BACKFILL_FROM_DATE = '2026-05-01T00:00:00.000Z';
export const FIREFLIES_BACKFILL_TO_DATE = '2026-07-30T00:00:00.000Z';
export const skipSleep = async (): Promise<void> => {};
type RawListedTranscript = {
id: string;
title: string;
date: number;
duration: number;
participants: string[];
host_email: string | null;
transcript_url: string | null;
meeting_link: string | null;
};
export const buildListedTranscript = (
id: string,
dateMilliseconds: number,
): RawListedTranscript => ({
id,
title: `Call ${id}`,
date: dateMilliseconds,
duration: 30,
participants: ['a@example.com'],
host_email: 'a@example.com',
transcript_url: null,
meeting_link: null,
});
export const buildGraphqlResponse = (data: object): Response =>
new Response(JSON.stringify({ data }), { status: 200 });
export const serveFirefliesApi = (
pages: RawListedTranscript[][],
fetchMock: Mock,
) => {
let listRequestIndex = 0;
fetchMock.mockImplementation(async (_url: string, init: RequestInit) => {
const body = JSON.parse(String(init.body)) as {
query: string;
variables?: { transcriptId?: string };
};
if (body.query.includes('query Transcripts(')) {
const page = pages[listRequestIndex] ?? [];
listRequestIndex += 1;
return buildGraphqlResponse({ transcripts: page });
}
return buildGraphqlResponse({
transcript: {
id: body.variables?.transcriptId ?? 'detail',
title: 'Call detail',
date: Date.parse(FIREFLIES_BACKFILL_FROM_DATE),
duration: 30,
meeting_link: null,
participants: ['a@example.com'],
organizer_email: 'a@example.com',
calendar_id: null,
cal_id: null,
calendar_type: null,
sentences: [
{
speaker_name: 'A',
text: 'hello',
start_time: 0,
end_time: 1,
},
],
summary: null,
},
});
});
};
type StoredCallRecording = {
id: string;
status?: string;
transcript?: unknown;
summary?: { markdown: string } | null;
};
export const answerTwentyQueries = ({
queryMock,
callRecordings = [],
}: {
queryMock: Mock;
callRecordings?: StoredCallRecording[];
}) => {
queryMock.mockImplementation(
async (query: {
callRecordings?: {
__args?: {
filter?: {
id?: { in?: string[]; eq?: string };
};
};
};
}) => {
const idFilter = query.callRecordings?.__args?.filter?.id;
if (Array.isArray(idFilter?.in)) {
return {
callRecordings: {
edges: callRecordings
.filter(({ id }) => idFilter.in?.includes(id))
.map((node) => ({ node })),
},
};
}
if (idFilter?.eq) {
const callRecording = callRecordings.find(
({ id }) => id === idFilter.eq,
);
return {
callRecordings: {
edges: callRecording ? [{ node: callRecording }] : [],
},
};
}
return {
calendarChannelEventAssociations: { edges: [] },
calendarEvents: { edges: [] },
};
},
);
};
export const getListRequestVariables = (
fetchMock: Mock,
): Array<{
fromDate?: string;
skip?: number;
toDate?: string;
}> =>
fetchMock.mock.calls
.map(([, init]) => JSON.parse(String((init as RequestInit).body)))
.filter((body: { query: string }) =>
body.query.includes('query Transcripts('),
)
.map(
(body: { variables: { skip?: number; toDate?: string } }) =>
body.variables,
);
export const setUpImportMissingFirefliesCallsTest = ({
fetchMock,
queryMock,
mutationMock,
}: {
fetchMock: Mock;
queryMock: Mock;
mutationMock: Mock;
}) => {
vi.clearAllMocks();
vi.stubGlobal('fetch', fetchMock);
answerTwentyQueries({ queryMock });
mutationMock.mockResolvedValue({
createCallRecording: { id: 'created' },
});
};

View File

@@ -0,0 +1,321 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant';
import {
answerTwentyQueries,
buildGraphqlResponse,
FIREFLIES_API_KEY,
serveFirefliesApi,
setUpImportMissingFirefliesCallsTest,
skipSleep,
} from 'src/logic-functions/flows/__tests__/import-missing-fireflies-calls.test-support';
import { importMissingFirefliesCalls } from 'src/logic-functions/flows/import-missing-fireflies-calls.util';
import { computeCallRecordingIdForFirefliesMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting';
const queryMock = vi.hoisted(() => vi.fn());
const mutationMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: class {
query = queryMock;
mutation = mutationMock;
},
}));
const fetchMock = vi.fn();
describe('importMissingFirefliesCalls synchronization', () => {
beforeEach(() => {
setUpImportMissingFirefliesCallsTest({
fetchMock,
queryMock,
mutationMock,
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('imports every call in the batch', async () => {
serveFirefliesApi([], fetchMock);
const { CoreApiClient } = await import('twenty-client-sdk/core');
const result = await importMissingFirefliesCalls({
apiKey: FIREFLIES_API_KEY,
coreApiClient: new CoreApiClient(),
transcriptIds: ['call-1', 'call-2'],
sleep: skipSleep,
});
expect(result).toEqual({
status: 'completed',
importedCallCount: 2,
erroredCallCount: 0,
skippedCallCount: 0,
});
expect(mutationMock).toHaveBeenCalledTimes(2);
expect(
mutationMock.mock.calls.map(
([mutation]) =>
mutation.createCallRecording.__args.data.externalRecordingId,
),
).toEqual(['call-1', 'call-2']);
});
it('imports duplicate transcript ids only once', async () => {
serveFirefliesApi([], fetchMock);
const { CoreApiClient } = await import('twenty-client-sdk/core');
const result = await importMissingFirefliesCalls({
apiKey: FIREFLIES_API_KEY,
coreApiClient: new CoreApiClient(),
transcriptIds: ['call-1', 'call-1'],
sleep: skipSleep,
});
expect(result).toEqual({
status: 'completed',
importedCallCount: 1,
erroredCallCount: 0,
skippedCallCount: 0,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(mutationMock).toHaveBeenCalledTimes(1);
});
it('keeps a call recording processing while its Fireflies summary is pending', async () => {
serveFirefliesApi([], fetchMock);
const { CoreApiClient } = await import('twenty-client-sdk/core');
await importMissingFirefliesCalls({
apiKey: FIREFLIES_API_KEY,
coreApiClient: new CoreApiClient(),
transcriptIds: ['call-with-pending-summary'],
sleep: skipSleep,
});
expect(mutationMock).toHaveBeenCalledWith({
createCallRecording: {
__args: {
data: expect.objectContaining({
status: CALL_RECORDING_STATUS.PROCESSING,
transcript: expect.any(Array),
}),
},
id: true,
},
});
});
it('skips complete calls without calling Fireflies', async () => {
serveFirefliesApi([], fetchMock);
answerTwentyQueries({
queryMock,
callRecordings: ['call-1', 'call-2'].map((callId) => ({
id: computeCallRecordingIdForFirefliesMeeting(callId),
transcript: [{ text: 'already imported' }],
summary: { markdown: 'Already imported' },
})),
});
const { CoreApiClient } = await import('twenty-client-sdk/core');
const result = await importMissingFirefliesCalls({
apiKey: FIREFLIES_API_KEY,
coreApiClient: new CoreApiClient(),
transcriptIds: ['call-1', 'call-2'],
sleep: skipSleep,
});
expect(result).toEqual({
status: 'completed',
importedCallCount: 0,
erroredCallCount: 0,
skippedCallCount: 2,
});
expect(mutationMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it('syncs only the missing field of a partial call recording', async () => {
const callId = 'partially-imported-call';
const callRecordingId = computeCallRecordingIdForFirefliesMeeting(callId);
const callDateMilliseconds = Date.parse('2026-06-02T10:00:00.000Z');
let summaryRequestCount = 0;
let transcriptRequestCount = 0;
fetchMock.mockImplementation(async (_url: string, init: RequestInit) => {
const body = JSON.parse(String(init.body)) as { query: string };
if (body.query.includes('sentences {')) {
transcriptRequestCount += 1;
}
summaryRequestCount += 1;
return buildGraphqlResponse({
transcript: {
id: callId,
title: 'Partially imported call',
date: callDateMilliseconds,
duration: 30,
meeting_link: null,
participants: ['a@example.com'],
organizer_email: 'a@example.com',
calendar_id: null,
cal_id: null,
calendar_type: null,
summary: { overview: 'Recovered summary' },
},
});
});
vi.stubGlobal('fetch', fetchMock);
answerTwentyQueries({
queryMock,
callRecordings: [
{
id: callRecordingId,
status: 'COMPLETED',
transcript: [{ text: 'Already imported' }],
summary: null,
},
],
});
const { CoreApiClient } = await import('twenty-client-sdk/core');
const result = await importMissingFirefliesCalls({
apiKey: FIREFLIES_API_KEY,
coreApiClient: new CoreApiClient(),
transcriptIds: [callId],
sleep: skipSleep,
});
expect(result).toEqual(
expect.objectContaining({
status: 'completed',
importedCallCount: 1,
}),
);
expect(transcriptRequestCount).toBe(0);
expect(summaryRequestCount).toBe(1);
expect(mutationMock).toHaveBeenCalledWith({
updateCallRecording: {
__args: {
id: callRecordingId,
data: expect.objectContaining({
status: CALL_RECORDING_STATUS.COMPLETED,
summary: expect.objectContaining({
markdown: expect.stringContaining('Recovered summary'),
}),
}),
},
id: true,
},
});
});
it('moves a completed legacy recording back to processing while its summary is unavailable', async () => {
const callId = 'legacy-transcript-only-call';
const callRecordingId = computeCallRecordingIdForFirefliesMeeting(callId);
const callDateMilliseconds = Date.parse('2026-06-02T10:00:00.000Z');
fetchMock.mockResolvedValue(
buildGraphqlResponse({
transcript: {
id: callId,
title: 'Legacy transcript-only call',
date: callDateMilliseconds,
duration: 30,
meeting_link: null,
participants: ['a@example.com'],
organizer_email: 'a@example.com',
calendar_id: null,
cal_id: null,
calendar_type: null,
summary: null,
},
}),
);
vi.stubGlobal('fetch', fetchMock);
answerTwentyQueries({
queryMock,
callRecordings: [
{
id: callRecordingId,
status: CALL_RECORDING_STATUS.COMPLETED,
transcript: [{ text: 'Already imported' }],
summary: null,
},
],
});
const { CoreApiClient } = await import('twenty-client-sdk/core');
await importMissingFirefliesCalls({
apiKey: FIREFLIES_API_KEY,
coreApiClient: new CoreApiClient(),
transcriptIds: [callId],
sleep: skipSleep,
});
expect(mutationMock).toHaveBeenCalledWith({
updateCallRecording: {
__args: {
id: callRecordingId,
data: expect.objectContaining({
status: CALL_RECORDING_STATUS.PROCESSING,
}),
},
id: true,
},
});
});
it('keeps a transcript processing when its summary remains unavailable', async () => {
const callId = 'call-with-skipped-summary';
const callRecordingId = computeCallRecordingIdForFirefliesMeeting(callId);
const callDateMilliseconds = Date.parse('2026-06-02T10:00:00.000Z');
fetchMock.mockResolvedValue(
buildGraphqlResponse({
transcript: {
id: callId,
title: 'Call with skipped summary',
date: callDateMilliseconds,
duration: 30,
meeting_link: null,
participants: ['a@example.com'],
organizer_email: 'a@example.com',
calendar_id: null,
cal_id: null,
calendar_type: null,
summary: null,
},
}),
);
vi.stubGlobal('fetch', fetchMock);
answerTwentyQueries({
queryMock,
callRecordings: [
{
id: callRecordingId,
status: CALL_RECORDING_STATUS.PROCESSING,
transcript: [{ text: 'Already imported' }],
summary: null,
},
],
});
const { CoreApiClient } = await import('twenty-client-sdk/core');
await importMissingFirefliesCalls({
apiKey: FIREFLIES_API_KEY,
coreApiClient: new CoreApiClient(),
transcriptIds: [callId],
sleep: skipSleep,
});
expect(mutationMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,38 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { sleepForMilliseconds } from 'src/utils/sleep-for-milliseconds.util';
import { findCallRecordingFieldStatesOrThrow } from 'src/logic-functions/data/find-call-recording-field-states-or-throw.util';
import { syncFirefliesTranscriptBatch } from 'src/logic-functions/flows/sync-fireflies-transcript-batch.util';
import { type ImportMissingFirefliesCallsResult } from 'src/logic-functions/types/import-missing-fireflies-calls-result.type';
import { computeCallRecordingIdForFirefliesMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting';
type ImportMissingFirefliesCallsParams = {
apiKey: string;
coreApiClient: CoreApiClient;
transcriptIds: string[];
sleep?: (milliseconds: number) => Promise<void>;
};
export const importMissingFirefliesCalls = async ({
apiKey,
coreApiClient,
transcriptIds,
sleep = sleepForMilliseconds,
}: ImportMissingFirefliesCallsParams): Promise<ImportMissingFirefliesCallsResult> => {
const uniqueTranscriptIds = [...new Set(transcriptIds)];
const callRecordingFieldStates = await findCallRecordingFieldStatesOrThrow({
coreApiClient,
callRecordingIds: uniqueTranscriptIds.map(
computeCallRecordingIdForFirefliesMeeting,
),
});
return syncFirefliesTranscriptBatch({
apiKey,
coreApiClient,
transcriptIds: uniqueTranscriptIds,
callRecordingFieldStates,
sleep,
});
};

View File

@@ -0,0 +1,215 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'src/utils/is-defined';
import { CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant';
import { FIREFLIES_BACKFILL_PACING_MILLISECONDS } from 'src/logic-functions/constants/fireflies-backfill-pacing-milliseconds.constant';
import { FIREFLIES_CALL_RECORDING_FIELDS } from 'src/logic-functions/constants/fireflies-call-recording-fields.constant';
import { type CallRecordingFieldState } from 'src/logic-functions/types/call-recording-field-state.type';
import { type FirefliesSyncableField } from 'src/logic-functions/types/fireflies-syncable-field.type';
import { type ImportMissingFirefliesCallsResult } from 'src/logic-functions/types/import-missing-fireflies-calls-result.type';
import { type SyncFirefliesCallResult } from 'src/logic-functions/types/sync-fireflies-call-result.type';
import { computeCallRecordingIdForFirefliesMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting';
import { isRetryableFirefliesApiStatus } from 'src/logic-functions/utils/is-retryable-fireflies-api-status.util';
import { syncFirefliesCallToCallRecording } from 'src/logic-functions/utils/sync-fireflies-call-to-call-recording.util';
type FirefliesCallSyncOutcome = 'imported' | 'errored' | 'skipped';
type SyncMissingFirefliesCallRecordingFieldsParams = {
apiKey: string;
coreApiClient: CoreApiClient;
transcriptId: string;
firefliesCallRecordingFields: FirefliesSyncableField[];
callRecordingFieldState: CallRecordingFieldState | undefined;
sleep: (milliseconds: number) => Promise<void>;
};
type SyncFirefliesTranscriptBatchParams = {
apiKey: string;
coreApiClient: CoreApiClient;
transcriptIds: string[];
callRecordingFieldStates: Map<string, CallRecordingFieldState>;
sleep: (milliseconds: number) => Promise<void>;
};
const getMissingFirefliesCallRecordingFields = (
callRecordingFieldState: CallRecordingFieldState | undefined,
): FirefliesSyncableField[] => {
if (!isDefined(callRecordingFieldState)) {
return FIREFLIES_CALL_RECORDING_FIELDS;
}
return FIREFLIES_CALL_RECORDING_FIELDS.filter(
(firefliesCallRecordingField) =>
(firefliesCallRecordingField === 'transcript' &&
!callRecordingFieldState.isTranscriptFilled) ||
(firefliesCallRecordingField === 'summary' &&
!callRecordingFieldState.isSummaryFilled),
);
};
const getFirefliesCallSyncOutcome = (
firefliesFieldSyncResults: SyncFirefliesCallResult[],
): FirefliesCallSyncOutcome => {
if (
firefliesFieldSyncResults.some(
(firefliesFieldSyncResult) => firefliesFieldSyncResult.status === 'error',
)
) {
return 'errored';
}
if (
firefliesFieldSyncResults.some(
(firefliesFieldSyncResult) =>
firefliesFieldSyncResult.status === 'updated',
)
) {
return 'imported';
}
return 'skipped';
};
const buildCurrentCallRecordingFieldState = ({
callRecordingFieldState,
firefliesFieldSyncResults,
}: {
callRecordingFieldState: CallRecordingFieldState | undefined;
firefliesFieldSyncResults: SyncFirefliesCallResult[];
}): CallRecordingFieldState => {
const isTranscriptFilled =
(callRecordingFieldState?.isTranscriptFilled ?? false) ||
firefliesFieldSyncResults.some(
(firefliesFieldSyncResult) =>
firefliesFieldSyncResult.status === 'updated' &&
firefliesFieldSyncResult.field === 'transcript',
);
const isSummaryFilled =
(callRecordingFieldState?.isSummaryFilled ?? false) ||
firefliesFieldSyncResults.some(
(firefliesFieldSyncResult) =>
firefliesFieldSyncResult.status === 'updated' &&
firefliesFieldSyncResult.field === 'summary',
);
if (firefliesFieldSyncResults.length === 0) {
return {
isTranscriptFilled,
isSummaryFilled,
status: callRecordingFieldState?.status,
};
}
return {
isTranscriptFilled,
isSummaryFilled,
status:
isTranscriptFilled && isSummaryFilled
? CALL_RECORDING_STATUS.COMPLETED
: CALL_RECORDING_STATUS.PROCESSING,
};
};
const syncMissingFirefliesCallRecordingFields = async ({
apiKey,
coreApiClient,
transcriptId,
firefliesCallRecordingFields,
callRecordingFieldState,
sleep,
}: SyncMissingFirefliesCallRecordingFieldsParams): Promise<
FirefliesCallSyncOutcome | 'retryable-error'
> => {
const firefliesFieldSyncResults: SyncFirefliesCallResult[] = [];
for (const firefliesCallRecordingField of firefliesCallRecordingFields) {
const currentCallRecordingFieldState = buildCurrentCallRecordingFieldState({
callRecordingFieldState,
firefliesFieldSyncResults,
});
const firefliesFieldSyncResult = await syncFirefliesCallToCallRecording({
apiKey,
coreApiClient,
transcriptId,
field: firefliesCallRecordingField,
callRecordingFieldState: currentCallRecordingFieldState,
});
if (
firefliesFieldSyncResult.status === 'error' &&
isDefined(firefliesFieldSyncResult.httpStatus) &&
isRetryableFirefliesApiStatus(firefliesFieldSyncResult.httpStatus)
) {
return 'retryable-error';
}
firefliesFieldSyncResults.push(firefliesFieldSyncResult);
await sleep(FIREFLIES_BACKFILL_PACING_MILLISECONDS);
}
return getFirefliesCallSyncOutcome(firefliesFieldSyncResults);
};
const countFirefliesCallSyncOutcomes = (
firefliesCallSyncOutcomes: FirefliesCallSyncOutcome[],
) => ({
importedCallCount: firefliesCallSyncOutcomes.filter(
(firefliesCallSyncOutcome) => firefliesCallSyncOutcome === 'imported',
).length,
erroredCallCount: firefliesCallSyncOutcomes.filter(
(firefliesCallSyncOutcome) => firefliesCallSyncOutcome === 'errored',
).length,
skippedCallCount: firefliesCallSyncOutcomes.filter(
(firefliesCallSyncOutcome) => firefliesCallSyncOutcome === 'skipped',
).length,
});
export const syncFirefliesTranscriptBatch = async ({
apiKey,
coreApiClient,
transcriptIds,
callRecordingFieldStates,
sleep,
}: SyncFirefliesTranscriptBatchParams): Promise<ImportMissingFirefliesCallsResult> => {
const firefliesCallSyncOutcomes: FirefliesCallSyncOutcome[] = [];
for (const transcriptId of transcriptIds) {
const callRecordingId =
computeCallRecordingIdForFirefliesMeeting(transcriptId);
const callRecordingFieldState =
callRecordingFieldStates.get(callRecordingId);
const firefliesCallRecordingFields = getMissingFirefliesCallRecordingFields(
callRecordingFieldState,
);
if (firefliesCallRecordingFields.length === 0) {
firefliesCallSyncOutcomes.push('skipped');
continue;
}
const firefliesCallSyncOutcome =
await syncMissingFirefliesCallRecordingFields({
apiKey,
coreApiClient,
transcriptId,
firefliesCallRecordingFields,
callRecordingFieldState,
sleep,
});
if (firefliesCallSyncOutcome === 'retryable-error') {
return {
status: 'retryable-error',
...countFirefliesCallSyncOutcomes(firefliesCallSyncOutcomes),
};
}
firefliesCallSyncOutcomes.push(firefliesCallSyncOutcome);
}
return {
status: 'completed',
...countFirefliesCallSyncOutcomes(firefliesCallSyncOutcomes),
};
};

View File

@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant';
import {
answerTwentyQueries,
buildGraphqlResponse,
} from 'src/logic-functions/flows/__tests__/import-missing-fireflies-calls.test-support';
import { firefliesWebhookHandler } from 'src/logic-functions/handlers/fireflies-webhook-handler';
const queryMock = vi.hoisted(() => vi.fn());
const mutationMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: class {
query = queryMock;
mutation = mutationMock;
},
}));
describe('firefliesWebhookHandler', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv('FIREFLIES_API_KEY', 'fireflies-api-key');
answerTwentyQueries({ queryMock });
mutationMock.mockResolvedValue({
createCallRecording: { id: 'created' },
});
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
it('keeps a summary-only webhook recording processing until its transcript arrives', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
buildGraphqlResponse({
transcript: {
id: 'summary-first-call',
title: 'Summary first call',
date: Date.parse('2026-06-02T10:00:00.000Z'),
duration: 30,
meeting_link: null,
participants: ['a@example.com'],
organizer_email: 'a@example.com',
calendar_id: null,
cal_id: null,
calendar_type: null,
meeting_info: { summary_status: 'processed' },
summary: { overview: 'Summary arrived first' },
},
}),
),
);
await firefliesWebhookHandler({
meetingId: 'summary-first-call',
eventType: 'meeting.summarized',
});
expect(mutationMock).toHaveBeenCalledWith({
createCallRecording: {
__args: {
data: expect.objectContaining({
status: CALL_RECORDING_STATUS.PROCESSING,
summary: expect.objectContaining({
markdown: expect.stringContaining('Summary arrived first'),
}),
}),
},
id: true,
},
});
});
});

View File

@@ -0,0 +1,46 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { FIREFLIES_BACKFILL_BATCH_SIZE } from 'src/logic-functions/constants/fireflies-backfill-batch-size.constant';
import { importMissingFirefliesCalls } from 'src/logic-functions/flows/import-missing-fireflies-calls.util';
import { firefliesBackfillBatchPayloadSchema } from 'src/logic-functions/schemas/fireflies-backfill-batch-payload.schema';
import { type ImportMissingFirefliesCallsResult } from 'src/logic-functions/types/import-missing-fireflies-calls-result.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
export const firefliesBackfillBatchHandler = async (
payload: unknown,
): Promise<ImportMissingFirefliesCallsResult> => {
const payloadParseResult =
firefliesBackfillBatchPayloadSchema.safeParse(payload);
if (!payloadParseResult.success) {
throw new Error(
`Fireflies backfill batch requires 1 to ${FIREFLIES_BACKFILL_BATCH_SIZE} non-empty transcript ids`,
);
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
throw new Error(apiKeyResult.error);
}
const importMissingFirefliesCallsResult = await importMissingFirefliesCalls({
apiKey: apiKeyResult.apiKey,
coreApiClient: new CoreApiClient(),
transcriptIds: payloadParseResult.data.transcriptIds,
});
console.log('[fireflies] Backfill batch processed', {
importedCallCount: importMissingFirefliesCallsResult.importedCallCount,
erroredCallCount: importMissingFirefliesCallsResult.erroredCallCount,
skippedCallCount: importMissingFirefliesCallsResult.skippedCallCount,
});
if (importMissingFirefliesCallsResult.status === 'retryable-error') {
throw new Error(
'Fireflies backfill batch hit a transient Fireflies API error; failing the job so the queue retries it',
);
}
return importMissingFirefliesCallsResult;
};

View File

@@ -0,0 +1,55 @@
import { FIREFLIES_BACKFILL_OUTCOME } from 'src/constants/fireflies-backfill-outcome.constant';
import { FIREFLIES_BACKFILL_BATCH_SIZE } from 'src/logic-functions/constants/fireflies-backfill-batch-size.constant';
import { enqueueFirefliesBackfillBatches } from 'src/logic-functions/data/enqueue-fireflies-backfill-batches.util';
import { type FirefliesBackfillResult } from 'src/logic-functions/types/fireflies-backfill-result.type';
import { buildFirefliesBackfillWindow } from 'src/logic-functions/utils/build-fireflies-backfill-window.util';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import { listFirefliesTranscriptIds } from 'src/logic-functions/utils/list-fireflies-transcript-ids.util';
import { chunkIntoBatches } from 'src/utils/chunk-into-batches.util';
export const firefliesBackfillHandler = async ({
windowDays,
}: {
windowDays: number;
}): Promise<FirefliesBackfillResult> => {
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return {
outcome: FIREFLIES_BACKFILL_OUTCOME.NOT_CONFIGURED,
error: apiKeyResult.error,
};
}
const { fromDate, toDate } = buildFirefliesBackfillWindow({
windowDays,
nowMilliseconds: Date.now(),
});
const listFirefliesTranscriptIdsResult = await listFirefliesTranscriptIds({
apiKey: apiKeyResult.apiKey,
fromDate,
toDate,
});
if (!listFirefliesTranscriptIdsResult.ok) {
throw new Error(
`Fireflies backfill listing failed: ${listFirefliesTranscriptIdsResult.errorMessage}`,
);
}
const transcriptIdBatches = chunkIntoBatches(
listFirefliesTranscriptIdsResult.transcriptIds,
FIREFLIES_BACKFILL_BATCH_SIZE,
);
await enqueueFirefliesBackfillBatches({ transcriptIdBatches });
return {
outcome: FIREFLIES_BACKFILL_OUTCOME.COMPLETED,
fromDate,
toDate,
transcriptCount: listFirefliesTranscriptIdsResult.transcriptIds.length,
batchCount: transcriptIdBatches.length,
enqueuedBatchCount: transcriptIdBatches.length,
};
};

View File

@@ -0,0 +1,62 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type FirefliesCallListResult } from 'src/logic-functions/types/fireflies-call-list-result.type';
import { type FirefliesListCallsByParticipantInput } from 'src/logic-functions/types/fireflies-list-calls-by-participant-input.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import { listFirefliesTranscripts } from 'src/logic-functions/utils/list-fireflies-transcripts.util';
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
const clampLimit = (limit: number | undefined): number => {
if (!Number.isFinite(limit) || limit === undefined) {
return DEFAULT_LIMIT;
}
return Math.max(1, Math.min(MAX_LIMIT, Math.trunc(limit)));
};
export const firefliesListCallsByParticipantHandler = async (
parameters: FirefliesListCallsByParticipantInput,
): Promise<FirefliesCallListResult> => {
const participantEmail = parameters.participantEmail?.trim();
if (!isNonEmptyString(participantEmail)) {
return {
success: false,
message: 'Failed to list Fireflies calls',
error: '`participantEmail` is required.',
};
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return {
success: false,
message: 'Fireflies is not configured',
error: apiKeyResult.error,
};
}
const result = await listFirefliesTranscripts({
apiKey: apiKeyResult.apiKey,
participants: [participantEmail],
limit: clampLimit(parameters.limit),
});
if (!result.ok) {
return {
success: false,
message: 'Failed to list Fireflies calls',
error: result.errorMessage,
};
}
return {
success: true,
message: `Found ${result.data.length} Fireflies call(s) with ${participantEmail}.`,
calls: result.data,
count: result.data.length,
};
};

View File

@@ -0,0 +1,63 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type FirefliesCallListResult } from 'src/logic-functions/types/fireflies-call-list-result.type';
import { type FirefliesSearchCallsInput } from 'src/logic-functions/types/fireflies-search-calls-input.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import { listFirefliesTranscripts } from 'src/logic-functions/utils/list-fireflies-transcripts.util';
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
const clampLimit = (limit: number | undefined): number => {
if (!Number.isFinite(limit) || limit === undefined) {
return DEFAULT_LIMIT;
}
return Math.max(1, Math.min(MAX_LIMIT, Math.trunc(limit)));
};
export const firefliesSearchCallsHandler = async (
parameters: FirefliesSearchCallsInput,
): Promise<FirefliesCallListResult> => {
const keyword = parameters.keyword?.trim();
if (!isNonEmptyString(keyword)) {
return {
success: false,
message: 'Failed to search Fireflies calls',
error: '`keyword` is required.',
};
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return {
success: false,
message: 'Fireflies is not configured',
error: apiKeyResult.error,
};
}
const result = await listFirefliesTranscripts({
apiKey: apiKeyResult.apiKey,
keyword,
keywordScope: 'all',
limit: clampLimit(parameters.limit),
});
if (!result.ok) {
return {
success: false,
message: 'Failed to search Fireflies calls',
error: result.errorMessage,
};
}
return {
success: true,
message: `Found ${result.data.length} Fireflies call(s) matching "${keyword}".`,
calls: result.data,
count: result.data.length,
};
};

View File

@@ -0,0 +1,163 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant';
import { findCallRecordingFieldStatesOrThrow } from 'src/logic-functions/data/find-call-recording-field-states-or-throw.util';
import { type FirefliesSyncCallInput } from 'src/logic-functions/types/fireflies-sync-call-input.type';
import {
type FirefliesSyncCallFieldOutcome,
type FirefliesSyncCallResult,
} from 'src/logic-functions/types/fireflies-sync-call-result.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import { type FirefliesSyncableField } from 'src/logic-functions/types/fireflies-syncable-field.type';
import { type SyncFirefliesCallResult } from 'src/logic-functions/types/sync-fireflies-call-result.type';
import { computeCallRecordingIdForFirefliesMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting';
import { syncFirefliesCallToCallRecording } from 'src/logic-functions/utils/sync-fireflies-call-to-call-recording.util';
const ALL_FIELDS: FirefliesSyncableField[] = ['transcript', 'summary'];
const buildFailure = (
message: string,
error: string,
transcriptId?: string,
): FirefliesSyncCallResult => ({
success: false,
message,
error,
transcriptId,
});
export const firefliesSyncCallHandler = async (
parameters: FirefliesSyncCallInput,
): Promise<FirefliesSyncCallResult> => {
const transcriptId = parameters.transcriptId?.trim();
if (!isNonEmptyString(transcriptId)) {
return buildFailure(
'Failed to sync Fireflies call',
'`transcriptId` is required.',
);
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return buildFailure(
'Fireflies is not configured',
apiKeyResult.error,
transcriptId,
);
}
const coreApiClient = new CoreApiClient();
const callRecordingId =
computeCallRecordingIdForFirefliesMeeting(transcriptId);
const callRecordingFieldStates = await findCallRecordingFieldStatesOrThrow({
coreApiClient,
callRecordingIds: [callRecordingId],
});
const initialCallRecordingFieldState =
callRecordingFieldStates.get(callRecordingId);
// Sequential: both fields upsert the same row, concurrent runs would race on the create.
const results: SyncFirefliesCallResult[] = [];
for (const field of ALL_FIELDS) {
const isTranscriptFilled =
(initialCallRecordingFieldState?.isTranscriptFilled ?? false) ||
results.some(
(result) =>
result.status === 'updated' && result.field === 'transcript',
);
const isSummaryFilled =
(initialCallRecordingFieldState?.isSummaryFilled ?? false) ||
results.some(
(result) => result.status === 'updated' && result.field === 'summary',
);
const currentCallRecordingFieldState = {
isTranscriptFilled,
isSummaryFilled,
status:
isTranscriptFilled && isSummaryFilled
? CALL_RECORDING_STATUS.COMPLETED
: initialCallRecordingFieldState?.status,
};
results.push(
await syncFirefliesCallToCallRecording({
apiKey: apiKeyResult.apiKey,
coreApiClient,
transcriptId,
field,
callRecordingFieldState: currentCallRecordingFieldState,
}),
);
}
const fieldOutcomes: FirefliesSyncCallFieldOutcome[] = results.map(
(result) => {
if (result.status === 'skipped') {
return {
field: result.field,
status: 'skipped',
reason: result.reason,
};
}
if (result.status === 'error') {
return { field: result.field, status: 'error', error: result.error };
}
return { field: result.field, status: 'updated' };
},
);
const updatedFields = fieldOutcomes
.filter((outcome) => outcome.status === 'updated')
.map((outcome) => outcome.field);
const updatedResult = results.find(
(
result,
): result is Extract<SyncFirefliesCallResult, { status: 'updated' }> =>
result.status === 'updated',
);
if (updatedFields.length === 0) {
const skipReasons = fieldOutcomes
.filter((outcome) => outcome.status === 'skipped')
.map((outcome) => `${outcome.field}: ${outcome.reason}`);
const errors = fieldOutcomes
.filter((outcome) => outcome.status === 'error')
.map((outcome) => `${outcome.field}: ${outcome.error}`);
return {
success: false,
message: `No CallRecording was written for Fireflies transcript ${transcriptId}.`,
error: [...errors, ...skipReasons].join(' | ') || 'No fields updated.',
transcriptId,
fieldOutcomes,
};
}
const partialFailures = fieldOutcomes.filter(
(outcome) => outcome.status === 'error',
);
return {
success: true,
message:
partialFailures.length > 0
? `Synced ${updatedFields.join(
' + ',
)} onto the CallRecording for Fireflies transcript ${transcriptId} (with errors on ${partialFailures
.map((outcome) => outcome.field)
.join(', ')}).`
: `Synced ${updatedFields.join(
' + ',
)} onto the CallRecording for Fireflies transcript ${transcriptId}.`,
transcriptId,
callRecordingId: updatedResult?.callRecordingId,
calendarEventId: updatedResult?.calendarEventId,
updatedFields,
fieldOutcomes,
};
};

View File

@@ -0,0 +1,79 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { findCallRecordingFieldStatesOrThrow } from 'src/logic-functions/data/find-call-recording-field-states-or-throw.util';
import { type FirefliesWebhookResult } from 'src/logic-functions/types/fireflies-webhook-payload.type';
import { computeCallRecordingIdForFirefliesMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import { type FirefliesSyncableField } from 'src/logic-functions/types/fireflies-syncable-field.type';
import { syncFirefliesCallToCallRecording } from 'src/logic-functions/utils/sync-fireflies-call-to-call-recording.util';
const TRANSCRIPT_READY_EVENT = 'meeting.transcribed';
const SUMMARY_READY_EVENT = 'meeting.summarized';
const FIELD_BY_EVENT: Record<string, FirefliesSyncableField> = {
[TRANSCRIPT_READY_EVENT]: 'transcript',
[SUMMARY_READY_EVENT]: 'summary',
};
export const firefliesWebhookHandler = async ({
meetingId,
eventType,
}: {
meetingId?: string | null;
eventType?: string | null;
}): Promise<FirefliesWebhookResult> => {
if (!isNonEmptyString(meetingId)) {
return { error: 'Webhook payload is missing meetingId' };
}
const field = isNonEmptyString(eventType)
? FIELD_BY_EVENT[eventType]
: undefined;
if (field === undefined) {
return {
skipped: true,
reason: `Unsupported Fireflies Webhooks V2 event "${
eventType ?? '<missing>'
}"; expected "${TRANSCRIPT_READY_EVENT}" or "${SUMMARY_READY_EVENT}"`,
meetingId,
};
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return { error: apiKeyResult.error, meetingId };
}
const coreApiClient = new CoreApiClient();
const callRecordingId = computeCallRecordingIdForFirefliesMeeting(meetingId);
const callRecordingFieldStates = await findCallRecordingFieldStatesOrThrow({
coreApiClient,
callRecordingIds: [callRecordingId],
});
const syncResult = await syncFirefliesCallToCallRecording({
apiKey: apiKeyResult.apiKey,
coreApiClient,
transcriptId: meetingId,
field,
callRecordingFieldState: callRecordingFieldStates.get(callRecordingId),
});
if (syncResult.status === 'error') {
return { error: syncResult.error, meetingId };
}
if (syncResult.status === 'skipped') {
return { skipped: true, reason: syncResult.reason, meetingId };
}
return {
action: 'updated',
field: syncResult.field,
callRecordingId: syncResult.callRecordingId,
calendarEventId: syncResult.calendarEventId,
meetingId,
};
};

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { callRecordingFieldStateQueryResultSchema } from 'src/logic-functions/schemas/call-recording-field-state-query-result.schema';
describe('callRecordingFieldStateQueryResultSchema', () => {
it('accepts the queried call recording field state', () => {
expect(
callRecordingFieldStateQueryResultSchema.safeParse({
callRecordings: {
edges: [
{
node: {
id: 'call-recording-id',
transcript: [{ speakerName: 'A', text: 'hello' }],
summary: { markdown: 'Imported summary' },
},
},
],
},
}).success,
).toBe(true);
});
it('rejects a call recording without an id', () => {
expect(
callRecordingFieldStateQueryResultSchema.safeParse({
callRecordings: {
edges: [{ node: { summary: null } }],
},
}).success,
).toBe(false);
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { FIREFLIES_BACKFILL_MAX_WINDOW_DAYS } from 'src/constants/fireflies-backfill-max-window-days.constant';
import { firefliesBackfillRequestBodySchema } from 'src/logic-functions/schemas/fireflies-backfill-request-body.schema';
describe('firefliesBackfillRequestBodySchema', () => {
it('accepts a days window', () => {
expect(
firefliesBackfillRequestBodySchema.safeParse({ days: 30 }).success,
).toBe(true);
});
it('rejects an empty request body', () => {
expect(firefliesBackfillRequestBodySchema.safeParse(null).success).toBe(
false,
);
});
it('rejects a non-positive days window', () => {
expect(
firefliesBackfillRequestBodySchema.safeParse({ days: 0 }).success,
).toBe(false);
});
it('rejects a fractional days window', () => {
expect(
firefliesBackfillRequestBodySchema.safeParse({ days: 1.5 }).success,
).toBe(false);
});
it('rejects a days window beyond the supported maximum', () => {
expect(
firefliesBackfillRequestBodySchema.safeParse({
days: FIREFLIES_BACKFILL_MAX_WINDOW_DAYS + 1,
}).success,
).toBe(false);
});
});

View File

@@ -0,0 +1,36 @@
import { z } from 'zod';
import { CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant';
export const callRecordingFieldStateQueryResultSchema = z.object({
callRecordings: z
.object({
edges: z.array(
z
.object({
node: z
.object({
id: z.string(),
status: z
.enum([
CALL_RECORDING_STATUS.PROCESSING,
CALL_RECORDING_STATUS.COMPLETED,
])
.optional(),
transcript: z.unknown().nullable().optional(),
summary: z
.object({
markdown: z.string().nullable().optional(),
})
.nullable()
.optional(),
})
.nullable()
.optional(),
})
.nullable(),
),
})
.nullable()
.optional(),
});

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
import { FIREFLIES_BACKFILL_BATCH_SIZE } from 'src/logic-functions/constants/fireflies-backfill-batch-size.constant';
export const firefliesBackfillBatchPayloadSchema = z.object({
transcriptIds: z
.array(z.string().min(1))
.min(1)
.max(FIREFLIES_BACKFILL_BATCH_SIZE),
});

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
import { FIREFLIES_BACKFILL_MAX_WINDOW_DAYS } from 'src/constants/fireflies-backfill-max-window-days.constant';
export const firefliesBackfillRequestBodySchema = z.object({
days: z.number().int().positive().max(FIREFLIES_BACKFILL_MAX_WINDOW_DAYS),
});

View File

@@ -0,0 +1,23 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const firefliesListCallsByParticipantInputSchema: InputJsonSchema = {
type: 'object',
properties: {
participantEmail: {
type: 'string',
label: 'Participant email',
description:
'Email address of a meeting attendee. Returns Fireflies calls where this email appears in the participants list (case-insensitive match performed by Fireflies). Useful to answer "what calls have we had with this contact?" before reaching out to them.',
},
limit: {
type: 'integer',
label: 'Maximum number of calls',
description:
'Optional. Maximum number of calls to return. Defaults to 20. Fireflies caps the limit at 50 per query.',
minimum: 1,
maximum: 50,
},
},
required: ['participantEmail'],
additionalProperties: false,
};

View File

@@ -0,0 +1,23 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const firefliesSearchCallsInputSchema: InputJsonSchema = {
type: 'object',
properties: {
keyword: {
type: 'string',
label: 'Keyword to search for',
description:
'Keyword or phrase to search across Fireflies meetings. Matches against both meeting titles and the words spoken during meetings. Useful for finding "the call where we discussed pricing" or "any meeting that mentioned the new integration".',
},
limit: {
type: 'integer',
label: 'Maximum number of calls',
description:
'Optional. Maximum number of calls to return. Defaults to 20. Fireflies caps the limit at 50 per query.',
minimum: 1,
maximum: 50,
},
},
required: ['keyword'],
additionalProperties: false,
};

View File

@@ -0,0 +1,15 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const firefliesSyncCallInputSchema: InputJsonSchema = {
type: 'object',
properties: {
transcriptId: {
type: 'string',
label: 'Fireflies call ID',
description:
'The ID of the Fireflies call to sync (also referred to as the "transcript ID" in Fireflies\' API and docs). Found at the end of the Fireflies meeting URL (`https://app.fireflies.ai/view/<id>`) or in the `meeting_id` field of a Fireflies webhook payload. Runs the same pipeline as the webhook: fetches transcript + AI summary from Fireflies and writes both onto the matching CalendarEvent.',
},
},
required: ['transcriptId'],
additionalProperties: false,
};

View File

@@ -0,0 +1,9 @@
import { type CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant';
export type CallRecordingFieldState = {
isTranscriptFilled: boolean;
isSummaryFilled: boolean;
status?:
| typeof CALL_RECORDING_STATUS.PROCESSING
| typeof CALL_RECORDING_STATUS.COMPLETED;
};

View File

@@ -0,0 +1,17 @@
import type { CALL_RECORDING_REQUEST_STATUS } from 'src/logic-functions/constants/call-recording-request-status.constant';
import type { CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant';
import { type TranscriptEntry } from 'src/logic-functions/types/transcript-entry.type';
export type CallRecordingSyncFields = {
title?: string;
status?:
| typeof CALL_RECORDING_STATUS.PROCESSING
| typeof CALL_RECORDING_STATUS.COMPLETED;
recordingRequestStatus?: typeof CALL_RECORDING_REQUEST_STATUS.REQUESTED;
externalRecordingId?: string;
startedAt?: string;
endedAt?: string;
transcript?: TranscriptEntry[];
summary?: { markdown: string; blocknote: null };
calendarEventId?: string;
};

View File

@@ -0,0 +1,15 @@
import type { FIREFLIES_BACKFILL_OUTCOME } from 'src/constants/fireflies-backfill-outcome.constant';
export type FirefliesBackfillResult =
| {
outcome: typeof FIREFLIES_BACKFILL_OUTCOME.NOT_CONFIGURED;
error: string;
}
| {
outcome: typeof FIREFLIES_BACKFILL_OUTCOME.COMPLETED;
fromDate: string;
toDate: string;
transcriptCount: number;
batchCount: number;
enqueuedBatchCount: number;
};

View File

@@ -0,0 +1,4 @@
export type FirefliesBackfillWindow = {
fromDate: string;
toDate: string;
};

View File

@@ -0,0 +1,18 @@
export type FirefliesCallSummary = {
id: string;
title: string | null;
date: string | null;
durationMinutes: number | null;
participants: string[];
hostEmail: string | null;
transcriptUrl: string | null;
meetingLink: string | null;
};
export type FirefliesCallListResult = {
success: boolean;
message: string;
error?: string;
calls?: FirefliesCallSummary[];
count?: number;
};

View File

@@ -0,0 +1,4 @@
export type FirefliesListCallsByParticipantInput = {
participantEmail: string;
limit?: number;
};

View File

@@ -0,0 +1,4 @@
export type FirefliesSearchCallsInput = {
keyword: string;
limit?: number;
};

View File

@@ -0,0 +1,3 @@
export type FirefliesSyncCallInput = {
transcriptId: string;
};

View File

@@ -0,0 +1,17 @@
import { type FirefliesSyncableField } from 'src/logic-functions/types/fireflies-syncable-field.type';
export type FirefliesSyncCallFieldOutcome =
| { field: FirefliesSyncableField; status: 'updated' }
| { field: FirefliesSyncableField; status: 'skipped'; reason: string }
| { field: FirefliesSyncableField; status: 'error'; error: string };
export type FirefliesSyncCallResult = {
success: boolean;
message: string;
error?: string;
transcriptId?: string;
callRecordingId?: string;
calendarEventId?: string;
updatedFields?: FirefliesSyncableField[];
fieldOutcomes?: FirefliesSyncCallFieldOutcome[];
};

View File

@@ -0,0 +1 @@
export type FirefliesSyncableField = 'transcript' | 'summary';

View File

@@ -0,0 +1,31 @@
export type FirefliesTranscriptSentence = {
speaker_name: string | null;
text: string;
start_time: number | null;
end_time?: number | null;
};
export type FirefliesSummary = {
overview: string | null;
action_items: string | null;
keywords: string[] | null;
topics_discussed: string[] | null;
short_summary: string | null;
};
export type FirefliesTranscript = {
id: string;
title: string | null;
duration: number | null;
meeting_link: string | null;
participants: string[];
organizer_email: string | null;
host_email?: string | null;
date?: number | null;
transcript_url?: string | null;
sentences?: FirefliesTranscriptSentence[] | null;
summary?: FirefliesSummary | null;
calendar_id?: string | null;
cal_id?: string | null;
calendar_type?: string | null;
};

View File

@@ -0,0 +1,19 @@
export type FirefliesWebhookPayload = {
event: string;
meeting_id: string;
timestamp?: number;
client_reference_id?: string | null;
};
export type FirefliesSyncedField = 'transcript' | 'summary';
export type FirefliesWebhookResult =
| {
action: 'updated';
field: FirefliesSyncedField;
callRecordingId: string;
calendarEventId?: string;
meetingId: string;
}
| { skipped: true; reason: string; meetingId?: string }
| { error: string; meetingId?: string };

View File

@@ -0,0 +1,13 @@
type ImportMissingFirefliesCallsCounts = {
importedCallCount: number;
erroredCallCount: number;
skippedCallCount: number;
};
export type ImportMissingFirefliesCallsResult =
| (ImportMissingFirefliesCallsCounts & {
status: 'completed';
})
| (ImportMissingFirefliesCallsCounts & {
status: 'retryable-error';
});

View File

@@ -0,0 +1,10 @@
export type ListFirefliesTranscriptIdsResult =
| {
ok: true;
transcriptIds: string[];
}
| {
ok: false;
status: number;
errorMessage: string;
};

View File

@@ -0,0 +1,17 @@
import { type FirefliesSyncableField } from 'src/logic-functions/types/fireflies-syncable-field.type';
export type SyncFirefliesCallResult =
| {
status: 'updated';
field: FirefliesSyncableField;
callRecordingId: string;
calendarEventId?: string;
created: boolean;
}
| { status: 'skipped'; field: FirefliesSyncableField; reason: string }
| {
status: 'error';
field: FirefliesSyncableField;
error: string;
httpStatus?: number;
};

View File

@@ -0,0 +1,5 @@
export type TranscriptEntryWord = {
text: string;
start_timestamp?: { relative: number };
end_timestamp?: { relative: number };
};

View File

@@ -0,0 +1,6 @@
import { type TranscriptEntryWord } from 'src/logic-functions/types/transcript-entry-word.type';
export type TranscriptEntry = {
participant: { name: string };
words: TranscriptEntryWord[];
};

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { buildFirefliesBackfillWindow } from 'src/logic-functions/utils/build-fireflies-backfill-window.util';
describe('buildFirefliesBackfillWindow', () => {
it('creates a date window ending now for the requested days', () => {
const nowMilliseconds = Date.parse('2026-07-30T00:00:00.000Z');
expect(
buildFirefliesBackfillWindow({ windowDays: 30, nowMilliseconds }),
).toEqual({
fromDate: '2026-06-30T00:00:00.000Z',
toDate: '2026-07-30T00:00:00.000Z',
});
});
});

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { computeCallRecordingIdForFirefliesMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting';
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
describe('computeCallRecordingIdForFirefliesMeeting', () => {
it('should produce a valid v4-shaped UUID', () => {
const id = computeCallRecordingIdForFirefliesMeeting('01K1234ABCD');
expect(id).toMatch(UUID_V4_PATTERN);
});
it('should be deterministic for the same meeting id', () => {
expect(computeCallRecordingIdForFirefliesMeeting('meeting-a')).toBe(
computeCallRecordingIdForFirefliesMeeting('meeting-a'),
);
});
it('should differ for different meeting ids', () => {
expect(computeCallRecordingIdForFirefliesMeeting('meeting-a')).not.toBe(
computeCallRecordingIdForFirefliesMeeting('meeting-b'),
);
});
});

Some files were not shown because too many files have changed in this diff Show More