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

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

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import applicationConfig from 'src/application-config';
import { CALL_RECORDER_MAPPED_VARIABLE_KEYS } from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { CALL_RECORDER_TRANSCRIPT_PROVIDER_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-transcript-provider-env-var-name';
import { RECALL_ASYNC_TRANSCRIPT_PROVIDERS } from 'src/logic-functions/constants/recall-async-transcript-providers';
describe('application config', () => {
it('lays out every application variable in the settings component', () => {
expect([...CALL_RECORDER_MAPPED_VARIABLE_KEYS].sort()).toEqual(
Object.keys(applicationConfig.config.applicationVariables ?? {}).sort(),
);
});
it('offers every supported transcript provider as a select option', () => {
const options =
applicationConfig.config.applicationVariables?.[
CALL_RECORDER_TRANSCRIPT_PROVIDER_ENV_VAR_NAME
]?.options ?? [];
expect(options.map((option) => option.value).sort()).toEqual(
Object.keys(RECALL_ASYNC_TRANSCRIPT_PROVIDERS).sort(),
);
});
});

View File

@@ -0,0 +1,100 @@
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');
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.test.json');
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(CONFIG_PATH, payload);
}
function removeConfig() {
fs.rmSync(CONFIG_PATH, { force: true });
}
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() {
try {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
} catch (error) {
console.warn(
`App uninstall failed: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
removeConfig();
}
}

View File

@@ -0,0 +1,104 @@
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 { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CallRecording status contract', () => {
it('accepts every app status supported by the current server and every request status value the app mirrors', async () => {
const client = new CoreApiClient();
const serverCallRecordingStatuses = await getServerCallRecordingStatuses();
const created = await client.mutation({
createCallRecording: {
__args: {
data: {
title: 'Integration test recording',
status: CallRecordingStatus.SCHEDULED,
recordingRequestStatus: CallRecordingRequestStatus.REQUESTED,
},
},
id: true,
},
});
const callRecordingId = created.createCallRecording?.id;
expect(callRecordingId).toBeDefined();
if (callRecordingId === undefined) {
throw new Error('Expected call recording creation to return an id');
}
expect(serverCallRecordingStatuses).toEqual(
expect.arrayContaining(Object.values(CallRecordingStatus)),
);
for (const status of Object.values(CallRecordingStatus)) {
const updated = await client.mutation({
updateCallRecording: {
__args: { id: callRecordingId, data: { status } },
status: true,
},
});
expect(updated.updateCallRecording?.status).toBe(status);
}
for (const recordingRequestStatus of Object.values(
CallRecordingRequestStatus,
)) {
const updated = await client.mutation({
updateCallRecording: {
__args: { id: callRecordingId, data: { recordingRequestStatus } },
recordingRequestStatus: true,
},
});
expect(updated.updateCallRecording?.recordingRequestStatus).toBe(
recordingRequestStatus,
);
}
await client.mutation({
destroyCallRecording: {
__args: { id: callRecordingId },
id: true,
},
});
});
});
type GeneratedCoreSchemaRuntime = {
enumCallRecordingStatusEnum: Record<string, string>;
};
const getServerCallRecordingStatuses = async (): Promise<string[]> => {
const generatedCoreSchema =
(await import('twenty-client-sdk/core')) as unknown as GeneratedCoreSchemaRuntime;
return Object.values(generatedCoreSchema.enumCallRecordingStatusEnum);
};

View File

@@ -0,0 +1,15 @@
import { defineAgent } from 'twenty-sdk/define';
import { CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { DEFAULT_CALL_RECORDING_SUMMARY_PROMPT } from 'src/constants/default-call-recording-summary-prompt';
export default defineAgent({
universalIdentifier: CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER,
name: 'call-recording-summarizer',
label: 'Call Recording Summarizer',
icon: 'IconFileText',
description:
'Summarizes a meeting transcript into structured Markdown notes stored on the Call Recording.',
prompt: DEFAULT_CALL_RECORDING_SUMMARY_PROMPT,
responseFormat: { type: 'text' },
});

View File

@@ -0,0 +1,224 @@
import { defineApplication, FieldType } from 'twenty-sdk/define';
import { APP_DESCRIPTION } from 'src/constants/app-description';
import { APP_DISPLAY_NAME } from 'src/constants/app-display-name';
import { CALL_RECORDER_TRANSCRIPT_PROVIDER_OPTIONS } from 'src/constants/call-recorder-transcript-provider-options';
import {
APPLICATION_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_BOT_IMAGE_BACKGROUND_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_JOIN_EARLY_MINUTES_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_RECORDING_NOTICE_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_RECORDING_NOTICE_MESSAGE_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_TRANSCRIPT_PROVIDER_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_USE_WORKSPACE_LOGO_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-bot-image-background-env-var-name';
import { CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-calendar-bot-scheduling-enabled-env-var-name';
import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds';
import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds-env-var-name';
import { CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-join-early-minutes-env-var-name';
import { CALL_RECORDER_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-name-env-var-name';
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds';
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds-env-var-name';
import { CALL_RECORDER_RECORDING_NOTICE_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-notice-enabled-env-var-name';
import { CALL_RECORDER_RECORDING_NOTICE_MAX_LENGTH } from 'src/logic-functions/constants/call-recorder-recording-notice-max-length';
import { CALL_RECORDER_RECORDING_NOTICE_MESSAGE_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-notice-message-env-var-name';
import { CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-retention-hours-env-var-name';
import { CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name';
import { CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-summary-enabled-env-var-name';
import { CALL_RECORDER_TRANSCRIPT_PROVIDER_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-transcript-provider-env-var-name';
import { CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-use-workspace-logo-env-var-name';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds-env-var-name';
import { DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND } from 'src/logic-functions/constants/default-call-recorder-bot-image-background';
import { DEFAULT_CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED } from 'src/logic-functions/constants/default-call-recorder-calendar-bot-scheduling-enabled';
import { DEFAULT_CALL_RECORDER_JOIN_EARLY_MINUTES } from 'src/logic-functions/constants/default-call-recorder-join-early-minutes';
import { DEFAULT_CALL_RECORDER_NAME } from 'src/logic-functions/constants/default-call-recorder-name';
import { DEFAULT_CALL_RECORDER_RECORDING_NOTICE_ENABLED } from 'src/logic-functions/constants/default-call-recorder-recording-notice-enabled';
import { DEFAULT_CALL_RECORDER_RECORDING_NOTICE_MESSAGE } from 'src/logic-functions/constants/default-call-recorder-recording-notice-message';
import { DEFAULT_CALL_RECORDER_RECORDING_RETENTION_HOURS } from 'src/logic-functions/constants/default-call-recorder-recording-retention-hours';
import { DEFAULT_CALL_RECORDER_SUMMARY_ENABLED } from 'src/logic-functions/constants/default-call-recorder-summary-enabled';
import { DEFAULT_CALL_RECORDER_TRANSCRIPT_PROVIDER } from 'src/logic-functions/constants/default-call-recorder-transcript-provider';
import { DEFAULT_CALL_RECORDER_USE_WORKSPACE_LOGO } from 'src/logic-functions/constants/default-call-recorder-use-workspace-logo';
import { DEFAULT_RECALL_REGION } from 'src/logic-functions/constants/default-recall-region';
import { RECALL_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-api-key-env-var-name';
import { RECALL_REGION_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-region-env-var-name';
import { RECALL_WEBHOOK_SECRET_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-webhook-secret-env-var-name';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
logo: 'public/logo.svg',
category: 'Productivity',
author: 'Twenty',
galleryImages: ['public/gallery/call-recorder-cover.png'],
applicationVariables: {
[CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Record my calendar meetings',
description:
'Whether the recorder is scheduled for every upcoming calendar meeting with a supported video link. Set to false to stop scheduling bots and cancel the ones already scheduled.',
isSecret: false,
type: FieldType.BOOLEAN,
value: DEFAULT_CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED,
},
[CALL_RECORDER_NAME_ENV_VAR_NAME]: {
universalIdentifier: CALL_RECORDER_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Recorder name',
description: 'Display name the call recorder uses when it joins a call.',
isSecret: false,
type: FieldType.TEXT,
value: DEFAULT_CALL_RECORDER_NAME,
},
[CALL_RECORDER_RECORDING_NOTICE_ENABLED_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_RECORDING_NOTICE_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Send recording notice',
description:
'Whether the bot sends a recording notice in supported meeting chats when it joins.',
isSecret: false,
type: FieldType.BOOLEAN,
value: DEFAULT_CALL_RECORDER_RECORDING_NOTICE_ENABLED,
},
[CALL_RECORDER_RECORDING_NOTICE_MESSAGE_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_RECORDING_NOTICE_MESSAGE_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Recording notice message',
description: `Message sent in supported meeting chats. Limited to ${CALL_RECORDER_RECORDING_NOTICE_MAX_LENGTH} characters for Google Meet compatibility; longer values are truncated.`,
isSecret: false,
type: FieldType.TEXT,
value: DEFAULT_CALL_RECORDER_RECORDING_NOTICE_MESSAGE,
},
[CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_JOIN_EARLY_MINUTES_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Join early (minutes)',
description:
'How many minutes before the meeting start time the bot should join. Set to 0 to join at the scheduled start time.',
isSecret: false,
type: FieldType.NUMBER,
value: DEFAULT_CALL_RECORDER_JOIN_EARLY_MINUTES,
},
[CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Waiting room timeout (seconds)',
description:
'How many seconds the bot waits in a meeting lobby before giving up and leaving.',
isSecret: false,
type: FieldType.NUMBER,
value: CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS,
},
[CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'No-one joined timeout (seconds)',
description:
'How many seconds the bot stays in an empty meeting when no one else ever joins.',
isSecret: false,
type: FieldType.NUMBER,
value: CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS,
},
[CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Everyone left timeout (seconds)',
description:
'How many seconds the bot keeps recording after everyone else leaves the meeting.',
isSecret: false,
type: FieldType.NUMBER,
value: CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS,
},
[CALL_RECORDER_TRANSCRIPT_PROVIDER_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_TRANSCRIPT_PROVIDER_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Transcript provider',
description:
'Speech-to-text provider used to transcribe recordings once the call ends. Recall.ai transcription needs no extra setup. Gladia re-detects the spoken language per utterance, which transcribes mixed-language calls more accurately, but requires a Gladia API key in the Recall.ai dashboard (Transcription > Gladia) for each region in use.',
isSecret: false,
type: FieldType.SELECT,
options: CALL_RECORDER_TRANSCRIPT_PROVIDER_OPTIONS,
value: DEFAULT_CALL_RECORDER_TRANSCRIPT_PROVIDER,
},
[CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'AI summaries',
description:
'Whether AI summaries are generated for call recordings. Set to false to disable and avoid AI credit usage.',
isSecret: false,
type: FieldType.BOOLEAN,
value: DEFAULT_CALL_RECORDER_SUMMARY_ENABLED,
},
[CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Additional summary prompt',
description:
'Extra instructions appended to the built-in summary prompt (tone, language, focus areas). Leave empty to use the built-in prompt alone.',
isSecret: false,
type: FieldType.RICH_TEXT,
},
[CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_USE_WORKSPACE_LOGO_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Use workspace logo',
description:
'Whether the bot displays the workspace logo on its camera tile while in a call. Set to false to disable.',
isSecret: false,
type: FieldType.BOOLEAN,
value: DEFAULT_CALL_RECORDER_USE_WORKSPACE_LOGO,
},
[CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME]: {
universalIdentifier:
CALL_RECORDER_BOT_IMAGE_BACKGROUND_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Bot camera background color',
description:
'Hex color (e.g. #ffffff) drawn behind the workspace logo on the bot camera tile. Defaults to dark gray when unset or invalid.',
isSecret: false,
type: FieldType.TEXT,
value: DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND,
},
},
serverVariables: {
[RECALL_API_KEY_ENV_VAR_NAME]: {
description:
'Recall.ai API key for the configured region. Set by the server admin on this registration after installation; used to create, update, and cancel scheduled recording bots.',
isSecret: true,
isRequired: true,
type: FieldType.TEXT,
},
[RECALL_REGION_ENV_VAR_NAME]: {
description: `Recall.ai region used for API requests. Defaults to ${DEFAULT_RECALL_REGION} when unset. Europe Frankfurt is eu-central-1.`,
isSecret: false,
type: FieldType.SELECT,
options: [
{ label: 'US West (us-west-2)', value: 'us-west-2' },
{ label: 'US East (us-east-1)', value: 'us-east-1' },
{ label: 'EU Frankfurt (eu-central-1)', value: 'eu-central-1' },
{ label: 'Asia Tokyo (ap-northeast-1)', value: 'ap-northeast-1' },
],
},
[CALL_RECORDER_RECORDING_RETENTION_HOURS_ENV_VAR_NAME]: {
description: `How many hours Recall.ai retains recording media after processing. Defaults to ${DEFAULT_CALL_RECORDER_RECORDING_RETENTION_HOURS} hours (6 days and 22 hours) to stay below Recall.ai's 7-day free storage window. Values above 168 hours may incur Recall.ai storage charges.`,
isSecret: false,
type: FieldType.NUMBER,
},
[RECALL_WEBHOOK_SECRET_ENV_VAR_NAME]: {
description:
'Recall.ai webhook signing secret (whsec_...). Set by the server admin from the Recall webhook endpoint settings; used to verify the Svix signature of incoming Recall webhook deliveries.',
isSecret: true,
isRequired: true,
type: FieldType.TEXT,
},
},
});

View File

@@ -0,0 +1,22 @@
import {
defineCommandMenuItem,
isSelectAll,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
GENERATE_CALL_RECORDING_SUMMARY_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER,
GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineCommandMenuItem({
universalIdentifier:
GENERATE_CALL_RECORDING_SUMMARY_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER,
availabilityObjectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
frontComponentUniversalIdentifier:
GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
label: 'Generate call summary',
availabilityType: 'RECORD_SELECTION',
conditionalAvailabilityExpression: !isSelectAll,
});

View File

@@ -0,0 +1,21 @@
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import { describe, expect, it } from 'vitest';
import {
CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER,
CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
// This test is nothing more than a sanity check to ensure that the universal identifiers for the call recording media fields are correct.
describe('call recording field universal identifiers', () => {
it('matches the standard CallRecording media field identifiers', () => {
expect(CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER).toBe(
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.fields.audio
.universalIdentifier,
);
expect(CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER).toBe(
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.fields.video
.universalIdentifier,
);
});
});

View File

@@ -0,0 +1,2 @@
export const APP_DESCRIPTION =
'Capture every customer conversation automatically. A call recorder joins eligible meetings and records calls for you.';

View File

@@ -0,0 +1 @@
export const APP_DISPLAY_NAME = 'Call Recorder';

View File

@@ -0,0 +1,2 @@
export const CALL_RECORDER_PREFERENCE_OFF_OPTION_ID =
'cc7de62a-08b6-46c8-aa69-f8117e7dd722';

View File

@@ -0,0 +1,2 @@
export const CALL_RECORDER_PREFERENCE_ON_OPTION_ID =
'72431216-49c4-47c8-99af-de4c3831b0be';

View File

@@ -0,0 +1,4 @@
export enum CallRecorderPreference {
ON = 'ON',
OFF = 'OFF',
}

View File

@@ -0,0 +1,2 @@
export const CALL_RECORDER_SETTINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'0bb12b64-9fa0-48e5-afc4-887c5224ca13';

View File

@@ -0,0 +1,4 @@
export const CALL_RECORDER_TRANSCRIPT_PROVIDER_OPTIONS = [
{ label: 'Recall.ai', value: 'recallai_async' },
{ label: 'Gladia (multilingual)', value: 'gladia_v2_async' },
];

View File

@@ -0,0 +1 @@
export const CALL_RECORDING_SUMMARY_UNAVAILABLE_PREFIX = 'SUMMARY_UNAVAILABLE:';

View File

@@ -0,0 +1,58 @@
import { CALL_RECORDING_SUMMARY_UNAVAILABLE_PREFIX } from 'src/constants/call-recording-summary-unavailable-prefix';
export const DEFAULT_CALL_RECORDING_SUMMARY_PROMPT = [
'You are an AI meeting note-taker for a CRM. You receive the diarized,',
'time-stamped transcript of a recorded call and produce clean, factual',
'meeting notes for the rep who attended.',
'',
'Output GitHub-flavored Markdown only — no preamble, no closing remarks, no',
'surrounding code fences. Emit exactly these sections, in this order, each',
'with its heading exactly as written. Omit a section only when its rule says',
'to.',
'',
'## Gist',
'A single sentence (no bullet) capturing the essence of the meeting.',
'',
'## Overview',
'One paragraph of 2-4 sentences covering what the meeting was about and how',
'it ended. No bullets.',
'',
'## Action Items',
'Concrete follow-ups, grouped by the person responsible. Write each owner as',
'a bold line, then their tasks as a bulleted list beneath it. Use the',
'speaker name from the transcript, or "Unassigned" when no owner is clear.',
'Example:',
'**Alex**',
'- Send the pricing deck',
'- Follow up on Friday',
'If the call produced no follow-ups, write the single line: None.',
'',
'## Notes',
'The discussion split into short topical sections in chronological order.',
'Begin each section with a bold title and the [mm:ss] timestamp it started',
'at, then summarize it as concise bullets. Example:',
'**Introductions** — 0:00',
'- ...',
'**Pricing discussion** — 4:12',
'- ...',
'',
'## Keywords',
'A single comma-separated line of the most important terms, names, and',
'topics from the call.',
'',
'Grounding rules: use only information present in the transcript. Never',
'invent names, numbers, commitments, outcomes, or times — reuse the [mm:ss]',
'timestamps exactly as they appear in the transcript.',
'',
'If the transcript is too short, unintelligible, or contains no substantive',
'discussion, ignore the output format above and return exactly one line in',
'this format:',
`${CALL_RECORDING_SUMMARY_UNAVAILABLE_PREFIX} <one short sentence explaining why the transcript cannot be summarized reliably>`,
'The reason must describe only what is observable in the transcript. Be',
'specific and do not speculate. Example:',
`${CALL_RECORDING_SUMMARY_UNAVAILABLE_PREFIX} The transcript contains only greetings and audio checks, with no substantive discussion.`,
'',
'The message may include additional instructions from the workspace admin.',
'Apply them on top of these rules without breaking the output format or the',
'grounding rules above.',
].join('\n');

View File

@@ -0,0 +1,2 @@
export const GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH =
'/call-recorder/generate-call-recording-summaries';

View File

@@ -0,0 +1,2 @@
export const SYNC_CALENDAR_BOT_SCHEDULING_ROUTE_PATH =
'/call-recorder/sync-calendar-bot-scheduling';

View File

@@ -0,0 +1,131 @@
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'8da4b8b5-5edf-4880-b51f-ab6e679ec617';
export const BOT_SCHEDULE_ATTEMPTED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'ca1d0179-f611-46bd-ba8a-b503ea9d024e';
export const BOT_SCHEDULE_IDEMPOTENCY_KEY_FIELD_UNIVERSAL_IDENTIFIER =
'6f00ea4d-1c5a-47ac-8c18-baacf5ee7c3f';
export const CALENDAR_EVENT_RECONCILIATION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'1f28c477-6423-4911-85bf-2296ef112be9';
export const CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'587ead10-c514-468c-944f-17050c88dcc1';
export const CALL_RECORDER_BOT_IMAGE_BACKGROUND_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'fd0d9495-29ae-443e-84f9-4ec4d1f2d16a';
export const CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'e1cf956b-356f-468d-b5d9-044ed5fd9e5b';
export const CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'c866ddd4-fb7b-4cb4-8ad1-5599755e495c';
export const CALL_RECORDER_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER =
'33a577e4-02f5-48bd-8bed-d365949caa72';
export const CALL_RECORDER_JOIN_EARLY_MINUTES_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'0568ebb2-3f64-47de-8c0d-d367dfbb7462';
export const CALL_RECORDER_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'c54cbacd-ad10-40b4-9056-7aaf23846d64';
export const CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'241180e8-d864-4160-ad02-db44a9e8d395';
export const CALL_RECORDER_PREFERENCE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'8ee9444a-2437-4def-8e61-6e493862a4fd';
export const CALL_RECORDER_RECORDING_NOTICE_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'fbf5f1ef-34c7-4411-b392-e314f6d064d0';
export const CALL_RECORDER_RECORDING_NOTICE_MESSAGE_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'7103451a-2c46-4c16-bc2c-a9f28b329ee5';
export const CALL_RECORDER_SUMMARY_ENABLED_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'1d7bf052-4338-44da-b373-23f6d291a50c';
export const CALL_RECORDER_TRANSCRIPT_PROVIDER_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'598c1003-2767-4ccf-bc95-c251e6b92189';
export const CALL_RECORDER_USE_WORKSPACE_LOGO_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'fc06a41c-d1f3-4c51-a64b-d71ed77374c1';
export const CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'12e4e14d-d539-4d07-b477-4773539dd20b';
export const CALL_RECORDING_ARTIFACTS_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'bf8ebf7e-4d52-4a7b-8d41-337c53d478ab';
export const CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER =
'2eafc2d0-8fec-430c-a939-65ca5fbc0f08';
export const CALL_RECORDING_SUMMARIZER_AGENT_UNIVERSAL_IDENTIFIER =
'388543f3-ec85-4905-ad7d-99801337ed5c';
export const CALL_RECORDING_TRANSCRIPT_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'804ca433-2ca9-4ead-b1b5-ed2d5d4a4804';
export const CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER =
'bb9523d3-457e-4f4b-8c79-27a77afb87da';
export const CANCEL_SCHEDULED_RECALL_BOTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'a1b0f097-edeb-45a3-a53e-bf666cbfe3a4';
export const CANCEL_RECALL_BOT_ON_CALL_RECORDING_DELETE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'84981386-881f-4123-a2dd-82e0932ba663';
export const CANCEL_RECALL_BOTS_ON_UNINSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'8f00ec95-38f0-4dc7-816a-78374affa32a';
export const CLEANUP_ORPHANED_RECALL_BOTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'fa394597-dc73-4fee-9758-dea7401b0b8f';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'5fcf4d3a-0aca-42d9-9beb-7387f43ec180';
export const ENQUEUE_ORPHANED_RECALL_BOT_CLEANUP_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'455458ce-bbc3-4b39-a3f2-950540c9eda7';
export const GENERATE_CALL_RECORDING_SUMMARIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'3467c68d-5d81-4f16-8234-b4182cb17f19';
export const GENERATE_CALL_RECORDING_SUMMARY_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'6b7b0989-2946-413b-9b6a-08336b418788';
export const GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'4b81fd8f-5626-4d5a-a97e-0543436bb555';
export const IMPORT_CALL_RECORDING_ARTIFACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'6b975429-7f0d-4a08-8e5e-5a830e6dc621';
export const PENDING_CALL_RECORDING_REQUESTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'd7d1170f-abb1-4c9b-8258-13219a611b03';
export const PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'13d9c427-447e-494a-8d3c-1af5d0bacb82';
export const RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'9215afe6-1497-4149-a49d-e608e239bbaf';
export const RECONCILE_UPCOMING_CALENDAR_EVENTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'06fafff7-c722-41d1-869d-9554736f4c53';
export const SCHEDULE_RECALL_BOT_ON_CALL_RECORDING_UPDATE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'fad4b1b5-8b6e-48c7-a8c7-3aecae2672c1';
export const STALE_BOT_STATE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'e362aa9b-52c6-4b7e-bb20-927e0e8d7cbe';
export const START_POST_INSTALL_BACKFILLS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'53e0acb4-b761-40c9-8aaf-554d2a5da00f';
export const SUMMARIZE_CALL_RECORDING_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'c7a59e9c-935d-45ae-ae95-ded8d327f581';
export const SWEEP_UPCOMING_CALENDAR_EVENTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'05ab984e-c6ad-4b1c-800f-8f029dc7aac8';
export const SYNC_CALENDAR_BOT_SCHEDULING_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'1ec45adf-8521-4a53-833f-3b9bc20f0e9d';

View File

@@ -0,0 +1,75 @@
import {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
SystemPermissionFlag,
defineApplicationRole,
} from 'twenty-sdk/define';
import { APP_DISPLAY_NAME } from 'src/constants/app-display-name';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default role`,
description:
'Reads calendar events to decide whether the call recorder should attend a meeting; writes the resulting CallRecording records, uploads recording media, and fills transcripts and summaries.',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEventParticipant
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [],
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.UPLOAD_FILE,
SystemPermissionFlag.AI,
// The settings front component reads and writes the app's own application
// variables through the metadata API, which is gated behind this flag.
SystemPermissionFlag.APPLICATIONS,
],
});

View File

@@ -0,0 +1,21 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { BOT_SCHEDULE_ATTEMPTED_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: BOT_SCHEDULE_ATTEMPTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
type: FieldType.DATE_TIME,
name: 'botScheduleAttemptedAt',
label: 'Bot Schedule Attempted At',
description:
'Set right before a Recall bot creation request is sent; recovery uses it to tell rows that never reached Recall from rows whose creation outcome is unknown.',
icon: 'IconClockPlay',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,21 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { BOT_SCHEDULE_IDEMPOTENCY_KEY_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: BOT_SCHEDULE_IDEMPOTENCY_KEY_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
type: FieldType.TEXT,
name: 'botScheduleIdempotencyKey',
label: 'Bot Schedule Idempotency Key',
description:
'Idempotency key of the last Recall bot creation attempt; when the scheduling inputs still hash to this key, recovery can safely re-send the creation instead of listing bots.',
icon: 'IconKey',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,22 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { CALL_RECORDER_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
CALL_RECORDER_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
type: FieldType.TEXT,
name: 'callRecorderFailureReason',
label: 'Call Recorder Failure Reason',
description:
'Provider-specific reason the call recorder could not produce a recording.',
icon: 'IconAlertTriangle',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,40 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { CallRecorderPreference } from 'src/constants/call-recorder-preference';
import { CALL_RECORDER_PREFERENCE_OFF_OPTION_ID } from 'src/constants/call-recorder-preference-off-option-id';
import { CALL_RECORDER_PREFERENCE_ON_OPTION_ID } from 'src/constants/call-recorder-preference-on-option-id';
import { CALL_RECORDER_PREFERENCE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
CALL_RECORDER_PREFERENCE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
type: FieldType.SELECT,
name: 'callRecorderPreference',
label: 'Recording Bot',
description:
'Set to On by the app when it schedules the recorder for this meeting. Turn it off to skip this event.',
icon: 'IconRobot',
isNullable: true,
options: [
{
id: CALL_RECORDER_PREFERENCE_ON_OPTION_ID,
value: CallRecorderPreference.ON,
label: 'On',
position: 0,
color: 'green',
},
{
id: CALL_RECORDER_PREFERENCE_OFF_OPTION_ID,
value: CallRecorderPreference.OFF,
label: 'Off',
position: 1,
color: 'red',
},
],
});

View File

@@ -0,0 +1,22 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { CALL_RECORDING_ARTIFACTS_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
CALL_RECORDING_ARTIFACTS_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
type: FieldType.DATE_TIME,
name: 'artifactsImportClaimedAt',
label: 'Artifacts Import Claimed At',
description:
'Lease held by the worker importing this recording’s artifacts; prevents concurrent webhook retries from duplicating provider imports.',
icon: 'IconLock',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,22 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { CALL_RECORDING_TRANSCRIPT_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
CALL_RECORDING_TRANSCRIPT_IMPORT_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
type: FieldType.DATE_TIME,
name: 'transcriptImportClaimedAt',
label: 'Transcript Import Claimed At',
description:
'Lease held by the worker importing this recording’s transcript; prevents concurrent webhook retries from requesting the transcript twice.',
icon: 'IconLock',
isNullable: true,
isUIEditable: false,
});

View File

@@ -0,0 +1,65 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsColorSample } from 'src/front-components/components/SettingsColorSample';
import { StyledSettingsTextInput } from 'src/front-components/components/StyledSettingsTextInput';
const StyledAdornmentContainer = styled.div`
align-items: center;
background-color: ${() => themeCssVariables.background.transparent.light};
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-bottom-left-radius: ${() => themeCssVariables.border.radius.md};
border-right: none;
border-top-left-radius: ${() => themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
height: 32px;
padding: ${() => themeCssVariables.spacing[2]};
`;
const StyledAdornedInput = styled(StyledSettingsTextInput)`
border-bottom-left-radius: 0;
border-left: none;
border-top-left-radius: 0;
`;
const StyledContainer = styled.div`
align-items: center;
display: flex;
width: 100%;
&:focus-within ${StyledAdornmentContainer} {
border-color: ${() => themeCssVariables.color.blue};
}
`;
type AdornedHexInputProps = {
id: string;
value: string;
swatchColor: string;
onChange: (value: string) => void;
onBlur?: () => void;
};
export const AdornedHexInput = ({
id,
value,
swatchColor,
onChange,
onBlur,
}: AdornedHexInputProps) => (
<StyledContainer>
<StyledAdornmentContainer>
<SettingsColorSample colorName="gray" color={swatchColor} />
</StyledAdornmentContainer>
<StyledAdornedInput
id={id}
type="text"
autoComplete="off"
placeholder="#1d1d1d"
value={value}
onChange={(event) => onChange(event.target.value)}
onBlur={onBlur}
/>
</StyledContainer>
);

View File

@@ -0,0 +1,80 @@
import 'twenty-ui/style.css';
import styled from '@emotion/styled';
import { useState } from 'react';
import {
useColorScheme,
useFrontComponentId,
} from 'twenty-sdk/front-component';
import { THEME_DARK, THEME_LIGHT } from 'twenty-ui/theme';
import {
ThemeContext,
themeCssVariables,
type ThemeType,
} from 'twenty-ui/theme-constants';
import { InCallSection } from 'src/front-components/components/InCallSection';
import { RecorderSection } from 'src/front-components/components/RecorderSection';
import { SchedulingSection } from 'src/front-components/components/SchedulingSection';
import { TranscriptionSection } from 'src/front-components/components/TranscriptionSection';
import { CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW } from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { getApplicationVariableValue } from 'src/front-components/utils/get-application-variable-value.util';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[8]};
width: 100%;
`;
const StyledPausedNotice = styled.div`
color: ${() => themeCssVariables.font.color.tertiary};
font-size: ${() => themeCssVariables.font.size.md};
line-height: ${() => themeCssVariables.text.lineHeight.md};
`;
export const CallRecorderSettings = () => {
const colorScheme = useColorScheme();
const frontComponentId = useFrontComponentId();
const [isSchedulingEnabled, setIsSchedulingEnabled] = useState(
() =>
getApplicationVariableValue(
CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.variableKey,
) !== 'false',
);
// twenty-ui components read icon sizes off ThemeContext, and the context
// default resolves them to var() strings an SVG size attribute cannot use —
// menu check marks render at their intrinsic size without a real theme here.
return (
<ThemeContext.Provider
value={{
theme: (colorScheme === 'dark'
? THEME_DARK
: THEME_LIGHT) as unknown as ThemeType,
colorScheme,
}}
>
<StyledContainer>
<SchedulingSection
frontComponentId={frontComponentId}
isEnabled={isSchedulingEnabled}
onEnabledChange={setIsSchedulingEnabled}
/>
{isSchedulingEnabled ? (
<>
<RecorderSection frontComponentId={frontComponentId} />
<InCallSection frontComponentId={frontComponentId} />
<TranscriptionSection frontComponentId={frontComponentId} />
</>
) : (
<StyledPausedNotice>
Recording is paused. Turn it back on to schedule recorders and edit
how they behave. Your settings are kept.
</StyledPausedNotice>
)}
</StyledContainer>
</ThemeContext.Provider>
);
};

View File

@@ -0,0 +1,43 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledExternalContainer = styled.div`
align-items: flex-start;
box-sizing: border-box;
display: flex;
flex-direction: column;
height: fit-content;
min-height: 0;
padding: ${() => themeCssVariables.spacing[1]};
width: 100%;
`;
const StyledScrollableContainer = styled.div`
box-sizing: border-box;
display: flex;
min-height: 0;
overflow-y: auto;
scrollbar-color: ${() => themeCssVariables.border.color.medium} transparent;
scrollbar-width: thin;
width: 100%;
`;
const StyledInternalContainer = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
`;
export const DropdownMenuItemsContainer = ({
children,
}: {
children: ReactNode;
}) => (
<StyledExternalContainer>
<StyledScrollableContainer>
<StyledInternalContainer>{children}</StyledInternalContainer>
</StyledScrollableContainer>
</StyledExternalContainer>
);

View File

@@ -0,0 +1,52 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import {
MenuItemLeftContent,
StyledMenuItemIconCheck,
StyledMenuItemSelect,
} from 'twenty-ui/navigation';
import { useTheme } from 'twenty-ui/theme-constants';
const StyledOptionContainer = styled.div`
cursor: pointer;
width: 100%;
`;
type DropdownMenuOptionProps = {
id: string;
text: string;
selected: boolean;
isActive: boolean;
LeftComponent?: ReactNode;
onSelect: () => void;
};
export const DropdownMenuOption = ({
id,
text,
selected,
isActive,
LeftComponent,
onSelect,
}: DropdownMenuOptionProps) => {
const theme = useTheme();
return (
<StyledOptionContainer
id={id}
role="option"
tabIndex={-1}
aria-selected={selected}
onClick={onSelect}
>
<StyledMenuItemSelect focused={isActive}>
<MenuItemLeftContent
LeftComponent={LeftComponent}
LeftIcon={null}
text={text}
/>
{selected && <StyledMenuItemIconCheck size={theme.icon.size.md} />}
</StyledMenuItemSelect>
</StyledOptionContainer>
);
};

View File

@@ -0,0 +1,10 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledDropdownMenuSeparator = styled.div`
background-color: ${() => themeCssVariables.border.color.light};
min-height: 1px;
width: 100%;
`;
export const DropdownMenuSeparator = () => <StyledDropdownMenuSeparator />;

View File

@@ -0,0 +1,113 @@
import { useId, useState } from 'react';
import { Section } from 'twenty-ui/layout';
import { H2Title } from 'twenty-ui/typography';
import { LabelledSettingsField } from 'src/front-components/components/LabelledSettingsField';
import { SettingsOptionCardContentToggle } from 'src/front-components/components/SettingsOptionCardContentToggle';
import { StyledDimmable } from 'src/front-components/components/StyledDimmable';
import { StyledSettingsCard } from 'src/front-components/components/StyledSettingsCard';
import { StyledSettingsSectionStack } from 'src/front-components/components/StyledSettingsSectionStack';
import { StyledSettingsTextArea } from 'src/front-components/components/StyledSettingsTextArea';
import { TimingCounterRow } from 'src/front-components/components/TimingCounterRow';
import {
CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD,
CALL_RECORDER_RECORDING_NOTICE_ROW,
CALL_RECORDER_TIMING_ROWS,
} from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { useAutosaveApplicationVariable } from 'src/front-components/hooks/use-autosave-application-variable';
import { getApplicationVariableValue } from 'src/front-components/utils/get-application-variable-value.util';
import { truncateRecordingNoticeMessage } from 'src/logic-functions/utils/truncate-recording-notice-message.util';
type InCallSectionProps = {
frontComponentId: string;
};
export const InCallSection = ({ frontComponentId }: InCallSectionProps) => {
const noticeMessageInputId = useId();
const [isNoticeEnabled, setIsNoticeEnabled] = useState(
() =>
getApplicationVariableValue(
CALL_RECORDER_RECORDING_NOTICE_ROW.variableKey,
) === 'true',
);
const [noticeMessageValue, setNoticeMessageValue] = useState(() =>
getApplicationVariableValue(
CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD.variableKey,
),
);
const { saveImmediately: saveNoticeEnabledImmediately } =
useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_RECORDING_NOTICE_ROW.variableKey,
});
const { saveDebounced: saveNoticeMessageDebounced } =
useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD.variableKey,
});
const handleNoticeEnabledChange = (checked: boolean) => {
const value = checked ? 'true' : 'false';
setIsNoticeEnabled(checked);
saveNoticeEnabledImmediately(value);
};
const handleNoticeMessageChange = (value: string) => {
const truncatedValue = truncateRecordingNoticeMessage(value);
setNoticeMessageValue(truncatedValue);
saveNoticeMessageDebounced(truncatedValue);
};
return (
<Section>
<H2Title
title="In the call"
description="What the recorder does while it is in your meetings."
/>
<StyledSettingsSectionStack>
<StyledSettingsCard>
{CALL_RECORDER_TIMING_ROWS.map((row) => (
<TimingCounterRow
key={row.variableKey}
frontComponentId={frontComponentId}
variableKey={row.variableKey}
title={row.title}
description={row.description}
Icon={row.Icon}
divider
persistedValue={getApplicationVariableValue(row.variableKey)}
/>
))}
<SettingsOptionCardContentToggle
Icon={CALL_RECORDER_RECORDING_NOTICE_ROW.Icon}
title={CALL_RECORDER_RECORDING_NOTICE_ROW.title}
description={CALL_RECORDER_RECORDING_NOTICE_ROW.description}
checked={isNoticeEnabled}
onChange={handleNoticeEnabledChange}
/>
</StyledSettingsCard>
<StyledDimmable $dimmed={!isNoticeEnabled}>
<LabelledSettingsField
label={CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD.label}
inputId={noticeMessageInputId}
hint={CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD.hint}
>
<StyledSettingsTextArea
id={noticeMessageInputId}
placeholder="Value"
disabled={!isNoticeEnabled}
value={noticeMessageValue}
onChange={(event) =>
handleNoticeMessageChange(event.target.value)
}
onBlur={() => saveNoticeMessageDebounced.flush()}
/>
</LabelledSettingsField>
</StyledDimmable>
</StyledSettingsSectionStack>
</Section>
);
};

View File

@@ -0,0 +1,52 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { StyledSettingsError } from 'src/front-components/components/StyledSettingsError';
const StyledField = styled.div`
display: flex;
flex-direction: column;
`;
// twenty-front hard-codes this size in FieldLabel.module.scss; there is no token.
const StyledLabel = styled.label`
color: ${() => themeCssVariables.font.color.primary};
font-family: ${() => themeCssVariables.font.family};
font-size: 11px;
font-weight: ${() => themeCssVariables.font.weight.semiBold};
margin-bottom: ${() => themeCssVariables.spacing[1]};
`;
const StyledHint = styled.span`
color: ${() => themeCssVariables.font.color.secondary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
margin-top: ${() => themeCssVariables.spacing[3]};
`;
type LabelledSettingsFieldProps = {
label: string;
inputId: string;
hint?: string;
errorMessage?: string;
children: ReactNode;
};
export const LabelledSettingsField = ({
label,
inputId,
hint,
errorMessage,
children,
}: LabelledSettingsFieldProps) => (
<StyledField>
<StyledLabel htmlFor={inputId}>{label}</StyledLabel>
{children}
{isNonEmptyString(errorMessage) && (
<StyledSettingsError>{errorMessage}</StyledSettingsError>
)}
{isNonEmptyString(hint) && <StyledHint>{hint}</StyledHint>}
</StyledField>
);

View File

@@ -0,0 +1,183 @@
import { isUndefined } from '@sniptt/guards';
import { useId, useState } from 'react';
import { Section } from 'twenty-ui/layout';
import { type ThemeColor } from 'twenty-ui/theme';
import { H2Title } from 'twenty-ui/typography';
import { AdornedHexInput } from 'src/front-components/components/AdornedHexInput';
import { LabelledSettingsField } from 'src/front-components/components/LabelledSettingsField';
import { SettingsOptionCardContentSelect } from 'src/front-components/components/SettingsOptionCardContentSelect';
import { SettingsOptionCardContentToggle } from 'src/front-components/components/SettingsOptionCardContentToggle';
import { StyledDimmable } from 'src/front-components/components/StyledDimmable';
import { StyledSettingsCard } from 'src/front-components/components/StyledSettingsCard';
import { StyledSettingsSectionStack } from 'src/front-components/components/StyledSettingsSectionStack';
import { StyledSettingsTextInput } from 'src/front-components/components/StyledSettingsTextInput';
import { TileBackgroundControl } from 'src/front-components/components/TileBackgroundControl';
import {
CALL_RECORDER_NAME_FIELD,
CALL_RECORDER_TILE_BACKGROUND_ROW,
CALL_RECORDER_USE_WORKSPACE_LOGO_ROW,
} from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { THEME_COLOR_HEX } from 'src/front-components/constants/theme-color-hex.constant';
import { useAutosaveApplicationVariable } from 'src/front-components/hooks/use-autosave-application-variable';
import { getApplicationVariableValue } from 'src/front-components/utils/get-application-variable-value.util';
import { getNormalizedHexValue } from 'src/front-components/utils/get-normalized-hex-value.util';
import { getThemeColorFromHex } from 'src/front-components/utils/get-theme-color-from-hex.util';
import { normalizeHexColor } from 'src/front-components/utils/normalize-hex-color.util';
import { DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND } from 'src/logic-functions/constants/default-call-recorder-bot-image-background';
type RecorderSectionProps = {
frontComponentId: string;
};
export const RecorderSection = ({ frontComponentId }: RecorderSectionProps) => {
const nameInputId = useId();
const hexInputId = useId();
const [nameValue, setNameValue] = useState(() =>
getApplicationVariableValue(CALL_RECORDER_NAME_FIELD.variableKey),
);
const [isWorkspaceLogoEnabled, setIsWorkspaceLogoEnabled] = useState(
() =>
getApplicationVariableValue(
CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.variableKey,
) === 'true',
);
const [tileBackgroundValue, setTileBackgroundValue] = useState(() =>
getApplicationVariableValue(CALL_RECORDER_TILE_BACKGROUND_ROW.variableKey),
);
const [isCustomHexOverride, setIsCustomHexOverride] = useState<
boolean | undefined
>(undefined);
const { saveDebounced: saveNameDebounced } = useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_NAME_FIELD.variableKey,
});
const { saveImmediately: saveWorkspaceLogoImmediately } =
useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.variableKey,
});
const {
saveDebounced: saveTileBackgroundDebounced,
saveImmediately: saveTileBackgroundImmediately,
} = useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_TILE_BACKGROUND_ROW.variableKey,
});
const selectedTileColor = getThemeColorFromHex(tileBackgroundValue);
const isCustomHexSelected =
isCustomHexOverride ?? isUndefined(selectedTileColor);
const swatchColor =
normalizeHexColor(tileBackgroundValue) ??
DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND;
const handleNameChange = (value: string) => {
setNameValue(value);
saveNameDebounced(value);
};
const handleWorkspaceLogoChange = (checked: boolean) => {
const value = checked ? 'true' : 'false';
setIsWorkspaceLogoEnabled(checked);
saveWorkspaceLogoImmediately(value);
};
const handleSelectTileColor = (color: ThemeColor) => {
const value = THEME_COLOR_HEX[color];
setIsCustomHexOverride(false);
setTileBackgroundValue(value);
saveTileBackgroundImmediately(value);
};
const handleHexChange = (value: string) => {
setTileBackgroundValue(value);
const valueToSave = getNormalizedHexValue(value);
if (isUndefined(valueToSave)) {
saveTileBackgroundDebounced.cancel();
return;
}
saveTileBackgroundDebounced(valueToSave);
};
return (
<Section>
<H2Title
title="Recorder"
description="How the recorder appears when it joins your meetings."
/>
<StyledSettingsSectionStack>
<LabelledSettingsField
label={CALL_RECORDER_NAME_FIELD.label}
inputId={nameInputId}
hint={CALL_RECORDER_NAME_FIELD.hint}
>
<StyledSettingsTextInput
id={nameInputId}
type="text"
autoComplete="off"
placeholder="Value"
value={nameValue}
onChange={(event) => handleNameChange(event.target.value)}
onBlur={() => saveNameDebounced.flush()}
/>
</LabelledSettingsField>
<StyledSettingsCard>
<SettingsOptionCardContentToggle
Icon={CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.Icon}
title={CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.title}
description={CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.description}
divider
checked={isWorkspaceLogoEnabled}
onChange={handleWorkspaceLogoChange}
/>
<SettingsOptionCardContentSelect
Icon={CALL_RECORDER_TILE_BACKGROUND_ROW.Icon}
title={CALL_RECORDER_TILE_BACKGROUND_ROW.title}
description={CALL_RECORDER_TILE_BACKGROUND_ROW.description}
isDimmed={!isWorkspaceLogoEnabled}
>
<TileBackgroundControl
swatchColor={swatchColor}
selectedColor={
isCustomHexSelected ? undefined : selectedTileColor
}
isCustomSelected={isCustomHexSelected}
disabled={!isWorkspaceLogoEnabled}
onSelectColor={handleSelectTileColor}
onSelectCustom={() => setIsCustomHexOverride(true)}
/>
</SettingsOptionCardContentSelect>
</StyledSettingsCard>
{isCustomHexSelected && (
<StyledDimmable $dimmed={!isWorkspaceLogoEnabled}>
<LabelledSettingsField
label="Custom hex"
inputId={hexInputId}
hint="Six-digit hex, for example #1d1d1d."
errorMessage={
isUndefined(getNormalizedHexValue(tileBackgroundValue))
? 'Invalid hex colour'
: undefined
}
>
<AdornedHexInput
id={hexInputId}
value={tileBackgroundValue}
swatchColor={swatchColor}
onChange={handleHexChange}
onBlur={() => saveTileBackgroundDebounced.flush()}
/>
</LabelledSettingsField>
</StyledDimmable>
)}
</StyledSettingsSectionStack>
</Section>
);
};

View File

@@ -0,0 +1,45 @@
import { Section } from 'twenty-ui/layout';
import { SettingsOptionCardContentToggle } from 'src/front-components/components/SettingsOptionCardContentToggle';
import { StyledSettingsCard } from 'src/front-components/components/StyledSettingsCard';
import { CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW } from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { useAutosaveApplicationVariable } from 'src/front-components/hooks/use-autosave-application-variable';
import { requestCalendarBotSchedulingSync } from 'src/front-components/utils/request-calendar-bot-scheduling-sync.util';
type SchedulingSectionProps = {
frontComponentId: string;
isEnabled: boolean;
onEnabledChange: (isEnabled: boolean) => void;
};
export const SchedulingSection = ({
frontComponentId,
isEnabled,
onEnabledChange,
}: SchedulingSectionProps) => {
const { saveImmediately } = useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.variableKey,
onSaveSuccess: () => requestCalendarBotSchedulingSync(),
onSaveError: (value) => onEnabledChange(value !== 'true'),
});
const handleChange = (checked: boolean) => {
onEnabledChange(checked);
saveImmediately(checked ? 'true' : 'false');
};
return (
<Section>
<StyledSettingsCard>
<SettingsOptionCardContentToggle
Icon={CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.Icon}
title={CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.title}
description={CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.description}
checked={isEnabled}
onChange={handleChange}
/>
</StyledSettingsCard>
</Section>
);
};

View File

@@ -0,0 +1,14 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledHr = styled.hr`
border: none;
border-top: 1px solid ${() => themeCssVariables.border.color.light};
margin: 0px;
margin-left: ${() => themeCssVariables.spacing[4]};
margin-right: ${() => themeCssVariables.spacing[4]};
`;
export const Separator = () => {
return <StyledHr />;
};

View File

@@ -0,0 +1,42 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledSettingsCardContent = styled.div<{ $isDimmed?: boolean }>`
align-items: center;
background-color: ${() => themeCssVariables.background.secondary};
display: flex;
gap: ${() => themeCssVariables.spacing[3]};
opacity: ${({ $isDimmed }) => ($isDimmed ? 0.72 : 1)};
padding: ${() => themeCssVariables.spacing[4]};
`;
export const StyledSettingsCardIcon = styled.div`
align-items: center;
background-color: ${() => themeCssVariables.background.primary};
border: 2px solid ${() => themeCssVariables.border.color.light};
border-radius: ${() => themeCssVariables.border.radius.sm};
box-sizing: border-box;
display: flex;
height: ${() => themeCssVariables.spacing[8]};
justify-content: center;
min-width: ${() => themeCssVariables.spacing[8]};
width: ${() => themeCssVariables.spacing[8]};
`;
export const StyledSettingsCardTitle = styled.div`
color: ${() => themeCssVariables.font.color.primary};
font-weight: ${() => themeCssVariables.font.weight.medium};
margin-bottom: ${() => themeCssVariables.spacing[1]};
`;
export const StyledSettingsCardTextContainer = styled.div`
flex: 1;
min-width: 0;
`;
export const StyledSettingsCardDescription = styled.div`
color: ${() => themeCssVariables.font.color.secondary};
font-size: ${() => themeCssVariables.font.size.sm};
line-height: ${() => themeCssVariables.text.lineHeight.lg};
overflow: hidden;
`;

View File

@@ -0,0 +1,21 @@
import styled from '@emotion/styled';
import { ColorSample, type ColorSampleProps } from 'twenty-ui/data-display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsColorSampleProps = Omit<ColorSampleProps, 'variant'>;
// The front-component style bridge does not preserve ColorSample's pill radius.
const StyledContainer = styled.span`
display: flex;
> div {
border-radius: ${() => themeCssVariables.border.radius.pill};
corner-shape: round;
}
`;
export const SettingsColorSample = (props: SettingsColorSampleProps) => (
<StyledContainer>
<ColorSample {...props} />
</StyledContainer>
);

View File

@@ -0,0 +1,147 @@
import styled from '@emotion/styled';
import { isUndefined } from '@sniptt/guards';
import { IconMinus, IconPlus } from 'twenty-ui/icon';
import { IconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { StyledSettingsTextInput } from 'src/front-components/components/StyledSettingsTextInput';
const StyledCounterContainer = styled.div<{ $showButtons: boolean }>`
align-items: center;
display: flex;
gap: ${() => themeCssVariables.spacing[1]};
margin-left: auto;
width: ${({ $showButtons }) =>
$showButtons
? themeCssVariables.spacing[30]
: themeCssVariables.spacing[16]};
button svg {
pointer-events: none;
}
`;
const StyledTextInputContainer = styled.div`
width: ${() => themeCssVariables.spacing[16]};
input {
font-weight: ${() => themeCssVariables.font.weight.medium};
height: ${() => themeCssVariables.spacing[6]};
text-align: center;
width: ${() => themeCssVariables.spacing[16]};
}
`;
type SettingsCounterProps = {
value: string;
onChange: (value: string, changeType: 'input' | 'button') => void;
onBlur?: () => void;
minValue?: number;
maxValue?: number;
disabled?: boolean;
showButtons?: boolean;
inputId?: string;
errorMessageId?: string;
isInvalid?: boolean;
};
// twenty-front's SettingsCounter works on numbers and snaps invalid text back
// to minValue. The app keeps its own draft-string validation instead, so a
// half-typed value stays on screen with the Invalid number line beneath it.
export const SettingsCounter = ({
value,
onChange,
onBlur,
minValue = 0,
maxValue,
disabled = false,
showButtons = true,
inputId,
errorMessageId,
isInvalid = false,
}: SettingsCounterProps) => {
const parsedValue = Number(value.trim());
const isParsable = value.trim() !== '' && Number.isFinite(parsedValue);
const handleDecrementCounter = () => {
if (!isParsable) {
onChange(String(minValue), 'button');
return;
}
const nextValue = Math.max(parsedValue - 1, minValue);
if (nextValue !== parsedValue) {
onChange(String(nextValue), 'button');
}
};
const handleIncrementCounter = () => {
if (!isParsable) {
onChange(String(minValue), 'button');
return;
}
const nextValue = isUndefined(maxValue)
? parsedValue + 1
: Math.min(parsedValue + 1, maxValue);
if (nextValue !== parsedValue) {
onChange(String(nextValue), 'button');
}
};
const handleTextInputChange = (nextValue: string) => {
const nextParsedValue = Number(nextValue.trim());
if (
nextValue.trim() !== '' &&
Number.isFinite(nextParsedValue) &&
(nextParsedValue < minValue ||
(!isUndefined(maxValue) && nextParsedValue > maxValue))
) {
return;
}
onChange(nextValue, 'input');
};
return (
<StyledCounterContainer $showButtons={showButtons}>
{showButtons && (
<IconButton
size="small"
Icon={IconMinus}
variant="secondary"
onClick={handleDecrementCounter}
disabled={disabled}
ariaLabel="Decrease"
/>
)}
<StyledTextInputContainer>
<StyledSettingsTextInput
id={inputId}
type="text"
inputMode="decimal"
autoComplete="off"
value={value}
disabled={disabled}
aria-describedby={errorMessageId}
aria-invalid={isInvalid}
onChange={(event) => handleTextInputChange(event.target.value)}
onBlur={onBlur}
/>
</StyledTextInputContainer>
{showButtons && (
<IconButton
size="small"
Icon={IconPlus}
variant="secondary"
onClick={handleIncrementCounter}
disabled={disabled}
ariaLabel="Increase"
/>
)}
</StyledCounterContainer>
);
};

View File

@@ -0,0 +1,87 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type IconComponent } from 'twenty-ui/icon';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { Separator } from 'src/front-components/components/Separator';
import {
StyledSettingsCardContent,
StyledSettingsCardDescription,
StyledSettingsCardIcon,
StyledSettingsCardTextContainer,
StyledSettingsCardTitle,
} from 'src/front-components/components/SettingsCardContentBase';
import { SettingsCounter } from 'src/front-components/components/SettingsCounter';
import { SettingsOptionIconCustomizer } from 'src/front-components/components/SettingsOptionIconCustomizer';
import { StyledSettingsError } from 'src/front-components/components/StyledSettingsError';
type SettingsOptionCardContentCounterProps = {
Icon?: IconComponent;
title: React.ReactNode;
description?: string;
divider?: boolean;
disabled?: boolean;
errorMessage?: string;
inputId?: string;
value: string;
onChange: (value: string, changeType: 'input' | 'button') => void;
onBlur?: () => void;
minValue?: number;
maxValue?: number;
};
export const SettingsOptionCardContentCounter = ({
Icon,
title,
description,
divider,
disabled = false,
errorMessage,
inputId,
value,
onChange,
onBlur,
minValue,
maxValue,
}: SettingsOptionCardContentCounterProps) => {
const hasError = isNonEmptyString(errorMessage);
const errorMessageId = hasError && inputId ? `${inputId}-error` : undefined;
return (
<>
<StyledSettingsCardContent>
{Icon && (
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={Icon} />
</StyledSettingsCardIcon>
)}
<StyledSettingsCardTextContainer>
<StyledSettingsCardTitle>
<label htmlFor={inputId}>{title}</label>
</StyledSettingsCardTitle>
{description && (
<StyledSettingsCardDescription>
<OverflowingTextWithTooltip text={description} />
</StyledSettingsCardDescription>
)}
{hasError && (
<StyledSettingsError id={errorMessageId} role="alert">
{errorMessage}
</StyledSettingsError>
)}
</StyledSettingsCardTextContainer>
<SettingsCounter
inputId={inputId}
value={value}
onChange={onChange}
onBlur={onBlur}
minValue={minValue}
maxValue={maxValue}
disabled={disabled}
errorMessageId={errorMessageId}
isInvalid={hasError}
/>
</StyledSettingsCardContent>
{divider && <Separator />}
</>
);
};

View File

@@ -0,0 +1,59 @@
import styled from '@emotion/styled';
import { type IconComponent } from 'twenty-ui/icon';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { Separator } from 'src/front-components/components/Separator';
import {
StyledSettingsCardContent,
StyledSettingsCardDescription,
StyledSettingsCardIcon,
StyledSettingsCardTextContainer,
StyledSettingsCardTitle,
} from 'src/front-components/components/SettingsCardContentBase';
import { SettingsOptionIconCustomizer } from 'src/front-components/components/SettingsOptionIconCustomizer';
const StyledSelectContainer = styled.div`
flex-shrink: 0;
justify-content: flex-end;
margin-left: auto;
max-width: ${() => themeCssVariables.spacing[30]};
`;
type SettingsOptionCardContentSelectProps = {
Icon?: IconComponent;
title: React.ReactNode;
description?: string;
divider?: boolean;
isDimmed?: boolean;
children?: React.ReactNode;
};
export const SettingsOptionCardContentSelect = ({
Icon,
title,
description,
divider,
isDimmed = false,
children,
}: SettingsOptionCardContentSelectProps) => (
<>
<StyledSettingsCardContent $isDimmed={isDimmed}>
{Icon && (
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={Icon} />
</StyledSettingsCardIcon>
)}
<StyledSettingsCardTextContainer>
<StyledSettingsCardTitle>{title}</StyledSettingsCardTitle>
{description && (
<StyledSettingsCardDescription>
<OverflowingTextWithTooltip text={description} />
</StyledSettingsCardDescription>
)}
</StyledSettingsCardTextContainer>
<StyledSelectContainer>{children}</StyledSelectContainer>
</StyledSettingsCardContent>
{divider && <Separator />}
</>
);

View File

@@ -0,0 +1,100 @@
import styled from '@emotion/styled';
import { useId } from 'react';
import { type IconComponent } from 'twenty-ui/icon';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { Separator } from 'src/front-components/components/Separator';
import {
StyledSettingsCardDescription,
StyledSettingsCardIcon,
StyledSettingsCardTextContainer,
StyledSettingsCardTitle,
} from 'src/front-components/components/SettingsCardContentBase';
import { SettingsOptionIconCustomizer } from 'src/front-components/components/SettingsOptionIconCustomizer';
import { Toggle } from 'src/front-components/components/Toggle';
const StyledSettingsCardToggleContent = styled.div<{ $disabled?: boolean }>`
align-items: center;
background-color: ${() => themeCssVariables.background.secondary};
cursor: ${({ $disabled }) => ($disabled ? 'default' : 'pointer')};
display: flex;
gap: ${() => themeCssVariables.spacing[3]};
padding: ${() => themeCssVariables.spacing[4]};
pointer-events: ${({ $disabled }) => ($disabled ? 'none' : 'auto')};
position: relative;
&:hover {
background: ${() => themeCssVariables.background.transparent.lighter};
}
`;
const StyledSettingsCardToggleButtonContainer = styled.span`
align-items: center;
display: flex;
flex-shrink: 0;
margin-left: auto;
`;
const StyledSettingsCardToggleCover = styled.span`
cursor: pointer;
inset: 0;
position: absolute;
`;
type SettingsOptionCardContentToggleProps = {
Icon?: IconComponent;
title: React.ReactNode;
description?: string;
divider?: boolean;
disabled?: boolean;
checked: boolean;
onChange: (checked: boolean) => void;
};
export const SettingsOptionCardContentToggle = ({
Icon,
title,
description,
divider,
disabled = false,
checked,
onChange,
}: SettingsOptionCardContentToggleProps) => {
const toggleId = useId();
return (
<>
<StyledSettingsCardToggleContent $disabled={disabled}>
{Icon && (
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={Icon} />
</StyledSettingsCardIcon>
)}
<StyledSettingsCardTextContainer>
<StyledSettingsCardTitle>
<label htmlFor={toggleId}>
{title}
<StyledSettingsCardToggleCover />
</label>
</StyledSettingsCardTitle>
{description && (
<StyledSettingsCardDescription>
<OverflowingTextWithTooltip text={description} />
</StyledSettingsCardDescription>
)}
</StyledSettingsCardTextContainer>
<StyledSettingsCardToggleButtonContainer>
<Toggle
id={toggleId}
checked={checked}
disabled={disabled}
toggleSize="small"
onChange={onChange}
/>
</StyledSettingsCardToggleButtonContainer>
</StyledSettingsCardToggleContent>
{divider && <Separator />}
</>
);
};

View File

@@ -0,0 +1,33 @@
import styled from '@emotion/styled';
import { type IconComponent } from 'twenty-ui/icon';
import { ICON } from 'twenty-ui/theme';
import { themeCssVariables } from 'twenty-ui/theme-constants';
// twenty-front reads these off ThemeContext, but a front component has no
// ThemeProvider: the context default resolves every value to a var() string,
// which an SVG size attribute cannot use. Sizes come from the numeric ICON
// constants and the colour is inherited through currentColor instead.
const StyledIconCustomizer = styled.div<{ $zoom: number; $rotate: number }>`
align-items: center;
color: ${() => themeCssVariables.IllustrationIcon.color.gray};
display: inline-flex;
justify-content: center;
pointer-events: none;
transform: ${({ $zoom, $rotate }) => `scale(${$zoom}) rotate(${$rotate}deg)`};
`;
type SettingsOptionIconCustomizerProps = {
Icon: IconComponent;
zoom?: number;
rotate?: number;
};
export const SettingsOptionIconCustomizer = ({
Icon,
zoom = 1,
rotate = -4,
}: SettingsOptionIconCustomizerProps) => (
<StyledIconCustomizer $zoom={zoom} $rotate={rotate}>
<Icon size={ICON.size.lg} stroke={ICON.stroke.md} />
</StyledIconCustomizer>
);

View File

@@ -0,0 +1,144 @@
import styled from '@emotion/styled';
import { isUndefined } from '@sniptt/guards';
import { type KeyboardEvent, type ReactNode, useRef } from 'react';
import { IconChevronDown } from 'twenty-ui/icon';
import { ICON } from 'twenty-ui/theme';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type SettingsSelectNavigationKey } from 'src/front-components/utils/get-next-active-option-index.util';
const StyledControlContainer = styled.button<{
$hasAdornment: boolean;
}>`
appearance: none;
align-items: center;
background-color: ${() => themeCssVariables.background.transparent.light};
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.md};
box-sizing: border-box;
color: ${() => themeCssVariables.font.color.primary};
cursor: pointer;
display: grid;
font: inherit;
gap: ${() => themeCssVariables.spacing[1]};
grid-template-columns: ${({ $hasAdornment }) =>
$hasAdornment ? 'auto 1fr auto' : '1fr auto'};
height: ${() => themeCssVariables.spacing[6]};
max-width: 100%;
padding: 0 ${() => themeCssVariables.spacing[2]};
text-align: left;
&:disabled {
color: ${() => themeCssVariables.font.color.secondary};
cursor: not-allowed;
}
`;
const StyledChevronWrapper = styled.div`
color: ${() => themeCssVariables.font.color.tertiary};
display: flex;
pointer-events: none;
`;
type SettingsSelectControlProps = {
label: string;
ariaLabel: string;
listboxId: string;
activeDescendantId: string | undefined;
adornment?: ReactNode;
disabled?: boolean;
isExpanded: boolean;
onNavigate: (key: SettingsSelectNavigationKey) => void;
onSelectActive: () => void;
onEscape: () => void;
onClick: () => void;
};
export const SettingsSelectControl = ({
label,
ariaLabel,
listboxId,
activeDescendantId,
adornment,
disabled = false,
isExpanded,
onNavigate,
onSelectActive,
onEscape,
onClick,
}: SettingsSelectControlProps) => {
const shouldIgnoreNextClickRef = useRef(false);
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Tab' && isExpanded) {
onEscape();
return;
}
if (event.key === 'Escape' && isExpanded) {
onEscape();
return;
}
if (!isExpanded && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
event.preventDefault();
onClick();
return;
}
if (
isExpanded &&
(event.key === 'ArrowDown' ||
event.key === 'ArrowUp' ||
event.key === 'Home' ||
event.key === 'End')
) {
event.preventDefault();
onNavigate(event.key);
return;
}
if (isExpanded && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
shouldIgnoreNextClickRef.current = true;
onSelectActive();
}
};
const handleClick = () => {
if (shouldIgnoreNextClickRef.current) {
shouldIgnoreNextClickRef.current = false;
return;
}
onClick();
};
return (
<StyledControlContainer
type="button"
role="combobox"
$hasAdornment={!isUndefined(adornment)}
disabled={disabled}
aria-label={`${ariaLabel}: ${label}`}
aria-expanded={isExpanded}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-activedescendant={isExpanded ? activeDescendantId : undefined}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
{adornment}
<OverflowingTextWithTooltip text={label} />
<StyledChevronWrapper>
<IconChevronDown size={ICON.size.md} stroke={ICON.stroke.sm} />
</StyledChevronWrapper>
</StyledControlContainer>
);
};

View File

@@ -0,0 +1,65 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
SETTINGS_SELECT_MENU_BACKDROP_Z_INDEX,
SETTINGS_SELECT_MENU_GAP_PIXELS,
SETTINGS_SELECT_MENU_MAX_HEIGHT_PIXELS,
SETTINGS_SELECT_MENU_WIDTH_PIXELS,
SETTINGS_SELECT_MENU_Z_INDEX,
} from 'src/front-components/constants/settings-select-menu.constant';
// The sandbox cannot portal into the host document, so the menu opens inline
// below its control, and the backdrop stands in for the outside click the
// host page never forwards to the front component.
const StyledBackdrop = styled.div`
inset: 0;
position: fixed;
z-index: ${SETTINGS_SELECT_MENU_BACKDROP_Z_INDEX};
`;
const StyledMenu = styled.div`
background-color: ${() => themeCssVariables.background.primary};
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.md};
box-shadow: ${() => themeCssVariables.boxShadow.strong};
box-sizing: border-box;
display: flex;
flex-direction: column;
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.md};
max-height: ${SETTINGS_SELECT_MENU_MAX_HEIGHT_PIXELS}px;
overflow: hidden;
position: absolute;
right: 0;
top: calc(100% + ${SETTINGS_SELECT_MENU_GAP_PIXELS}px);
width: ${SETTINGS_SELECT_MENU_WIDTH_PIXELS}px;
z-index: ${SETTINGS_SELECT_MENU_Z_INDEX};
`;
type SettingsSelectMenuProps = {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
};
export const SettingsSelectMenu = ({
isOpen,
onClose,
children,
}: SettingsSelectMenuProps) => (
<>
<StyledBackdrop
aria-hidden={!isOpen}
style={{ display: isOpen ? 'block' : 'none' }}
onClick={onClose}
/>
<StyledMenu
aria-hidden={!isOpen}
style={{ display: isOpen ? 'flex' : 'none' }}
>
{children}
</StyledMenu>
</>
);

View File

@@ -0,0 +1,5 @@
import styled from '@emotion/styled';
export const StyledDimmable = styled.div<{ $dimmed: boolean }>`
opacity: ${({ $dimmed }) => ($dimmed ? 0.72 : 1)};
`;

View File

@@ -0,0 +1,23 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
// Mirrors twenty-ui's Card without its overflow: hidden, which would clip the
// select menus that open inline below their rows. The rows paint their own
// background, so the first and last ones take over the card's corners.
export const StyledSettingsCard = styled.div`
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.md};
box-sizing: border-box;
color: ${() => themeCssVariables.font.color.secondary};
width: 100%;
& > :first-child {
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
& > :last-child {
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
}
`;

View File

@@ -0,0 +1,9 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledSettingsError = styled.div`
color: ${() => themeCssVariables.font.color.danger};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.xs};
margin-top: ${() => themeCssVariables.spacing[1]};
`;

View File

@@ -0,0 +1,8 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledSettingsSectionStack = styled.div`
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[4]};
`;

View File

@@ -0,0 +1,5 @@
import styled from '@emotion/styled';
export const StyledSettingsSelectAnchor = styled.div`
position: relative;
`;

View File

@@ -0,0 +1,11 @@
import styled from '@emotion/styled';
import { getSettingsControlStyles } from 'src/front-components/utils/get-settings-control-styles.util';
export const StyledSettingsTextArea = styled.textarea`
display: block;
line-height: 1.5;
min-height: 96px;
resize: vertical;
${getSettingsControlStyles}
`;

View File

@@ -0,0 +1,11 @@
import styled from '@emotion/styled';
import { getSettingsControlStyles } from 'src/front-components/utils/get-settings-control-styles.util';
export const StyledSettingsTextInput = styled.input`
display: flex;
flex-grow: 1;
height: 32px;
text-overflow: ellipsis;
${getSettingsControlStyles}
`;

View File

@@ -0,0 +1,60 @@
import styled from '@emotion/styled';
import { DEFAULT_COLOR_LABELS } from 'twenty-ui/navigation';
import { MAIN_COLOR_NAMES, type ThemeColor } from 'twenty-ui/theme';
import { DropdownMenuItemsContainer } from 'src/front-components/components/DropdownMenuItemsContainer';
import { DropdownMenuOption } from 'src/front-components/components/DropdownMenuOption';
import { DropdownMenuSeparator } from 'src/front-components/components/DropdownMenuSeparator';
import { SettingsColorSample } from 'src/front-components/components/SettingsColorSample';
const StyledListbox = styled.div`
min-height: 0;
overflow-y: auto;
width: 100%;
`;
type ThemeColorPickerMenuProps = {
listboxId: string;
selectedColor: ThemeColor | undefined;
isCustomSelected: boolean;
activeOption: ThemeColor | 'custom';
getOptionId: (option: ThemeColor | 'custom') => string;
onSelectColor: (color: ThemeColor) => void;
onSelectCustom: () => void;
};
export const ThemeColorPickerMenu = ({
listboxId,
selectedColor,
isCustomSelected,
activeOption,
getOptionId,
onSelectColor,
onSelectCustom,
}: ThemeColorPickerMenuProps) => (
<StyledListbox id={listboxId} role="listbox" aria-label="Tile background">
<DropdownMenuItemsContainer>
{MAIN_COLOR_NAMES.map((colorName) => (
<DropdownMenuOption
key={colorName}
id={getOptionId(colorName)}
onSelect={() => onSelectColor(colorName)}
text={DEFAULT_COLOR_LABELS[colorName]}
selected={colorName === selectedColor}
isActive={colorName === activeOption}
LeftComponent={<SettingsColorSample colorName={colorName} />}
/>
))}
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<DropdownMenuOption
id={getOptionId('custom')}
text="Custom hex"
selected={isCustomSelected}
isActive={activeOption === 'custom'}
onSelect={onSelectCustom}
/>
</DropdownMenuItemsContainer>
</StyledListbox>
);

View File

@@ -0,0 +1,141 @@
import { isUndefined } from '@sniptt/guards';
import { useState } from 'react';
import { DEFAULT_COLOR_LABELS } from 'twenty-ui/navigation';
import { MAIN_COLOR_NAMES, type ThemeColor } from 'twenty-ui/theme';
import { isDefined } from 'twenty-ui/utilities';
import { SettingsSelectControl } from 'src/front-components/components/SettingsSelectControl';
import { SettingsSelectMenu } from 'src/front-components/components/SettingsSelectMenu';
import { SettingsColorSample } from 'src/front-components/components/SettingsColorSample';
import { StyledSettingsSelectAnchor } from 'src/front-components/components/StyledSettingsSelectAnchor';
import { ThemeColorPickerMenu } from 'src/front-components/components/ThemeColorPickerMenu';
import {
getNextActiveOptionIndex,
type SettingsSelectNavigationKey,
} from 'src/front-components/utils/get-next-active-option-index.util';
// The control is capped at 120px, where "Custom hex" ellipsises to "Custom …".
const CUSTOM_CONTROL_LABEL = 'Custom';
const CUSTOM_OPTION_VALUE = 'custom';
const FALLBACK_SWATCH_COLOR_NAME: ThemeColor = 'gray';
const TILE_BACKGROUND_LISTBOX_ID = 'call-recorder-tile-background';
type TileBackgroundOption = ThemeColor | typeof CUSTOM_OPTION_VALUE;
// Built lazily: the SDK manifest extractor loads this module with twenty-ui
// mocked, so module-scope iteration over MAIN_COLOR_NAMES would throw.
const getTileBackgroundOptions = (): TileBackgroundOption[] => [
...MAIN_COLOR_NAMES,
CUSTOM_OPTION_VALUE,
];
const getTileBackgroundOptionId = (option: TileBackgroundOption) =>
`${TILE_BACKGROUND_LISTBOX_ID}-option-${option}`;
type TileBackgroundControlProps = {
swatchColor: string;
selectedColor: ThemeColor | undefined;
isCustomSelected: boolean;
disabled: boolean;
onSelectColor: (color: ThemeColor) => void;
onSelectCustom: () => void;
};
export const TileBackgroundControl = ({
swatchColor,
selectedColor,
isCustomSelected,
disabled,
onSelectColor,
onSelectCustom,
}: TileBackgroundControlProps) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [activeOptionIndex, setActiveOptionIndex] = useState(0);
const tileBackgroundOptions = getTileBackgroundOptions();
const label = isUndefined(selectedColor)
? CUSTOM_CONTROL_LABEL
: (DEFAULT_COLOR_LABELS[selectedColor] ?? selectedColor);
const selectedOption: TileBackgroundOption = isCustomSelected
? CUSTOM_OPTION_VALUE
: (selectedColor ?? FALLBACK_SWATCH_COLOR_NAME);
const handleMenuToggle = () => {
if (!isMenuOpen) {
setActiveOptionIndex(
Math.max(tileBackgroundOptions.indexOf(selectedOption), 0),
);
}
setIsMenuOpen((isOpen) => !isOpen);
};
const handleMenuClose = () => setIsMenuOpen(false);
const handleNavigate = (key: SettingsSelectNavigationKey) => {
setActiveOptionIndex((currentIndex) =>
getNextActiveOptionIndex({
key,
currentIndex,
optionCount: tileBackgroundOptions.length,
}),
);
};
const handleSelectActive = () => {
const activeOption = tileBackgroundOptions[activeOptionIndex];
if (activeOption === CUSTOM_OPTION_VALUE) {
onSelectCustom();
} else if (isDefined(activeOption)) {
onSelectColor(activeOption);
}
handleMenuClose();
};
return (
<StyledSettingsSelectAnchor>
<SettingsSelectControl
label={label}
ariaLabel="Tile background"
disabled={disabled}
listboxId={TILE_BACKGROUND_LISTBOX_ID}
activeDescendantId={getTileBackgroundOptionId(
tileBackgroundOptions[activeOptionIndex] ?? CUSTOM_OPTION_VALUE,
)}
isExpanded={isMenuOpen}
onNavigate={handleNavigate}
onSelectActive={handleSelectActive}
onEscape={handleMenuClose}
adornment={
<SettingsColorSample
colorName={selectedColor ?? FALLBACK_SWATCH_COLOR_NAME}
color={isCustomSelected ? swatchColor : undefined}
/>
}
onClick={handleMenuToggle}
/>
<SettingsSelectMenu isOpen={isMenuOpen} onClose={handleMenuClose}>
<ThemeColorPickerMenu
listboxId={TILE_BACKGROUND_LISTBOX_ID}
selectedColor={selectedColor}
isCustomSelected={isCustomSelected}
activeOption={
tileBackgroundOptions[activeOptionIndex] ?? CUSTOM_OPTION_VALUE
}
getOptionId={getTileBackgroundOptionId}
onSelectColor={(color) => {
onSelectColor(color);
handleMenuClose();
}}
onSelectCustom={() => {
onSelectCustom();
handleMenuClose();
}}
/>
</SettingsSelectMenu>
</StyledSettingsSelectAnchor>
);
};

View File

@@ -0,0 +1,70 @@
import { isUndefined } from '@sniptt/guards';
import { useId, useState } from 'react';
import { type IconComponent } from 'twenty-ui/icon';
import { SettingsOptionCardContentCounter } from 'src/front-components/components/SettingsOptionCardContentCounter';
import { useAutosaveApplicationVariable } from 'src/front-components/hooks/use-autosave-application-variable';
import { getNormalizedNumberValue } from 'src/front-components/utils/get-normalized-number-value.util';
type TimingCounterRowProps = {
frontComponentId: string;
variableKey: string;
title: string;
description: string;
Icon: IconComponent;
divider: boolean;
persistedValue: string;
};
export const TimingCounterRow = ({
frontComponentId,
variableKey,
title,
description,
Icon,
divider,
persistedValue,
}: TimingCounterRowProps) => {
const inputId = useId();
const [inputValue, setInputValue] = useState(persistedValue);
const { saveDebounced, saveImmediately } = useAutosaveApplicationVariable({
frontComponentId,
variableKey,
});
const handleChange = (value: string, changeType: 'input' | 'button') => {
setInputValue(value);
const valueToSave = getNormalizedNumberValue(value);
if (isUndefined(valueToSave)) {
saveDebounced.cancel();
return;
}
if (changeType === 'button') {
saveImmediately(valueToSave);
return;
}
saveDebounced(valueToSave);
};
return (
<SettingsOptionCardContentCounter
Icon={Icon}
title={title}
description={description}
divider={divider}
inputId={inputId}
value={inputValue}
errorMessage={
isUndefined(getNormalizedNumberValue(inputValue))
? 'Invalid number'
: undefined
}
onChange={handleChange}
onBlur={() => saveDebounced.flush()}
/>
);
};

View File

@@ -0,0 +1,116 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
// twenty-ui's Toggle is a base-ui Switch whose click handler constructs a
// PointerEvent, which the front component sandbox does not provide. This is
// the same control as a plain button, styled after twenty-ui's Toggle. The
// host theme squircles every corner, so the capsule and thumb opt out.
export type ToggleSize = 'small' | 'medium';
type ToggleDimensions = {
width: number;
height: number;
thumbSize: number;
};
const TOGGLE_DIMENSIONS_BY_SIZE: Record<ToggleSize, ToggleDimensions> = {
small: { width: 24, height: 16, thumbSize: 12 },
medium: { width: 32, height: 20, thumbSize: 16 },
};
const THUMB_INSET_PIXELS = 2;
const getCheckedThumbOffsetPixels = ({
width,
thumbSize,
}: ToggleDimensions) => width - thumbSize - THUMB_INSET_PIXELS;
const StyledToggle = styled.button<{
$toggleSize: ToggleSize;
}>`
align-items: center;
appearance: none;
background-color: ${() => themeCssVariables.background.transparent.medium};
border: none;
border-radius: ${() => themeCssVariables.border.radius.pill};
corner-shape: round;
cursor: pointer;
display: flex;
flex-shrink: 0;
height: ${({ $toggleSize }) => TOGGLE_DIMENSIONS_BY_SIZE[$toggleSize].height}px;
padding: 0;
position: relative;
transition: background-color
calc(${() => themeCssVariables.animation.duration.normal} * 1s) ease;
width: ${({ $toggleSize }) => TOGGLE_DIMENSIONS_BY_SIZE[$toggleSize].width}px;
&[data-checked='true'] {
background-color: ${() => themeCssVariables.color.blue};
}
&:disabled {
cursor: default;
opacity: 0.5;
}
`;
const StyledThumb = styled.span<{
$toggleSize: ToggleSize;
}>`
background-color: ${() => themeCssVariables.background.primary};
border-radius: 50%;
corner-shape: round;
display: block;
height: ${({ $toggleSize }) =>
TOGGLE_DIMENSIONS_BY_SIZE[$toggleSize].thumbSize}px;
left: 0;
position: absolute;
top: 50%;
transform: translate(${THUMB_INSET_PIXELS}px, -50%);
transition: transform
calc(${() => themeCssVariables.animation.duration.normal} * 1s) ease;
width: ${({ $toggleSize }) =>
TOGGLE_DIMENSIONS_BY_SIZE[$toggleSize].thumbSize}px;
&[data-checked='true'] {
transform: translate(
${({ $toggleSize }) =>
getCheckedThumbOffsetPixels(
TOGGLE_DIMENSIONS_BY_SIZE[$toggleSize],
)}px,
-50%
);
}
`;
type ToggleProps = {
id?: string;
checked: boolean;
disabled?: boolean;
toggleSize?: ToggleSize;
onChange: (checked: boolean) => void;
};
export const Toggle = ({
id,
checked,
disabled = false,
toggleSize = 'medium',
onChange,
}: ToggleProps) => (
<StyledToggle
type="button"
role="switch"
id={id}
aria-checked={checked ? 'true' : 'false'}
data-checked={checked ? 'true' : 'false'}
disabled={disabled}
$toggleSize={toggleSize}
onClick={() => onChange(!checked)}
>
<StyledThumb
data-checked={checked ? 'true' : 'false'}
$toggleSize={toggleSize}
/>
</StyledToggle>
);

View File

@@ -0,0 +1,120 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { isDefined } from 'twenty-ui/utilities';
import { DropdownMenuItemsContainer } from 'src/front-components/components/DropdownMenuItemsContainer';
import { DropdownMenuOption } from 'src/front-components/components/DropdownMenuOption';
import { SettingsSelectControl } from 'src/front-components/components/SettingsSelectControl';
import { SettingsSelectMenu } from 'src/front-components/components/SettingsSelectMenu';
import { StyledSettingsSelectAnchor } from 'src/front-components/components/StyledSettingsSelectAnchor';
import { EMPTY_OPTION_LABEL } from 'src/front-components/constants/empty-option-label.constant';
import {
getNextActiveOptionIndex,
type SettingsSelectNavigationKey,
} from 'src/front-components/utils/get-next-active-option-index.util';
const StyledListbox = styled.div`
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
`;
const TRANSCRIPT_PROVIDER_LISTBOX_ID = 'call-recorder-transcript-provider';
const getTranscriptProviderOptionId = (index: number) =>
`${TRANSCRIPT_PROVIDER_LISTBOX_ID}-option-${index}`;
type TranscriptProviderControlProps = {
value: string;
options: { label: string; value: string }[];
onChange: (value: string) => void;
};
export const TranscriptProviderControl = ({
value,
options,
onChange,
}: TranscriptProviderControlProps) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [activeOptionIndex, setActiveOptionIndex] = useState(0);
const selectedOption = options.find((option) => option.value === value);
const selectableOptions = [
{ label: EMPTY_OPTION_LABEL, value: '' },
...options,
];
const selectedOptionIndex = Math.max(
selectableOptions.findIndex(
(selectableOption) => selectableOption.value === value,
),
0,
);
const handleMenuToggle = () => {
if (!isMenuOpen) {
setActiveOptionIndex(selectedOptionIndex);
}
setIsMenuOpen((isOpen) => !isOpen);
};
const handleMenuClose = () => setIsMenuOpen(false);
const handleNavigate = (key: SettingsSelectNavigationKey) => {
setActiveOptionIndex((currentIndex) =>
getNextActiveOptionIndex({
key,
currentIndex,
optionCount: selectableOptions.length,
}),
);
};
const handleSelectActive = () => {
const activeOption = selectableOptions[activeOptionIndex];
if (isDefined(activeOption)) {
onChange(activeOption.value);
handleMenuClose();
}
};
return (
<StyledSettingsSelectAnchor>
<SettingsSelectControl
label={selectedOption?.label ?? EMPTY_OPTION_LABEL}
ariaLabel="Transcript provider"
listboxId={TRANSCRIPT_PROVIDER_LISTBOX_ID}
activeDescendantId={getTranscriptProviderOptionId(activeOptionIndex)}
isExpanded={isMenuOpen}
onNavigate={handleNavigate}
onSelectActive={handleSelectActive}
onEscape={handleMenuClose}
onClick={handleMenuToggle}
/>
<SettingsSelectMenu isOpen={isMenuOpen} onClose={handleMenuClose}>
<StyledListbox
id={TRANSCRIPT_PROVIDER_LISTBOX_ID}
role="listbox"
aria-label="Transcript provider"
>
<DropdownMenuItemsContainer>
{selectableOptions.map((option, index) => (
<DropdownMenuOption
key={option.value}
id={getTranscriptProviderOptionId(index)}
text={option.label}
selected={option.value === value}
isActive={index === activeOptionIndex}
onSelect={() => {
onChange(option.value);
handleMenuClose();
}}
/>
))}
</DropdownMenuItemsContainer>
</StyledListbox>
</SettingsSelectMenu>
</StyledSettingsSelectAnchor>
);
};

View File

@@ -0,0 +1,133 @@
import { useId, useState } from 'react';
import { Section } from 'twenty-ui/layout';
import { H2Title } from 'twenty-ui/typography';
import { CALL_RECORDER_TRANSCRIPT_PROVIDER_OPTIONS } from 'src/constants/call-recorder-transcript-provider-options';
import { LabelledSettingsField } from 'src/front-components/components/LabelledSettingsField';
import { SettingsOptionCardContentSelect } from 'src/front-components/components/SettingsOptionCardContentSelect';
import { SettingsOptionCardContentToggle } from 'src/front-components/components/SettingsOptionCardContentToggle';
import { StyledDimmable } from 'src/front-components/components/StyledDimmable';
import { StyledSettingsCard } from 'src/front-components/components/StyledSettingsCard';
import { StyledSettingsSectionStack } from 'src/front-components/components/StyledSettingsSectionStack';
import { StyledSettingsTextArea } from 'src/front-components/components/StyledSettingsTextArea';
import { TranscriptProviderControl } from 'src/front-components/components/TranscriptProviderControl';
import {
CALL_RECORDER_SUMMARY_ENABLED_ROW,
CALL_RECORDER_SUMMARY_PROMPT_FIELD,
CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW,
} from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { useAutosaveApplicationVariable } from 'src/front-components/hooks/use-autosave-application-variable';
import { getApplicationVariableValue } from 'src/front-components/utils/get-application-variable-value.util';
import { serializeRichTextMarkdown } from 'src/front-components/utils/serialize-rich-text-markdown.util';
import { extractRichTextMarkdown } from 'src/logic-functions/utils/extract-rich-text-markdown.util';
type TranscriptionSectionProps = {
frontComponentId: string;
};
export const TranscriptionSection = ({
frontComponentId,
}: TranscriptionSectionProps) => {
const inputId = useId();
const [providerValue, setProviderValue] = useState(() =>
getApplicationVariableValue(
CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.variableKey,
),
);
const [isSummaryEnabled, setIsSummaryEnabled] = useState(
() =>
getApplicationVariableValue(
CALL_RECORDER_SUMMARY_ENABLED_ROW.variableKey,
) === 'true',
);
const [promptMarkdown, setPromptMarkdown] = useState(
() =>
extractRichTextMarkdown(
getApplicationVariableValue(
CALL_RECORDER_SUMMARY_PROMPT_FIELD.variableKey,
),
) ?? '',
);
const { saveImmediately: saveProviderImmediately } =
useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.variableKey,
});
const { saveImmediately: saveSummaryEnabledImmediately } =
useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_SUMMARY_ENABLED_ROW.variableKey,
});
const { saveDebounced: savePromptDebounced } = useAutosaveApplicationVariable(
{
frontComponentId,
variableKey: CALL_RECORDER_SUMMARY_PROMPT_FIELD.variableKey,
},
);
const handleProviderChange = (value: string) => {
setProviderValue(value);
saveProviderImmediately(value);
};
const handleSummaryEnabledChange = (checked: boolean) => {
const value = checked ? 'true' : 'false';
setIsSummaryEnabled(checked);
saveSummaryEnabledImmediately(value);
};
const handlePromptChange = (value: string) => {
setPromptMarkdown(value);
savePromptDebounced(serializeRichTextMarkdown(value));
};
return (
<Section>
<H2Title
title="Transcription"
description="What happens to a recording once the call ends."
/>
<StyledSettingsSectionStack>
<StyledSettingsCard>
<SettingsOptionCardContentSelect
Icon={CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.Icon}
title={CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.title}
description={CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.description}
divider
>
<TranscriptProviderControl
value={providerValue}
options={CALL_RECORDER_TRANSCRIPT_PROVIDER_OPTIONS}
onChange={handleProviderChange}
/>
</SettingsOptionCardContentSelect>
<SettingsOptionCardContentToggle
Icon={CALL_RECORDER_SUMMARY_ENABLED_ROW.Icon}
title={CALL_RECORDER_SUMMARY_ENABLED_ROW.title}
description={CALL_RECORDER_SUMMARY_ENABLED_ROW.description}
checked={isSummaryEnabled}
onChange={handleSummaryEnabledChange}
/>
</StyledSettingsCard>
<StyledDimmable $dimmed={!isSummaryEnabled}>
<LabelledSettingsField
label={CALL_RECORDER_SUMMARY_PROMPT_FIELD.label}
inputId={inputId}
hint={CALL_RECORDER_SUMMARY_PROMPT_FIELD.hint}
>
<StyledSettingsTextArea
id={inputId}
placeholder="Value"
disabled={!isSummaryEnabled}
value={promptMarkdown}
onChange={(event) => handlePromptChange(event.target.value)}
onBlur={() => savePromptDebounced.flush()}
/>
</LabelledSettingsField>
</StyledDimmable>
</StyledSettingsSectionStack>
</Section>
);
};

View File

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

View File

@@ -0,0 +1,139 @@
import {
IconCalendarEvent,
IconClockPlay,
IconColorSwatch,
IconDoorEnter,
IconHourglassHigh,
IconLanguage,
IconMessage,
IconPhoto,
IconPlayerStop,
IconSparkles,
type IconComponent,
} from 'twenty-ui/icon';
import { CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name';
import { CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-bot-image-background-env-var-name';
import { CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-calendar-bot-scheduling-enabled-env-var-name';
import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds-env-var-name';
import { CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-join-early-minutes-env-var-name';
import { CALL_RECORDER_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-name-env-var-name';
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds-env-var-name';
import { CALL_RECORDER_RECORDING_NOTICE_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-notice-enabled-env-var-name';
import { CALL_RECORDER_RECORDING_NOTICE_MAX_LENGTH } from 'src/logic-functions/constants/call-recorder-recording-notice-max-length';
import { CALL_RECORDER_RECORDING_NOTICE_MESSAGE_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-notice-message-env-var-name';
import { CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-summary-enabled-env-var-name';
import { CALL_RECORDER_TRANSCRIPT_PROVIDER_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-transcript-provider-env-var-name';
import { CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-use-workspace-logo-env-var-name';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds-env-var-name';
export type CallRecorderCounterRow = {
variableKey: string;
title: string;
description: string;
Icon: IconComponent;
};
export const CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW = {
variableKey: CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_ENV_VAR_NAME,
title: 'Record my calendar meetings',
description: 'Joins every meeting with a supported video link.',
Icon: IconCalendarEvent,
};
export const CALL_RECORDER_RECORDING_NOTICE_ROW = {
variableKey: CALL_RECORDER_RECORDING_NOTICE_ENABLED_ENV_VAR_NAME,
title: 'Send recording notice',
description:
'Post a notice in the meeting chat when the recorder joins. Zoom, Google Meet and Microsoft Teams only.',
Icon: IconMessage,
};
export const CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD = {
variableKey: CALL_RECORDER_RECORDING_NOTICE_MESSAGE_ENV_VAR_NAME,
label: 'Recording notice message',
hint: `Truncated to ${CALL_RECORDER_RECORDING_NOTICE_MAX_LENGTH} characters for Google Meet.`,
};
export const CALL_RECORDER_TIMING_ROWS: CallRecorderCounterRow[] = [
{
variableKey: CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME,
title: 'Join before start',
description:
'Minutes before the scheduled start time. Set 0 to join on time.',
Icon: IconClockPlay,
},
{
variableKey: CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME,
title: 'Leave the lobby after',
description: 'Seconds the recorder waits to be admitted before giving up.',
Icon: IconDoorEnter,
},
{
variableKey: CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME,
title: 'Leave an empty call after',
description: 'Seconds to wait when nobody else ever joins the meeting.',
Icon: IconHourglassHigh,
},
{
variableKey: CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME,
title: 'Keep recording after everyone leaves',
description: 'Seconds of grace before the recording is ended.',
Icon: IconPlayerStop,
},
];
export const CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW = {
variableKey: CALL_RECORDER_TRANSCRIPT_PROVIDER_ENV_VAR_NAME,
title: 'Transcript provider',
description: 'Speech-to-text service used once the call ends.',
Icon: IconLanguage,
};
export const CALL_RECORDER_SUMMARY_ENABLED_ROW = {
variableKey: CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME,
title: 'AI summaries',
description: 'Generate a summary for every recording. Uses AI credits.',
Icon: IconSparkles,
};
export const CALL_RECORDER_USE_WORKSPACE_LOGO_ROW = {
variableKey: CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME,
title: 'Show workspace logo',
description: 'Display your logo on the recorder camera tile.',
Icon: IconPhoto,
};
export const CALL_RECORDER_TILE_BACKGROUND_ROW = {
variableKey: CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME,
title: 'Tile background',
description: 'Colour drawn behind the logo on the camera tile.',
Icon: IconColorSwatch,
};
export const CALL_RECORDER_NAME_FIELD = {
variableKey: CALL_RECORDER_NAME_ENV_VAR_NAME,
label: 'Recorder name',
hint: 'Shown to everyone else in the meeting when the recorder joins.',
};
export const CALL_RECORDER_SUMMARY_PROMPT_FIELD = {
variableKey: CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME,
label: 'Extra summary instructions',
hint: 'Appended to the built-in prompt. Leave empty to use it alone.',
};
// Keep this list aligned with the app config so every application variable is
// intentionally placed in the synchronous settings UI.
export const CALL_RECORDER_MAPPED_VARIABLE_KEYS: string[] = [
CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.variableKey,
CALL_RECORDER_NAME_FIELD.variableKey,
CALL_RECORDER_RECORDING_NOTICE_ROW.variableKey,
CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD.variableKey,
...CALL_RECORDER_TIMING_ROWS.map((row) => row.variableKey),
CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.variableKey,
CALL_RECORDER_SUMMARY_ENABLED_ROW.variableKey,
CALL_RECORDER_SUMMARY_PROMPT_FIELD.variableKey,
CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.variableKey,
CALL_RECORDER_TILE_BACKGROUND_ROW.variableKey,
];

View File

@@ -0,0 +1 @@
export const EMPTY_OPTION_LABEL = 'Select a value';

View File

@@ -0,0 +1,9 @@
export const SETTINGS_SELECT_MENU_GAP_PIXELS = 4;
export const SETTINGS_SELECT_MENU_MAX_HEIGHT_PIXELS = 320;
export const SETTINGS_SELECT_MENU_WIDTH_PIXELS = 200;
export const SETTINGS_SELECT_MENU_BACKDROP_Z_INDEX = 2000;
export const SETTINGS_SELECT_MENU_Z_INDEX = 2001;

View File

@@ -0,0 +1,33 @@
import { type ThemeColor } from 'twenty-ui/theme';
// twenty-ui's MAIN_COLORS_LIGHT holds display-p3 strings, which the hex
// validator in get-bot-image-background rejects — it would silently fall back
// to the default background. Named colours are stored as the sRGB equivalent
// of the same Radix step instead.
export const THEME_COLOR_HEX: Record<ThemeColor, string> = {
red: '#e5484d',
ruby: '#e54666',
crimson: '#e93d82',
tomato: '#e54d2e',
orange: '#f76b15',
amber: '#ffc53d',
yellow: '#ffe629',
lime: '#bdee63',
grass: '#46a758',
green: '#30a46c',
jade: '#29a383',
mint: '#86ead4',
turquoise: '#12a594',
cyan: '#00a2c7',
sky: '#7ce2fe',
blue: '#3e63dd',
iris: '#5b5bd6',
violet: '#6e56cf',
purple: '#8e4ec6',
plum: '#ab4aba',
pink: '#d6409f',
bronze: '#a18072',
gold: '#978365',
brown: '#ad7f58',
gray: '#999999',
};

View File

@@ -0,0 +1,27 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command, useSelectedRecordIds } from 'twenty-sdk/front-component';
import { GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { requestCallRecordingSummaryGeneration } from 'src/front-components/utils/request-call-recording-summary-generation.util';
const GenerateCallRecordingSummary = () => {
const calendarEventIds = useSelectedRecordIds();
return (
<Command
execute={() =>
requestCallRecordingSummaryGeneration({ calendarEventIds })
}
/>
);
};
export default defineFrontComponent({
universalIdentifier:
GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'generate-call-recording-summary-effect',
description:
'Requests AI summary generation for the call recordings of the selected calendar events.',
component: GenerateCallRecordingSummary,
isHeadless: true,
});

View File

@@ -0,0 +1,75 @@
import { isUndefined } from '@sniptt/guards';
import { useCallback, useRef } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { APPLICATION_VARIABLE_SAVE_DEBOUNCE_MILLISECONDS } from 'src/front-components/constants/application-variable-save-debounce.constant';
import { useSaveApplicationVariable } from 'src/front-components/hooks/use-save-application-variable';
import { createApplicationVariableSaveQueue } from 'src/front-components/utils/create-application-variable-save-queue.util';
type UseAutosaveApplicationVariableParams = {
frontComponentId: string;
variableKey: string;
onSaveSuccess?: (value: string) => void | Promise<void>;
onSaveError?: (value: string) => void;
};
export const useAutosaveApplicationVariable = ({
frontComponentId,
variableKey,
onSaveSuccess,
onSaveError,
}: UseAutosaveApplicationVariableParams) => {
const { saveApplicationVariable } =
useSaveApplicationVariable(frontComponentId);
const saveApplicationVariableRef = useRef(saveApplicationVariable);
const onSaveSuccessRef = useRef(onSaveSuccess);
const onSaveErrorRef = useRef(onSaveError);
const variableKeyRef = useRef(variableKey);
saveApplicationVariableRef.current = saveApplicationVariable;
onSaveSuccessRef.current = onSaveSuccess;
onSaveErrorRef.current = onSaveError;
variableKeyRef.current = variableKey;
const saveQueueRef = useRef<
ReturnType<typeof createApplicationVariableSaveQueue> | undefined
>(undefined);
if (isUndefined(saveQueueRef.current)) {
saveQueueRef.current = createApplicationVariableSaveQueue({
saveValue: async (value, isSupersededValue) => {
const isSaved = await saveApplicationVariableRef.current({
variableKey: variableKeyRef.current,
value,
});
if (isSaved) {
await onSaveSuccessRef.current?.(value);
return;
}
if (isSupersededValue()) {
return;
}
onSaveErrorRef.current?.(value);
},
});
}
const saveDebounced = useDebouncedCallback(
saveQueueRef.current.enqueueSave,
APPLICATION_VARIABLE_SAVE_DEBOUNCE_MILLISECONDS,
);
const saveImmediately = useCallback(
(value: string) => {
saveDebounced.cancel();
saveQueueRef.current?.enqueueSave(value);
},
[saveDebounced],
);
return { saveDebounced, saveImmediately };
};

View File

@@ -0,0 +1,31 @@
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { useUpdateApplicationVariable } from 'src/front-components/hooks/use-update-application-variable';
type SaveApplicationVariableParams = {
variableKey: string;
value: string;
};
export const useSaveApplicationVariable = (frontComponentId: string) => {
const { updateApplicationVariable } =
useUpdateApplicationVariable(frontComponentId);
const saveApplicationVariable = async ({
variableKey,
value,
}: SaveApplicationVariableParams) => {
const isUpdated = await updateApplicationVariable({ variableKey, value });
if (!isUpdated) {
enqueueSnackbar({
message: `Could not save ${variableKey}.`,
variant: 'error',
});
}
return isUpdated;
};
return { saveApplicationVariable };
};

View File

@@ -0,0 +1,41 @@
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { resolveCallRecorderApplicationId } from 'src/front-components/utils/resolve-call-recorder-application-id.util';
type UpdateApplicationVariableParams = {
variableKey: string;
value: string;
};
type UpdateApplicationVariableState = {
updateApplicationVariable: (
params: UpdateApplicationVariableParams,
) => Promise<boolean>;
};
export const useUpdateApplicationVariable = (
frontComponentId: string,
): UpdateApplicationVariableState => {
const updateApplicationVariable = async ({
variableKey,
value,
}: UpdateApplicationVariableParams): Promise<boolean> => {
try {
const applicationId =
await resolveCallRecorderApplicationId(frontComponentId);
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 { CALL_RECORDER_SETTINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-settings-front-component-universal-identifier';
import { CallRecorderSettings } from 'src/front-components/components/CallRecorderSettings';
export default defineSettingsFrontComponent({
universalIdentifier:
CALL_RECORDER_SETTINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'call-recorder-settings',
description: 'Settings panel for the call recorder application variables.',
component: CallRecorderSettings,
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest';
import { createApplicationVariableSaveQueue } from 'src/front-components/utils/create-application-variable-save-queue.util';
const createDeferredPromise = () => {
let resolvePromise: () => void = () => {};
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
return { promise, resolvePromise };
};
describe('createApplicationVariableSaveQueue', () => {
it('persists one value at a time and coalesces queued values to the latest', async () => {
const firstSave = createDeferredPromise();
const latestSave = createDeferredPromise();
const saveValue = vi
.fn<(value: string, isSupersededValue: () => boolean) => Promise<void>>()
.mockReturnValueOnce(firstSave.promise)
.mockReturnValueOnce(latestSave.promise);
const { enqueueSave } = createApplicationVariableSaveQueue({ saveValue });
enqueueSave('first');
enqueueSave('second');
enqueueSave('latest');
expect(saveValue).toHaveBeenCalledTimes(1);
expect(saveValue).toHaveBeenNthCalledWith(1, 'first', expect.any(Function));
firstSave.resolvePromise();
await firstSave.promise;
await Promise.resolve();
expect(saveValue).toHaveBeenCalledTimes(2);
expect(saveValue).toHaveBeenNthCalledWith(
2,
'latest',
expect.any(Function),
);
latestSave.resolvePromise();
await latestSave.promise;
});
it('reports a value as superseded only while a newer one is queued', async () => {
const firstSave = createDeferredPromise();
const supersededByValue: Record<string, boolean> = {};
const saveValue = vi
.fn<(value: string, isSupersededValue: () => boolean) => Promise<void>>()
.mockImplementation(async (value, isSupersededValue) => {
if (value === 'first') {
await firstSave.promise;
}
supersededByValue[value] = isSupersededValue();
});
const { enqueueSave } = createApplicationVariableSaveQueue({ saveValue });
enqueueSave('first');
enqueueSave('latest');
firstSave.resolvePromise();
await firstSave.promise;
await Promise.resolve();
await Promise.resolve();
expect(supersededByValue).toEqual({ first: true, latest: false });
});
});

View File

@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getApplicationVariableValue } from 'src/front-components/utils/get-application-variable-value.util';
describe('getApplicationVariableValue', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('reads the value injected into the front component synchronously', () => {
vi.stubEnv(
'applicationVariables',
JSON.stringify({ RECORDER_NAME: 'Meeting assistant' }),
);
expect(getApplicationVariableValue('RECORDER_NAME')).toBe(
'Meeting assistant',
);
});
it('returns an empty value when the variable is not configured', () => {
vi.stubEnv('applicationVariables', '{}');
expect(getApplicationVariableValue('RECORDER_NAME')).toBe('');
});
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { getNextActiveOptionIndex } from 'src/front-components/utils/get-next-active-option-index.util';
describe('getNextActiveOptionIndex', () => {
it.each([
{ key: 'ArrowDown' as const, currentIndex: 1, expectedIndex: 2 },
{ key: 'ArrowDown' as const, currentIndex: 3, expectedIndex: 0 },
{ key: 'ArrowUp' as const, currentIndex: 2, expectedIndex: 1 },
{ key: 'ArrowUp' as const, currentIndex: 0, expectedIndex: 3 },
{ key: 'Home' as const, currentIndex: 2, expectedIndex: 0 },
{ key: 'End' as const, currentIndex: 1, expectedIndex: 3 },
])(
'returns $expectedIndex for $key from $currentIndex',
({ key, currentIndex, expectedIndex }) => {
expect(
getNextActiveOptionIndex({
key,
currentIndex,
optionCount: 4,
}),
).toBe(expectedIndex);
},
);
it('returns zero when there are no options', () => {
expect(
getNextActiveOptionIndex({
key: 'ArrowDown',
currentIndex: 0,
optionCount: 0,
}),
).toBe(0);
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { getNormalizedHexValue } from 'src/front-components/utils/get-normalized-hex-value.util';
describe('getNormalizedHexValue', () => {
it('normalizes valid hex values', () => {
expect(getNormalizedHexValue('#1D1D1D')).toBe('#1d1d1d');
expect(getNormalizedHexValue(' #abc ')).toBe('#aabbcc');
});
it('keeps an empty value so the variable can be cleared', () => {
expect(getNormalizedHexValue('')).toBe('');
expect(getNormalizedHexValue(' ')).toBe('');
});
it('returns undefined for values that are not hex colours', () => {
expect(getNormalizedHexValue('#12')).toBeUndefined();
expect(getNormalizedHexValue('1d1d1d')).toBeUndefined();
expect(getNormalizedHexValue('red')).toBeUndefined();
});
});

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { getNormalizedNumberValue } from 'src/front-components/utils/get-normalized-number-value.util';
describe('getNormalizedNumberValue', () => {
it('normalizes numeric values', () => {
expect(getNormalizedNumberValue('0')).toBe('0');
expect(getNormalizedNumberValue(' 7 ')).toBe('7');
expect(getNormalizedNumberValue('-1.50')).toBe('-1.5');
});
it('keeps an empty value so a variable can be cleared', () => {
expect(getNormalizedNumberValue('')).toBe('');
expect(getNormalizedNumberValue(' ')).toBe('');
});
it('returns undefined for values that are not numbers', () => {
expect(getNormalizedNumberValue('abc')).toBeUndefined();
expect(getNormalizedNumberValue('12abc')).toBeUndefined();
expect(getNormalizedNumberValue('Infinity')).toBeUndefined();
});
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { THEME_COLOR_HEX } from 'src/front-components/constants/theme-color-hex.constant';
import { getThemeColorFromHex } from 'src/front-components/utils/get-theme-color-from-hex.util';
describe('getThemeColorFromHex', () => {
it('maps a stored hex back to its theme colour name', () => {
expect(getThemeColorFromHex('#3E63DD')).toBe('blue');
expect(getThemeColorFromHex('#e5484d')).toBe('red');
});
it('returns undefined for a colour outside the palette', () => {
expect(getThemeColorFromHex('#1d1d1d')).toBeUndefined();
});
it('stores every palette colour as a hex the bot image validator accepts', () => {
const hexPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
for (const hex of Object.values(THEME_COLOR_HEX)) {
expect(hex).toMatch(hexPattern);
}
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { normalizeHexColor } from 'src/front-components/utils/normalize-hex-color.util';
describe('normalizeHexColor', () => {
it('expands short hex values', () => {
expect(normalizeHexColor('#ABC')).toBe('#aabbcc');
});
it('lowercases long hex values', () => {
expect(normalizeHexColor(' #1D1D1D ')).toBe('#1d1d1d');
});
it('rejects anything that is not a hex colour', () => {
expect(
normalizeHexColor('color(display-p3 0.83 0.329 0.324)'),
).toBeUndefined();
expect(normalizeHexColor('red')).toBeUndefined();
expect(normalizeHexColor('')).toBeUndefined();
});
});

View File

@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
import { requestCallRecordingSummaryGeneration } from 'src/front-components/utils/request-call-recording-summary-generation.util';
const enqueueSnackbarMock = vi.hoisted(() => vi.fn());
const postMock = vi.hoisted(() => vi.fn());
const restApiClientMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-sdk/front-component', () => ({
enqueueSnackbar: enqueueSnackbarMock,
}));
vi.mock('twenty-client-sdk/rest', () => ({
RestApiClient: restApiClientMock,
}));
describe('requestCallRecordingSummaryGeneration', () => {
beforeEach(() => {
vi.clearAllMocks();
restApiClientMock.mockImplementation(function RestApiClient() {
return { post: postMock };
});
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: ['call-recording-1'],
failedCallRecordingIds: [],
erroredCallRecordingIds: [],
});
});
it('posts the /s-prefixed route path and lets the client resolve the url', async () => {
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1'],
});
expect(restApiClientMock).toHaveBeenCalledWith();
expect(postMock).toHaveBeenCalledWith(
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{ calendarEventIds: ['calendar-event-1'] },
);
});
it('does nothing when no calendar events are selected', async () => {
await requestCallRecordingSummaryGeneration({ calendarEventIds: [] });
expect(postMock).not.toHaveBeenCalled();
expect(enqueueSnackbarMock).not.toHaveBeenCalled();
});
it('reports mixed generation results', async () => {
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: ['call-recording-1'],
failedCallRecordingIds: ['call-recording-2'],
erroredCallRecordingIds: [],
});
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1', 'calendar-event-2'],
});
expect(postMock).toHaveBeenCalledWith(
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{ calendarEventIds: ['calendar-event-1', 'calendar-event-2'] },
);
expect(enqueueSnackbarMock).toHaveBeenCalledWith({
message: 'Some summaries generated, some failed.',
variant: 'error',
});
});
it('reports generation errors as failed summaries', async () => {
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: [],
failedCallRecordingIds: [],
erroredCallRecordingIds: ['call-recording-1'],
});
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1'],
});
expect(enqueueSnackbarMock).toHaveBeenCalledWith({
message: 'Summary generation failed.',
variant: 'error',
});
});
it('reports when the call recording has no usable transcript', async () => {
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: [],
failedCallRecordingIds: [],
erroredCallRecordingIds: [],
skippedCallRecordingIds: ['call-recording-1'],
});
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1'],
});
expect(enqueueSnackbarMock).toHaveBeenCalledWith({
message: 'No usable call recording transcript found for this event.',
variant: 'error',
});
});
it('reports when the transcript has nothing substantive to summarize', async () => {
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: [],
failedCallRecordingIds: [],
erroredCallRecordingIds: [],
skippedCallRecordingIds: [],
unavailableCallRecordingIds: ['call-recording-1'],
});
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1'],
});
expect(enqueueSnackbarMock).toHaveBeenCalledWith({
message: 'Summary unavailable for this event.',
variant: 'error',
});
});
});

View File

@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { metadataQuery } = vi.hoisted(() => ({
metadataQuery: vi.fn(),
}));
vi.mock('twenty-client-sdk/metadata', () => ({
MetadataApiClient: class {
query = metadataQuery;
},
}));
import { resolveCallRecorderApplicationId } from 'src/front-components/utils/resolve-call-recorder-application-id.util';
describe('resolveCallRecorderApplicationId', () => {
beforeEach(() => {
metadataQuery.mockReset();
});
it('shares one application lookup across concurrent saves', async () => {
metadataQuery.mockResolvedValue({
frontComponent: { applicationId: 'application-id' },
});
const firstResolution =
resolveCallRecorderApplicationId('front-component-id');
const secondResolution =
resolveCallRecorderApplicationId('front-component-id');
await expect(firstResolution).resolves.toBe('application-id');
await expect(secondResolution).resolves.toBe('application-id');
expect(metadataQuery).toHaveBeenCalledOnce();
});
it('retries the lookup after a failed resolution', async () => {
metadataQuery
.mockResolvedValueOnce({ frontComponent: null })
.mockResolvedValueOnce({
frontComponent: { applicationId: 'application-id' },
});
await expect(
resolveCallRecorderApplicationId('retry-front-component-id'),
).rejects.toThrow('Could not resolve the call recorder application.');
await expect(
resolveCallRecorderApplicationId('retry-front-component-id'),
).resolves.toBe('application-id');
expect(metadataQuery).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { extractRichTextMarkdown } from 'src/logic-functions/utils/extract-rich-text-markdown.util';
import { serializeRichTextMarkdown } from 'src/front-components/utils/serialize-rich-text-markdown.util';
describe('serializeRichTextMarkdown', () => {
it('wraps markdown into the stored rich text shape', () => {
expect(serializeRichTextMarkdown('Write terse notes.')).toBe(
'{"blocknote":null,"markdown":"Write terse notes."}',
);
});
it('serializes an empty prompt using the stored rich text shape', () => {
expect(serializeRichTextMarkdown('')).toBe(
'{"blocknote":null,"markdown":null}',
);
});
it('round-trips through the reader used by the summary prompt', () => {
expect(
extractRichTextMarkdown(serializeRichTextMarkdown('Focus on decisions.')),
).toBe('Focus on decisions.');
});
});

View File

@@ -0,0 +1,47 @@
import { isUndefined } from '@sniptt/guards';
type CreateApplicationVariableSaveQueueParams = {
saveValue: (
value: string,
isSupersededValue: () => boolean,
) => Promise<void>;
};
export const createApplicationVariableSaveQueue = ({
saveValue,
}: CreateApplicationVariableSaveQueueParams) => {
let queuedValue: string | undefined;
let isSaving = false;
const persistQueuedValues = async () => {
if (isSaving) {
return;
}
isSaving = true;
try {
while (!isUndefined(queuedValue)) {
const valueToSave = queuedValue;
queuedValue = undefined;
await saveValue(valueToSave, () => !isUndefined(queuedValue));
}
} finally {
isSaving = false;
if (!isUndefined(queuedValue)) {
void persistQueuedValues();
}
}
};
const enqueueSave = (value: string) => {
queuedValue = value;
void persistQueuedValues();
};
return { enqueueSave };
};

View File

@@ -0,0 +1,4 @@
import { getApplicationVariable } from 'twenty-sdk/front-component';
export const getApplicationVariableValue = (variableKey: string): string =>
getApplicationVariable(variableKey) ?? '';

View File

@@ -0,0 +1,33 @@
export type SettingsSelectNavigationKey =
| 'ArrowDown'
| 'ArrowUp'
| 'Home'
| 'End';
export const getNextActiveOptionIndex = ({
key,
currentIndex,
optionCount,
}: {
key: SettingsSelectNavigationKey;
currentIndex: number;
optionCount: number;
}): number => {
if (optionCount <= 0) {
return 0;
}
if (key === 'ArrowDown') {
return (currentIndex + 1) % optionCount;
}
if (key === 'ArrowUp') {
return (currentIndex - 1 + optionCount) % optionCount;
}
if (key === 'Home') {
return 0;
}
return optionCount - 1;
};

View File

@@ -0,0 +1,9 @@
import { normalizeHexColor } from 'src/front-components/utils/normalize-hex-color.util';
export const getNormalizedHexValue = (value: string): string | undefined => {
if (value.trim() === '') {
return '';
}
return normalizeHexColor(value);
};

View File

@@ -0,0 +1,15 @@
// Returns undefined when the draft cannot be stored, so a half-typed number is
// kept on screen instead of being persisted as a broken value.
export const getNormalizedNumberValue = (
value: string,
): string | undefined => {
const trimmedValue = value.trim();
if (trimmedValue === '') {
return '';
}
const parsedValue = Number(trimmedValue);
return Number.isFinite(parsedValue) ? String(parsedValue) : undefined;
};

View File

@@ -0,0 +1,37 @@
import { themeCssVariables } from 'twenty-ui/theme-constants';
// Mirrors the settings inputs twenty-front renders for application variables,
// which cannot be imported here: twenty-ui ships no text, select or textarea
// input, and front components cannot reach into twenty-front internals.
export const getSettingsControlStyles = () => `
background-color: ${themeCssVariables.background.transparent.light};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
color: ${themeCssVariables.font.color.primary};
font-family: ${themeCssVariables.font.family};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.regular};
outline: none;
padding: ${themeCssVariables.spacing[2]};
width: 100%;
&::placeholder,
&::-webkit-input-placeholder {
color: ${themeCssVariables.font.color.tertiary};
font-family: ${themeCssVariables.font.family};
font-weight: ${themeCssVariables.font.weight.medium};
}
&:disabled {
color: ${themeCssVariables.font.color.secondary};
}
&[readonly] {
pointer-events: none;
}
&:focus {
border-color: ${themeCssVariables.color.blue};
}
`;

View File

@@ -0,0 +1,17 @@
import { isUndefined } from '@sniptt/guards';
import { MAIN_COLOR_NAMES, type ThemeColor } from 'twenty-ui/theme';
import { THEME_COLOR_HEX } from 'src/front-components/constants/theme-color-hex.constant';
import { normalizeHexColor } from 'src/front-components/utils/normalize-hex-color.util';
export const getThemeColorFromHex = (value: string): ThemeColor | undefined => {
const normalizedValue = normalizeHexColor(value);
if (isUndefined(normalizedValue)) {
return undefined;
}
return MAIN_COLOR_NAMES.find(
(colorName) => THEME_COLOR_HEX[colorName] === normalizedValue,
);
};

View File

@@ -0,0 +1,16 @@
const SHORT_HEX_PATTERN = /^#[0-9a-f]{3}$/;
const LONG_HEX_PATTERN = /^#[0-9a-f]{6}$/;
export const normalizeHexColor = (value: string): string | undefined => {
const normalizedValue = value.trim().toLowerCase();
if (SHORT_HEX_PATTERN.test(normalizedValue)) {
return `#${normalizedValue
.slice(1)
.split('')
.map((character) => `${character}${character}`)
.join('')}`;
}
return LONG_HEX_PATTERN.test(normalizedValue) ? normalizedValue : undefined;
};

View File

@@ -0,0 +1,51 @@
import { isUndefined } from '@sniptt/guards';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { SYNC_CALENDAR_BOT_SCHEDULING_ROUTE_PATH } from 'src/constants/sync-calendar-bot-scheduling-route-path';
type SyncCalendarBotSchedulingResponse = {
outcome?: string;
canceledCallRecordingCount?: number;
};
const buildSnackbarForResponse = (
response: SyncCalendarBotSchedulingResponse,
): { message: string; variant: 'success' | 'error' } | undefined => {
if (response.outcome === 'sweep-enqueued') {
return undefined;
}
const canceledCount = response.canceledCallRecordingCount ?? 0;
if (canceledCount === 0) {
return undefined;
}
return {
message: `Canceled ${canceledCount} scheduled recording${canceledCount === 1 ? '' : 's'}.`,
variant: 'success',
};
};
export const requestCalendarBotSchedulingSync = async (): Promise<void> => {
try {
const response =
await new RestApiClient().post<SyncCalendarBotSchedulingResponse>(
`/s${SYNC_CALENDAR_BOT_SCHEDULING_ROUTE_PATH}`,
{},
);
const snackbar = buildSnackbarForResponse(response ?? {});
if (!isUndefined(snackbar)) {
await enqueueSnackbar(snackbar);
}
} catch {
await enqueueSnackbar({
message:
'Setting saved, but scheduled recordings could not be updated yet.',
variant: 'error',
});
}
};

View File

@@ -0,0 +1,95 @@
import { RestApiClient } from 'twenty-client-sdk/rest';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
type GenerateSummariesResponse = {
outcome?: string;
generatedCallRecordingIds?: string[];
failedCallRecordingIds?: string[];
erroredCallRecordingIds?: string[];
skippedCallRecordingIds?: string[];
unavailableCallRecordingIds?: string[];
};
const buildSnackbarForResponse = (
response: GenerateSummariesResponse,
): { message: string; variant: 'success' | 'error' } => {
const generatedCallRecordingCount = (response.generatedCallRecordingIds ?? [])
.length;
const failedCallRecordingCount =
(response.failedCallRecordingIds ?? []).length +
(response.erroredCallRecordingIds ?? []).length;
if (response.outcome === 'disabled') {
return {
message: 'Call summaries are disabled for this workspace.',
variant: 'error',
};
}
if (response.outcome === 'no-call-recordings-for-calendar-events') {
return {
message: 'No call recording found for this event.',
variant: 'error',
};
}
if (generatedCallRecordingCount > 0 && failedCallRecordingCount > 0) {
return {
message: 'Some summaries generated, some failed.',
variant: 'error',
};
}
if (generatedCallRecordingCount > 0) {
return { message: 'Summary generated.', variant: 'success' };
}
if (failedCallRecordingCount > 0) {
return { message: 'Summary generation failed.', variant: 'error' };
}
if ((response.unavailableCallRecordingIds ?? []).length > 0) {
return {
message: 'Summary unavailable for this event.',
variant: 'error',
};
}
if ((response.skippedCallRecordingIds ?? []).length > 0) {
return {
message: 'No usable call recording transcript found for this event.',
variant: 'error',
};
}
return {
message: 'No summary was generated for this event.',
variant: 'error',
};
};
export const requestCallRecordingSummaryGeneration = async ({
calendarEventIds,
}: {
calendarEventIds: string[];
}): Promise<void> => {
if (calendarEventIds.length === 0) {
return;
}
try {
const response = await new RestApiClient().post<GenerateSummariesResponse>(
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{ calendarEventIds },
);
await enqueueSnackbar(buildSnackbarForResponse(response ?? {}));
} catch {
await enqueueSnackbar({
message: 'Summary generation failed.',
variant: 'error',
});
}
};

View File

@@ -0,0 +1,54 @@
import { isNonEmptyString, isUndefined } from '@sniptt/guards';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
const applicationIdPromiseByFrontComponentId = new Map<
string,
Promise<string>
>();
const fetchCallRecorderApplicationId = async (frontComponentId: string) => {
const client = new MetadataApiClient();
const frontComponentResult = await client.query({
frontComponent: {
__args: { id: frontComponentId },
applicationId: true,
},
});
const applicationId = frontComponentResult.frontComponent?.applicationId;
if (!isNonEmptyString(applicationId)) {
throw new Error('Could not resolve the call recorder application.');
}
return applicationId;
};
export const resolveCallRecorderApplicationId = (
frontComponentId: string,
): Promise<string> => {
const existingApplicationIdPromise =
applicationIdPromiseByFrontComponentId.get(frontComponentId);
if (isUndefined(existingApplicationIdPromise)) {
const applicationIdPromise =
fetchCallRecorderApplicationId(frontComponentId);
applicationIdPromiseByFrontComponentId.set(
frontComponentId,
applicationIdPromise,
);
void applicationIdPromise.catch(() => {
if (
applicationIdPromiseByFrontComponentId.get(frontComponentId) ===
applicationIdPromise
) {
applicationIdPromiseByFrontComponentId.delete(frontComponentId);
}
});
return applicationIdPromise;
}
return existingApplicationIdPromise;
};

View File

@@ -0,0 +1,8 @@
// The settings tab stores RICH_TEXT variables as the blocknote/markdown pair
// produced by Twenty's rich text editor, so plain markdown has to be wrapped
// back into that shape to stay readable by both editors.
export const serializeRichTextMarkdown = (markdown: string): string =>
JSON.stringify({
blocknote: null,
markdown: markdown === '' ? null : markdown,
});

View File

@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { RECALL_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-api-key-env-var-name';
import { RECALL_REGION_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-region-env-var-name';
import cancelRecallBotOnCallRecordingDeleteLogicFunction, {
cancelRecallBotOnCallRecordingDeleteHandler,
} from 'src/logic-functions/cancel-recall-bot-on-call-recording-delete';
type HandlerEvent = Parameters<
typeof cancelRecallBotOnCallRecordingDeleteHandler
>[0];
const buildDeleteEvent = (
externalBotId: string | null = 'recall-bot-1',
): HandlerEvent => {
return {
name: 'callRecording.deleted',
recordId: 'call-recording-1',
properties: {
before: {
id: 'call-recording-1',
externalBotId,
},
},
} as HandlerEvent;
};
const fetchMock = vi.fn();
const RECALL_BOT_URL = 'https://us-west-2.recall.ai/api/v1/bot/recall-bot-1/';
describe('cancelRecallBotOnCallRecordingDeleteHandler', () => {
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);
vi.stubEnv(RECALL_API_KEY_ENV_VAR_NAME, 'recall-api-key');
vi.stubEnv(RECALL_REGION_ENV_VAR_NAME, 'us-west-2');
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('subscribes only to CallRecording deletion events', () => {
expect(
cancelRecallBotOnCallRecordingDeleteLogicFunction.config
.databaseEventTriggerSettings,
).toEqual({
eventName: 'callRecording.deleted',
});
});
it('cancels the Recall bot referenced by the deleted CallRecording', async () => {
const result = await cancelRecallBotOnCallRecordingDeleteHandler(
buildDeleteEvent(' recall-bot-1 '),
);
expect(fetchMock).toHaveBeenCalledExactlyOnceWith(
RECALL_BOT_URL,
expect.objectContaining({
method: 'DELETE',
headers: expect.objectContaining({
Authorization: 'Token recall-api-key',
}),
}),
);
expect(result).toEqual({
callRecordingId: 'call-recording-1',
externalBotId: 'recall-bot-1',
canceled: true,
});
});
it('reports when Recall does not remove the bot', async () => {
fetchMock.mockImplementation(async (_url: string, init: RequestInit) => {
if (init.method === 'DELETE') {
return new Response(
JSON.stringify({ detail: 'cancellation rejected' }),
{
status: 400,
},
);
}
return new Response(JSON.stringify({ detail: 'ejection rejected' }), {
status: 400,
});
});
const result =
await cancelRecallBotOnCallRecordingDeleteHandler(buildDeleteEvent());
expect(fetchMock).toHaveBeenNthCalledWith(
1,
RECALL_BOT_URL,
expect.objectContaining({ method: 'DELETE' }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
`${RECALL_BOT_URL}leave_call/`,
expect.objectContaining({ method: 'POST' }),
);
expect(result).toEqual({
callRecordingId: 'call-recording-1',
externalBotId: 'recall-bot-1',
canceled: false,
});
});
it.each([null, ' '])(
'skips deleted CallRecordings without a usable bot id',
async (externalBotId) => {
const result = await cancelRecallBotOnCallRecordingDeleteHandler(
buildDeleteEvent(externalBotId),
);
expect(result).toEqual({
skipped: true,
reason: 'call recording has no Recall bot',
});
expect(fetchMock).not.toHaveBeenCalled();
},
);
it('ignores unexpected event names', async () => {
const result = await cancelRecallBotOnCallRecordingDeleteHandler({
...buildDeleteEvent(),
name: 'callRecording.updated',
});
expect(result).toEqual({
skipped: true,
reason: 'not a call recording deletion',
});
expect(fetchMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
cancelOpenScheduledCallRecordingRequests: vi.fn(),
cancelWorkspaceRecallBots: vi.fn(),
findOpenScheduledCallRecordings: vi.fn(),
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: class CoreApiClient {},
}));
vi.mock(
'src/logic-functions/data/cancel-open-scheduled-call-recording-requests.util',
() => ({
cancelOpenScheduledCallRecordingRequests:
mocks.cancelOpenScheduledCallRecordingRequests,
}),
);
vi.mock(
'src/logic-functions/data/find-open-scheduled-call-recordings.util',
() => ({
findOpenScheduledCallRecordings: mocks.findOpenScheduledCallRecordings,
}),
);
vi.mock('src/logic-functions/flows/cancel-workspace-recall-bots.util', () => ({
cancelWorkspaceRecallBots: mocks.cancelWorkspaceRecallBots,
}));
import uninstallLogicFunction, {
cancelRecallBotsOnUninstallHandler,
} from 'src/logic-functions/cancel-recall-bots-on-uninstall';
const UNINSTALL_EXECUTION_TIME = new Date('2026-01-01T08:00:00.000Z');
const RECORD_CLEANUP_REQUEST_START_CUTOFF = new Date(
'2026-01-01T08:00:05.000Z',
);
describe('cancel-recall-bots-on-uninstall', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(UNINSTALL_EXECUTION_TIME);
mocks.findOpenScheduledCallRecordings.mockResolvedValue([
{ id: 'call-recording-1', externalBotId: 'known-bot' },
{ id: 'call-recording-2' },
{ id: 'call-recording-3', externalBotId: 'known-bot' },
]);
mocks.cancelOpenScheduledCallRecordingRequests.mockResolvedValue(3);
mocks.cancelWorkspaceRecallBots.mockResolvedValue({
scannedBotCount: 1,
canceledExternalBotIds: ['known-bot'],
failedExternalBotIds: [],
truncatedBotList: false,
cutoffReached: false,
});
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('uses a request-compatible 30 second uninstall timeout', () => {
expect(uninstallLogicFunction.config).toEqual(
expect.objectContaining({
name: 'cancel-recall-bots-on-uninstall',
timeoutSeconds: 30,
}),
);
});
it('cancels open records by id then passes their bot ids to one 25-hour safety scan', async () => {
const uninstallCleanupResult = await cancelRecallBotsOnUninstallHandler();
expect(mocks.cancelOpenScheduledCallRecordingRequests).toHaveBeenCalledWith(
expect.anything(),
['call-recording-1', 'call-recording-2', 'call-recording-3'],
expect.any(Function),
);
expect(mocks.cancelWorkspaceRecallBots).toHaveBeenCalledWith({
knownExternalBotIds: ['known-bot'],
joinAtAfter: '2025-12-31T07:00:00.000Z',
cancellationCutoffEpochMs: new Date('2026-01-01T08:00:15.000Z').getTime(),
});
expect(uninstallCleanupResult).toEqual({
canceledCallRecordingRequestCount: 3,
scannedBotCount: 1,
canceledExternalBotIds: ['known-bot'],
failedExternalBotIds: [],
truncatedBotList: false,
cutoffReached: false,
});
});
it('reserves the remaining cancellation window when record reads consume their budget', async () => {
mocks.findOpenScheduledCallRecordings.mockImplementationOnce(
async (
_coreApiClient: unknown,
shouldStartPageRequest: () => boolean,
) => {
expect(shouldStartPageRequest()).toBe(true);
vi.setSystemTime(RECORD_CLEANUP_REQUEST_START_CUTOFF);
return [{ id: 'call-recording-1', externalBotId: 'known-bot' }];
},
);
await expect(cancelRecallBotsOnUninstallHandler()).rejects.toThrow(
'record cleanup request cutoff reached before updates started',
);
expect(
mocks.cancelOpenScheduledCallRecordingRequests,
).not.toHaveBeenCalled();
expect(mocks.cancelWorkspaceRecallBots).toHaveBeenCalledWith({
knownExternalBotIds: ['known-bot'],
joinAtAfter: '2025-12-31T07:00:00.000Z',
cancellationCutoffEpochMs: new Date('2026-01-01T08:00:15.000Z').getTime(),
});
});
it('still attempts Recall cleanup and then fails loudly when record updates fail', async () => {
mocks.cancelOpenScheduledCallRecordingRequests.mockRejectedValue(
new Error('Cannot update records'),
);
await expect(cancelRecallBotsOnUninstallHandler()).rejects.toThrow(
'failed to cancel open call recording requests: Cannot update records',
);
expect(mocks.cancelWorkspaceRecallBots).toHaveBeenCalledWith({
knownExternalBotIds: ['known-bot'],
joinAtAfter: '2025-12-31T07:00:00.000Z',
cancellationCutoffEpochMs: new Date('2026-01-01T08:00:15.000Z').getTime(),
});
});
it('runs the Recall safety scan and fails loudly when open records cannot be read', async () => {
mocks.findOpenScheduledCallRecordings.mockRejectedValue(
new Error('Cannot read records'),
);
await expect(cancelRecallBotsOnUninstallHandler()).rejects.toThrow(
'failed to read open call recording requests: Cannot read records',
);
expect(
mocks.cancelOpenScheduledCallRecordingRequests,
).not.toHaveBeenCalled();
expect(mocks.cancelWorkspaceRecallBots).toHaveBeenCalledWith({
knownExternalBotIds: [],
joinAtAfter: '2025-12-31T07:00:00.000Z',
cancellationCutoffEpochMs: new Date('2026-01-01T08:00:15.000Z').getTime(),
});
});
});

View File

@@ -0,0 +1,270 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type RoutePayload } from 'twenty-sdk/define';
import { generateCallRecordingSummariesHandler } from 'src/logic-functions/generate-call-recording-summaries';
const queryMock = vi.hoisted(() => vi.fn());
const mutationMock = vi.hoisted(() => vi.fn());
const runAgentMock = vi.hoisted(() => vi.fn());
const enqueueJobsMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: class {
query = queryMock;
mutation = mutationMock;
},
}));
vi.mock('twenty-sdk/logic-function', () => ({
runAgent: runAgentMock,
enqueueJobs: enqueueJobsMock,
}));
const buildRoutePayload = (
body: object | null,
): RoutePayload<{ callRecordingIds?: string[]; calendarEventIds?: string[] }> =>
({
body,
headers: {},
queryStringParameters: {},
pathParameters: {},
isBase64Encoded: false,
rawBody: undefined,
requestContext: { http: { method: 'POST', path: '/' } },
userWorkspaceId: null,
}) as never;
const TRANSCRIPT = [
{
participant: { name: 'Alex' },
words: [{ text: 'Hello' }, { text: 'team' }],
},
];
type CallRecordingsQueryShape = {
callRecordings: {
__args: {
filter: {
id?: { eq: string };
calendarEventId?: { in: string[] };
};
};
};
};
const buildConnection = (nodes: object[]) => ({
callRecordings: {
pageInfo: { hasNextPage: false, endCursor: null },
edges: nodes.map((node) => ({ node })),
},
});
const buildSummarizableCallRecordingNode = (id: string) => ({
id,
createdAt: '2026-01-01T00:00:00.000Z',
title: 'Weekly sync',
transcript: TRANSCRIPT,
summary: { markdown: null },
createdBy: { source: 'APPLICATION', name: 'Call Recorder' },
});
const seedCallRecordingQueries = ({
calendarEventNodes = [],
callRecordingsById = {},
}: {
calendarEventNodes?: object[];
callRecordingsById?: Record<string, object>;
} = {}) => {
queryMock.mockImplementation(async (queryShape: unknown) => {
const filter = (queryShape as CallRecordingsQueryShape).callRecordings
.__args.filter;
if (filter.id !== undefined) {
const node = callRecordingsById[filter.id.eq];
return {
callRecordings: { edges: node === undefined ? [] : [{ node }] },
};
}
if (filter.calendarEventId !== undefined) {
return buildConnection(calendarEventNodes);
}
return buildConnection([]);
});
};
const queriedCallRecordingFilters = (): unknown[] =>
queryMock.mock.calls.map(
([queryShape]) =>
(queryShape as CallRecordingsQueryShape).callRecordings.__args.filter,
);
const BATCH_RESULT = {
generatedCallRecordingIds: ['call-recording-1'],
failedCallRecordingIds: [],
erroredCallRecordingIds: [],
skippedCallRecordingIds: [],
unavailableCallRecordingIds: [],
};
describe('generateCallRecordingSummariesHandler', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv('CALL_RECORDER_SUMMARY_ENABLED', 'true');
vi.stubEnv('CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT', '');
mutationMock.mockResolvedValue({});
runAgentMock.mockResolvedValue({
success: true,
error: null,
result: { response: '## Overview\nGood call.' },
});
seedCallRecordingQueries();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('returns disabled without touching data when summaries are off', async () => {
vi.stubEnv('CALL_RECORDER_SUMMARY_ENABLED', 'false');
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload(null),
);
expect(result).toEqual({ outcome: 'disabled' });
expect(queryMock).not.toHaveBeenCalled();
expect(runAgentMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
expect(enqueueJobsMock).not.toHaveBeenCalled();
});
it('processes explicit call recording ids inline without enqueuing', async () => {
seedCallRecordingQueries({
callRecordingsById: {
'call-recording-1':
buildSummarizableCallRecordingNode('call-recording-1'),
},
});
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload({ callRecordingIds: ['call-recording-1'] }),
);
expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT });
expect(queriedCallRecordingFilters()).toEqual([
{ id: { eq: 'call-recording-1' } },
]);
expect(enqueueJobsMock).not.toHaveBeenCalled();
expect(mutationMock).toHaveBeenCalledWith({
updateCallRecording: {
__args: {
id: 'call-recording-1',
data: {
summary: { blocknote: null, markdown: '## Overview\nGood call.' },
},
},
id: true,
},
});
});
it('resolves calendar event ids to their call recordings', async () => {
seedCallRecordingQueries({
calendarEventNodes: [{ id: 'call-recording-7' }],
callRecordingsById: {
'call-recording-7':
buildSummarizableCallRecordingNode('call-recording-7'),
},
});
await generateCallRecordingSummariesHandler(
buildRoutePayload({ calendarEventIds: ['calendar-event-1'] }),
);
expect(queriedCallRecordingFilters()).toEqual([
{ calendarEventId: { in: ['calendar-event-1'] } },
{ id: { eq: 'call-recording-7' } },
]);
expect(mutationMock).toHaveBeenCalledWith(
expect.objectContaining({
updateCallRecording: expect.objectContaining({
__args: expect.objectContaining({ id: 'call-recording-7' }),
}),
}),
);
});
it('regenerates an existing summary when requested from a calendar event', async () => {
seedCallRecordingQueries({
calendarEventNodes: [{ id: 'call-recording-1' }],
callRecordingsById: {
'call-recording-1': {
...buildSummarizableCallRecordingNode('call-recording-1'),
summary: { markdown: '## Overview\nOld summary.' },
},
},
});
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload({ calendarEventIds: ['calendar-event-1'] }),
);
expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT });
expect(runAgentMock).toHaveBeenCalledTimes(1);
expect(mutationMock).toHaveBeenCalledTimes(1);
});
it('reports when the selected calendar events have no recordings instead of enqueuing', async () => {
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload({ calendarEventIds: ['calendar-event-1'] }),
);
expect(result).toEqual({
outcome: 'no-call-recordings-for-calendar-events',
});
expect(queriedCallRecordingFilters()).toEqual([
{ calendarEventId: { in: ['calendar-event-1'] } },
]);
expect(runAgentMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
expect(enqueueJobsMock).not.toHaveBeenCalled();
});
it('returns nothing-selected when no ids are given instead of sweeping history', async () => {
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload(null),
);
expect(result).toEqual({ outcome: 'nothing-selected' });
expect(queryMock).not.toHaveBeenCalled();
expect(runAgentMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
expect(enqueueJobsMock).not.toHaveBeenCalled();
});
it('does not enqueue when an empty calendar event selection is sent', async () => {
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload({ calendarEventIds: [] }),
);
expect(result).toEqual({ outcome: 'nothing-selected' });
expect(queryMock).not.toHaveBeenCalled();
expect(runAgentMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
expect(enqueueJobsMock).not.toHaveBeenCalled();
});
it('returns nothing-selected for an empty body', async () => {
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload({}),
);
expect(result).toEqual({ outcome: 'nothing-selected' });
expect(queryMock).not.toHaveBeenCalled();
expect(runAgentMock).not.toHaveBeenCalled();
expect(enqueueJobsMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import processRecallWebhookLogicFunction, {
processRecallWebhookHandler,
} from 'src/logic-functions/process-recall-webhook';
const queryMock = vi.hoisted(() => vi.fn());
const mutationMock = vi.hoisted(() => vi.fn());
const enqueueArtifactImportMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: class {
query = queryMock;
mutation = mutationMock;
},
}));
vi.mock(
'src/logic-functions/data/enqueue-call-recording-artifacts-import.util',
() => ({
enqueueCallRecordingArtifactsImport: enqueueArtifactImportMock,
}),
);
const buildRecordingDoneWebhookBody = () => ({
event: 'recording.done',
data: {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: '123e4567-e89b-12d3-a456-426614174000',
twentyCallRecordingId: 'call-recording-1',
},
},
recording: { id: 'recall-recording-1' },
},
});
describe('process-recall-webhook', () => {
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
queryMock.mockReset();
queryMock.mockResolvedValue({
callRecordings: {
edges: [
{
node: {
id: 'call-recording-1',
status: 'PROCESSING',
externalRecordingId: 'recall-recording-1',
transcript: [
{
participant: { name: 'Ada' },
words: [
{ text: 'Hello world', start_timestamp: { relative: 0 } },
],
},
],
audio: [{ fileId: 'file-audio-1' }],
video: [{ fileId: 'file-video-1' }],
},
},
],
},
});
mutationMock.mockReset();
mutationMock.mockResolvedValue({
updateCallRecording: { id: 'call-recording-1' },
});
enqueueArtifactImportMock.mockReset();
enqueueArtifactImportMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('declares no external trigger so it only runs when dispatched by the resolver', () => {
expect(processRecallWebhookLogicFunction.success).toBe(true);
expect(
'serverRouteTriggerSettings' in processRecallWebhookLogicFunction.config,
).toBe(false);
expect(
processRecallWebhookLogicFunction.config.httpRouteTriggerSettings,
).toBeUndefined();
});
it('forwards the resolved payload to handleRecallWebhook with a workspace-scoped client', async () => {
const body = buildRecordingDoneWebhookBody();
const result = await processRecallWebhookHandler(body);
expect(queryMock).toHaveBeenCalledTimes(1);
expect(queryMock).toHaveBeenCalledWith(
expect.objectContaining({
callRecordings: expect.objectContaining({
__args: expect.objectContaining({
filter: { id: { eq: 'call-recording-1' } },
}),
}),
}),
);
expect(mutationMock).toHaveBeenCalledTimes(1);
expect(mutationMock).toHaveBeenCalledWith({
updateCallRecording: {
__args: {
id: 'call-recording-1',
data: {
externalBotId: 'recall-bot-1',
externalRecordingId: 'recall-recording-1',
status: 'PROCESSING',
},
},
id: true,
},
});
expect(enqueueArtifactImportMock).toHaveBeenCalledWith(
expect.objectContaining({ callRecordingId: 'call-recording-1' }),
);
expect(result).toEqual({
status: 'updated',
event: 'recording.done',
callRecordingId: 'call-recording-1',
callRecordingStatus: 'PROCESSING',
});
});
it('marks processing failures as retryable so the platform redelivers the job', async () => {
enqueueArtifactImportMock.mockRejectedValue(
new Error('Service unavailable'),
);
await expect(
processRecallWebhookHandler(buildRecordingDoneWebhookBody()),
).rejects.toMatchObject({
name: 'RetryableLogicFunctionError',
message: expect.stringContaining('Service unavailable'),
});
});
});

View File

@@ -0,0 +1,174 @@
import { createHmac } from 'crypto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import recallWebhookLogicFunction, {
recallWebhookRouteHandler,
} from 'src/logic-functions/recall-webhook';
const SECRET_BYTES = Buffer.from('entry-test-secret');
const SECRET = `whsec_${SECRET_BYTES.toString('base64')}`;
const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
const CALL_RECORDING_ID = 'call-recording-1';
type RecallWebhookRoutePayload = Parameters<
typeof recallWebhookRouteHandler
>[0];
const buildRoutePayload = (
overrides: Partial<RecallWebhookRoutePayload>,
): RecallWebhookRoutePayload =>
({
headers: {},
...overrides,
}) as RecallWebhookRoutePayload;
const buildSignedHeaders = (rawBody: string): Record<string, string> => {
const webhookId = 'msg_entry_test';
const webhookTimestamp = Math.floor(Date.now() / 1000).toString();
const signature = createHmac('sha256', SECRET_BYTES)
.update(`${webhookId}.${webhookTimestamp}.${rawBody}`)
.digest('base64');
return {
'webhook-id': webhookId,
'webhook-timestamp': webhookTimestamp,
'webhook-signature': `v1,${signature}`,
};
};
const buildRecordingDoneWebhookBody = () => ({
event: 'recording.done',
data: {
bot: {
id: 'recall-bot-1',
metadata: {
twentyWorkspaceId: WORKSPACE_ID,
twentyCallRecordingId: CALL_RECORDING_ID,
},
},
recording: {
id: 'recall-recording-1',
},
},
});
describe('recallWebhookRouteHandler', () => {
beforeEach(() => {
vi.stubEnv('RECALL_WEBHOOK_SECRET', SECRET);
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('declares a server route trigger that forwards the webhook signature headers', () => {
expect(recallWebhookLogicFunction.success).toBe(true);
expect(
recallWebhookLogicFunction.config.httpRouteTriggerSettings,
).toBeUndefined();
expect(
'serverRouteTriggerSettings' in recallWebhookLogicFunction.config,
).toBe(true);
if (!('serverRouteTriggerSettings' in recallWebhookLogicFunction.config)) {
throw new Error('Expected a server route trigger');
}
expect(
recallWebhookLogicFunction.config.serverRouteTriggerSettings,
).toEqual({
forwardedRequestHeaders: [
'webhook-id',
'webhook-timestamp',
'webhook-signature',
'svix-id',
'svix-timestamp',
'svix-signature',
],
});
});
it('throws when the webhook secret is not configured', () => {
vi.stubEnv('RECALL_WEBHOOK_SECRET', '');
expect(() =>
recallWebhookRouteHandler(buildRoutePayload({ rawBody: '{}', body: {} })),
).toThrow('RECALL_WEBHOOK_SECRET');
});
it('throws when the raw body is not forwarded', () => {
expect(() =>
recallWebhookRouteHandler(buildRoutePayload({ body: {} })),
).toThrow('Raw request body');
});
it('throws when the signature is invalid', () => {
expect(() =>
recallWebhookRouteHandler(
buildRoutePayload({
rawBody: '{}',
body: {},
headers: {
'webhook-id': 'msg_entry_test',
'webhook-timestamp': Math.floor(Date.now() / 1000).toString(),
'webhook-signature': 'v1,not-a-real-signature',
},
}),
),
).toThrow('Invalid webhook signature');
});
it('throws when a correctly signed payload is empty', () => {
const rawBody = 'null';
expect(() =>
recallWebhookRouteHandler(
buildRoutePayload({
rawBody,
body: null,
headers: buildSignedHeaders(rawBody),
}),
),
).toThrow('Webhook payload was empty');
});
it('throws when the workspace id is missing from the bot metadata', () => {
const body = {
event: 'recording.done',
data: { bot: { id: 'recall-bot-1' } },
};
const rawBody = JSON.stringify(body);
expect(() =>
recallWebhookRouteHandler(
buildRoutePayload({
rawBody,
body,
headers: buildSignedHeaders(rawBody),
}),
),
).toThrow('workspace id');
});
it('resolves the target workspace for a correctly signed payload', () => {
const body = buildRecordingDoneWebhookBody();
const rawBody = JSON.stringify(body);
const result = recallWebhookRouteHandler(
buildRoutePayload({
rawBody,
body,
headers: buildSignedHeaders(rawBody),
}),
);
expect(result).toEqual({
workspaceId: WORKSPACE_ID,
targetLogicFunctionUniversalIdentifier:
PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
payload: body,
});
});
});

View File

@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import reconcileLogicFunction, {
reconcileUpcomingCalendarEventsHandler,
} from 'src/logic-functions/reconcile-upcoming-calendar-events';
const queryMock = vi.hoisted(() => vi.fn());
const mutationMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: class {
query = queryMock;
mutation = mutationMock;
},
}));
type CalendarEventNode = {
id: string;
title: string;
isCanceled: boolean;
startsAt: string;
endsAt: string;
};
type RecordsQuery = {
calendarEvents?: {
__args: {
filter: {
id?: { in: string[] };
startsAt?: { in: string[] };
};
};
};
callRecordings?: { __args: { filter: Record<string, unknown> } };
};
const UPCOMING_STARTS_AT = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const UPCOMING_ENDS_AT = new Date(
Date.now() + 2 * 60 * 60 * 1000,
).toISOString();
// Without a conference link the policy deterministically skips each meeting.
const buildUpcomingCalendarEventNode = (id: string): CalendarEventNode => ({
id,
title: 'Upcoming Sync',
isCanceled: false,
startsAt: UPCOMING_STARTS_AT,
endsAt: UPCOMING_ENDS_AT,
});
const buildConnection = <TNode>(nodes: TNode[]) => ({
pageInfo: { hasNextPage: false, endCursor: null },
edges: nodes.map((node) => ({ node })),
});
let upcomingCalendarEventNodes: CalendarEventNode[];
describe('reconcileUpcomingCalendarEventsHandler', () => {
beforeEach(() => {
vi.clearAllMocks();
upcomingCalendarEventNodes = [];
queryMock.mockImplementation(async (query: RecordsQuery) => {
if (query.calendarEvents !== undefined) {
const filter = query.calendarEvents.__args.filter;
if (filter.id !== undefined) {
const requestedIds = filter.id.in;
return {
calendarEvents: buildConnection(
upcomingCalendarEventNodes.filter((node) =>
requestedIds.includes(node.id),
),
),
};
}
if (filter.startsAt !== undefined) {
const requestedStartsAtValues = filter.startsAt.in;
return {
calendarEvents: buildConnection(
upcomingCalendarEventNodes.filter((node) =>
requestedStartsAtValues.includes(node.startsAt),
),
),
};
}
return {
calendarEvents: buildConnection(upcomingCalendarEventNodes),
};
}
if (query.callRecordings !== undefined) {
return { callRecordings: buildConnection([]) };
}
throw new Error(`Unhandled query: ${JSON.stringify(query)}`);
});
});
it('is configured as an enqueue-only batch worker', () => {
expect(reconcileLogicFunction.config).toEqual(
expect.objectContaining({
name: 'reconcile-upcoming-calendar-events',
timeoutSeconds: 900,
}),
);
expect(reconcileLogicFunction.config).not.toHaveProperty(
'httpRouteTriggerSettings',
);
expect(reconcileLogicFunction.config).not.toHaveProperty(
'cronTriggerSettings',
);
});
it('reconciles the calendar events of its batch payload', async () => {
upcomingCalendarEventNodes = [
buildUpcomingCalendarEventNode('calendar-event-1'),
buildUpcomingCalendarEventNode('calendar-event-2'),
];
const result = await reconcileUpcomingCalendarEventsHandler({
calendarEventIds: ['calendar-event-1', 'calendar-event-2'],
});
expect(queryMock).toHaveBeenCalledWith(
expect.objectContaining({
calendarEvents: expect.objectContaining({
__args: expect.objectContaining({
filter: { id: { in: ['calendar-event-1', 'calendar-event-2'] } },
}),
}),
}),
);
expect(result).toEqual({
outcome: 'processed',
reconciledCalendarEventIds: ['calendar-event-1', 'calendar-event-2'],
actionCounts: {
created: 0,
updated: 0,
canceled: 0,
skipped: 2,
failed: 0,
},
});
});
it('skips payloads without calendar event ids', async () => {
expect(await reconcileUpcomingCalendarEventsHandler({})).toEqual({
outcome: 'nothing-selected',
});
expect(await reconcileUpcomingCalendarEventsHandler(null)).toEqual({
outcome: 'nothing-selected',
});
expect(
await reconcileUpcomingCalendarEventsHandler({ calendarEventIds: [] }),
).toEqual({ outcome: 'nothing-selected' });
expect(queryMock).not.toHaveBeenCalled();
});
it('rethrows a batch failure as retryable so the queue redelivers it', async () => {
queryMock.mockRejectedValue(new Error('Service unavailable'));
await expect(
reconcileUpcomingCalendarEventsHandler({
calendarEventIds: ['calendar-event-1'],
}),
).rejects.toMatchObject({
name: 'RetryableLogicFunctionError',
message: expect.stringContaining('Service unavailable'),
});
});
});

View File

@@ -0,0 +1,326 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import scheduleRecallBotOnCallRecordingUpdateLogicFunction, {
scheduleRecallBotOnCallRecordingUpdateHandler,
} from 'src/logic-functions/schedule-recall-bot-on-call-recording-update';
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();
const NOW = new Date('2026-01-01T12:00:00.000Z');
const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
const RECALL_CREATE_BOT_URL = 'https://us-west-2.recall.ai/api/v1/bot/';
const buildAccessToken = (payload: Record<string, unknown>): string =>
[
Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'),
Buffer.from(JSON.stringify(payload)).toString('base64url'),
'signature',
].join('.');
const buildConnection = <Node>(nodes: Node[]) => ({
pageInfo: { hasNextPage: false, endCursor: undefined },
edges: nodes.map((node) => ({ node })),
});
type HandlerEvent = Parameters<
typeof scheduleRecallBotOnCallRecordingUpdateHandler
>[0];
const buildUpdateEvent = (overrides: Partial<HandlerEvent> = {}): HandlerEvent =>
({
name: 'callRecording.updated',
recordId: 'call-recording-1',
properties: {
updatedFields: ['externalBotId'],
before: {
id: 'call-recording-1',
status: 'SCHEDULED',
recordingRequestStatus: 'REQUESTED',
externalBotId: 'recall-bot-vanished',
},
after: {
id: 'call-recording-1',
status: 'SCHEDULED',
recordingRequestStatus: 'REQUESTED',
externalBotId: null,
},
},
...overrides,
}) as HandlerEvent;
const stubPendingCallRecordingQueries = () => {
queryMock.mockImplementation(async (query: any) => {
if (query.callRecordings !== undefined) {
return {
callRecordings: buildConnection([
{
id: 'call-recording-1',
status: 'SCHEDULED',
recordingRequestStatus: 'REQUESTED',
calendarEventId: 'calendar-event-1',
externalBotId: null,
botScheduleAttemptedAt: null,
},
]),
};
}
if (query.calendarEvents !== undefined) {
return {
calendarEvents: buildConnection([
{
id: 'calendar-event-1',
startsAt: '2026-01-01T13:00:00.000Z',
endsAt: '2026-01-01T14:00:00.000Z',
iCalUid: 'calendar-event-uid',
conferenceLink: {
primaryLinkUrl: 'https://meet.example.com/customer-sync',
},
},
]),
};
}
throw new Error(`Unhandled query: ${JSON.stringify(query)}`);
});
};
describe('scheduleRecallBotOnCallRecordingUpdateHandler', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubGlobal('fetch', fetchMock);
vi.stubEnv('RECALL_API_KEY', 'recall-api-key');
vi.stubEnv('RECALL_REGION', 'us-west-2');
vi.stubEnv('CALL_RECORDER_USE_WORKSPACE_LOGO', 'false');
vi.stubEnv(
'TWENTY_APP_ACCESS_TOKEN',
buildAccessToken({ workspaceId: WORKSPACE_ID }),
);
queryMock.mockReset();
mutationMock.mockReset();
mutationMock.mockImplementation(async (mutation: any) => ({
updateCallRecording: { id: mutation.updateCallRecording.__args.id },
}));
fetchMock.mockReset();
fetchMock.mockImplementation(async (requestUrl: string) => {
if (requestUrl === RECALL_CREATE_BOT_URL) {
return new Response(JSON.stringify({ id: 'recall-bot-new' }), {
status: 201,
});
}
throw new Error(`Unhandled fetch in test: ${requestUrl}`);
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.useRealTimers();
vi.restoreAllMocks();
});
it('declares the pending-transition fields on the trigger so the server drops other updates', () => {
expect(
scheduleRecallBotOnCallRecordingUpdateLogicFunction.config
.databaseEventTriggerSettings,
).toEqual({
eventName: 'callRecording.updated',
updatedFields: [
'recordingRequestStatus',
'status',
'externalBotId',
'calendarEventId',
],
});
});
it('schedules a bot when an update clears the bot id of a requested recording', async () => {
stubPendingCallRecordingQueries();
const result = await scheduleRecallBotOnCallRecordingUpdateHandler(
buildUpdateEvent(),
);
expect(result).toEqual({
callRecordingId: 'call-recording-1',
result: { status: 'scheduled' },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
RECALL_CREATE_BOT_URL,
expect.objectContaining({ method: 'POST' }),
);
});
it('skips events that are not call recording updates', async () => {
const result = await scheduleRecallBotOnCallRecordingUpdateHandler(
buildUpdateEvent({ name: 'callRecording.created' } as HandlerEvent),
);
expect(result).toEqual({
skipped: true,
reason: 'not a call recording update',
});
expect(queryMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it('skips its own scheduling-progress writes so it cannot re-trigger itself', async () => {
const result = await scheduleRecallBotOnCallRecordingUpdateHandler(
buildUpdateEvent({
properties: {
updatedFields: [
'botScheduleAttemptedAt',
'botScheduleIdempotencyKey',
],
before: {},
after: {
id: 'call-recording-1',
status: 'SCHEDULED',
recordingRequestStatus: 'REQUESTED',
externalBotId: null,
},
},
} as unknown as HandlerEvent),
);
expect(result).toEqual({
skipped: true,
reason: 'no pending-transition field changed',
});
expect(queryMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it('skips updates that leave the recording with a bot attached', async () => {
const result = await scheduleRecallBotOnCallRecordingUpdateHandler(
buildUpdateEvent({
properties: {
updatedFields: ['externalBotId'],
before: {},
after: {
id: 'call-recording-1',
status: 'SCHEDULED',
recordingRequestStatus: 'REQUESTED',
externalBotId: 'recall-bot-1',
},
},
} as unknown as HandlerEvent),
);
expect(result).toEqual({
skipped: true,
reason: 'call recording is not pending',
});
expect(queryMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it('defers rows with an ambiguous prior attempt to the recovery cron instead of listing bots', async () => {
queryMock.mockImplementationOnce(async () => ({
callRecordings: buildConnection([
{
id: 'call-recording-1',
status: 'SCHEDULED',
recordingRequestStatus: 'REQUESTED',
calendarEventId: 'calendar-event-1',
externalBotId: null,
botScheduleAttemptedAt: '2026-01-01T11:55:00.000Z',
botScheduleIdempotencyKey: 'stale-key-from-moved-meeting',
},
]),
}));
queryMock.mockImplementationOnce(async () => ({
calendarEvents: buildConnection([
{
id: 'calendar-event-1',
startsAt: '2026-01-01T13:00:00.000Z',
endsAt: '2026-01-01T14:00:00.000Z',
conferenceLink: {
primaryLinkUrl: 'https://meet.example.com/customer-sync',
},
},
]),
}));
const result = await scheduleRecallBotOnCallRecordingUpdateHandler(
buildUpdateEvent(),
);
expect(result).toEqual({
callRecordingId: 'call-recording-1',
result: {
status: 'deferred',
reason: 'ambiguous prior attempt; the recovery cron will reconcile it',
},
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('skips slim payloads whose diff shows the bot id was written back', async () => {
const result = await scheduleRecallBotOnCallRecordingUpdateHandler(
buildUpdateEvent({
properties: {
updatedFields: ['externalBotId'],
diff: {
externalBotId: { before: null, after: 'recall-bot-1' },
},
},
} as unknown as HandlerEvent),
);
expect(result).toEqual({
skipped: true,
reason: 'call recording is not pending',
});
expect(queryMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it('does not schedule anything when the meeting already ended', async () => {
stubPendingCallRecordingQueries();
queryMock.mockImplementationOnce(async () => ({
callRecordings: buildConnection([
{
id: 'call-recording-1',
status: 'SCHEDULED',
recordingRequestStatus: 'REQUESTED',
calendarEventId: 'calendar-event-1',
externalBotId: null,
},
]),
}));
queryMock.mockImplementationOnce(async () => ({
calendarEvents: buildConnection([
{
id: 'calendar-event-1',
startsAt: '2026-01-01T10:00:00.000Z',
endsAt: '2026-01-01T11:00:00.000Z',
},
]),
}));
const result = await scheduleRecallBotOnCallRecordingUpdateHandler(
buildUpdateEvent(),
);
expect(result).toEqual({
callRecordingId: 'call-recording-1',
result: { status: 'skipped', reason: 'meeting already ended' },
});
expect(fetchMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SWEEP_UPCOMING_CALENDAR_EVENTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { ENQUEUED_JOB_RETRY_LIMIT } from 'src/logic-functions/constants/enqueued-job-retry-limit';
import postInstallLogicFunction, {
startPostInstallBackfillsHandler,
} from 'src/logic-functions/start-post-install-backfills';
const enqueueJobsMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-sdk/logic-function', async (importOriginal) => ({
...(await importOriginal<object>()),
enqueueJobs: enqueueJobsMock,
}));
describe('start-post-install-backfills', () => {
beforeEach(() => {
vi.clearAllMocks();
enqueueJobsMock.mockResolvedValue({ enqueued: true, enqueuedJobsCount: 1 });
});
it('is configured to run on fresh installs only', () => {
expect(postInstallLogicFunction.config).toEqual(
expect.objectContaining({
name: 'start-post-install-backfills',
timeoutSeconds: 30,
}),
);
expect(postInstallLogicFunction.config).not.toHaveProperty(
'shouldRunOnVersionUpgrade',
);
});
it('enqueues the upcoming calendar events sweep', async () => {
const result = await startPostInstallBackfillsHandler();
expect(result).toEqual({ calendarEventSweepOutcome: 'sweep-enqueued' });
expect(enqueueJobsMock).toHaveBeenCalledExactlyOnceWith({
logicFunctionUniversalIdentifier:
SWEEP_UPCOMING_CALENDAR_EVENTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
payloads: [{}],
retryLimit: ENQUEUED_JOB_RETRY_LIMIT,
});
});
it('throws when the sweep enqueue fails so the hook retries', async () => {
enqueueJobsMock.mockRejectedValue(new Error('Network failed'));
await expect(startPostInstallBackfillsHandler()).rejects.toMatchObject({
name: 'RetryableLogicFunctionError',
message: expect.stringContaining('Network failed'),
});
});
});

View File

@@ -0,0 +1,204 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { summarizeCallRecordingHandler } from 'src/logic-functions/summarize-call-recording';
const queryMock = vi.hoisted(() => vi.fn());
const mutationMock = vi.hoisted(() => vi.fn());
const runAgentMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: class {
query = queryMock;
mutation = mutationMock;
},
}));
vi.mock('twenty-sdk/logic-function', async (importOriginal) => ({
...(await importOriginal<object>()),
runAgent: runAgentMock,
}));
const TRANSCRIPT = [
{
participant: { name: 'Alex' },
words: [{ text: 'Hello' }, { text: 'team' }],
},
];
const FAKE_OBJECT_METADATA = {
id: 'object-metadata-id',
nameSingular: 'callRecording',
namePlural: 'callRecordings',
labelSingular: 'Call Recording',
labelPlural: 'Call Recordings',
description: null,
icon: null,
universalIdentifier: 'call-recording-object',
applicationId: null,
dataSourceId: null,
overrides: null,
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
isUIEditable: false,
isUICreatable: false,
isAuditLogged: false,
isSearchable: false,
duplicateCriteria: null,
shortcut: null,
labelIdentifierFieldMetadataId: 'label-field-id',
imageIdentifierFieldMetadataId: null,
isLabelSyncedWithName: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
fieldIds: [],
indexMetadataIds: [],
viewIds: [],
applicationUniversalIdentifier: null,
labelIdentifierFieldMetadataUniversalIdentifier: 'label-field',
imageIdentifierFieldMetadataUniversalIdentifier: null,
fieldUniversalIdentifiers: [],
indexMetadataUniversalIdentifiers: [],
viewUniversalIdentifiers: [],
} satisfies Parameters<
typeof summarizeCallRecordingHandler
>[0]['objectMetadata'];
const buildEvent = ({
name,
updatedFields,
recordId = 'call-recording-1',
}: {
name: string;
updatedFields: string[];
recordId?: string;
}): Parameters<typeof summarizeCallRecordingHandler>[0] => ({
name,
workspaceId: 'workspace-id',
objectMetadata: FAKE_OBJECT_METADATA,
recordId,
properties: { updatedFields },
});
describe('summarize-call-recording logic function', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv('CALL_RECORDER_SUMMARY_ENABLED', 'true');
vi.stubEnv('CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT', '');
queryMock.mockResolvedValue({
callRecordings: {
edges: [
{
node: {
id: 'call-recording-1',
title: 'Weekly sync',
transcript: TRANSCRIPT,
summary: { markdown: null },
createdBy: { source: 'APPLICATION', name: 'Call Recorder' },
},
},
],
},
});
mutationMock.mockResolvedValue({});
runAgentMock.mockResolvedValue({
success: true,
error: null,
result: { response: '## Overview\nGood call.' },
});
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('generates a summary when the transcript field changed', async () => {
const result = await summarizeCallRecordingHandler(
buildEvent({
name: 'callRecording.updated',
updatedFields: ['transcript'],
}),
);
expect(result).toEqual({
callRecordingId: 'call-recording-1',
outcome: 'generated',
});
expect(mutationMock).toHaveBeenCalledWith({
updateCallRecording: {
__args: {
id: 'call-recording-1',
data: {
summary: { blocknote: null, markdown: '## Overview\nGood call.' },
},
},
id: true,
},
});
});
it('rethrows a generation failure as retryable so the trigger is redelivered', async () => {
runAgentMock.mockRejectedValue(new Error('Agent unavailable'));
await expect(
summarizeCallRecordingHandler(
buildEvent({
name: 'callRecording.updated',
updatedFields: ['transcript'],
}),
),
).rejects.toMatchObject({
name: 'RetryableLogicFunctionError',
message: expect.stringContaining('Agent unavailable'),
});
});
it('returns a save error without rethrowing so a paid agent run is never redelivered', async () => {
mutationMock.mockRejectedValue(new Error('Write timed out'));
const result = await summarizeCallRecordingHandler(
buildEvent({
name: 'callRecording.updated',
updatedFields: ['transcript'],
}),
);
expect(result).toEqual({
callRecordingId: 'call-recording-1',
outcome: 'save-error',
});
expect(runAgentMock).toHaveBeenCalledTimes(1);
});
it('skips summary-only updates to avoid re-entrancy', async () => {
const result = await summarizeCallRecordingHandler(
buildEvent({
name: 'callRecording.updated',
updatedFields: ['summary'],
}),
);
expect(queryMock).not.toHaveBeenCalled();
expect(runAgentMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
expect(result).toEqual({ skipped: true, reason: 'transcript unchanged' });
});
it('skips non-update events', async () => {
const result = await summarizeCallRecordingHandler(
buildEvent({
name: 'callRecording.created',
updatedFields: ['transcript'],
}),
);
expect(queryMock).not.toHaveBeenCalled();
expect(runAgentMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
expect(result).toEqual({
skipped: true,
reason: 'not a call recording update',
});
});
});

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