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,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
*.d.ts

View File

@@ -0,0 +1 @@
24.5.0

View File

@@ -0,0 +1,58 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.base.json"],
"plugins": ["react", "typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
],
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/jsx-key": "off",
"react/display-name": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-no-useless-fragment": "off",
"react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}

View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -0,0 +1,202 @@
# Fireflies for Twenty
Sync [Fireflies](https://fireflies.ai) call transcripts and AI summaries into
`CallRecording` records linked to the matching `CalendarEvent` in your Twenty
CRM — searchable, in context, and ready for AI agents and workflows to act
on. Plus on-demand workflow tools to sync, list, and search Fireflies calls
from the AI chat or workflow builder.
## What this app does
1. Fireflies records and transcribes your Zoom / Meet / Teams / phone call.
2. When the transcript is ready, Fireflies fires a `meeting.transcribed`
webhook; once Fireflies finishes its AI summary, it fires a separate
`meeting.summarized` webhook.
3. For each event, this app fetches the relevant data via the Fireflies
GraphQL API.
4. It upserts a `CallRecording` record (one per Fireflies call) with the
diarized transcript and the AI summary, linked to the matching
`CalendarEvent` when one is found.
Alongside the webhook, three [Workflow tools](#workflow-tools) let you
trigger Fireflies actions from the AI chat or as steps inside a workflow,
without waiting for Fireflies to push.
### How a transcript is matched to a CalendarEvent
The matcher tries two provider-ID strategies in priority order and stops at
the first hit:
1. **Provider-native event ID** — Fireflies' `calendar_id` / `cal_id` is
matched against `CalendarChannelEventAssociation.eventExternalId`. Covers
events synced into Twenty from Google Calendar (including individual
instances of recurring events, where Fireflies returns the per-instance
id with timestamp on `cal_id`).
2. **iCalUID** — Fireflies' `calendar_id` is matched against
`CalendarEvent.iCalUid`. Covers events synced from Outlook / CalDAV,
where Fireflies returns the RFC 5545 iCalUID directly.
Both identifiers are populated by Twenty's calendar drivers on every synced
CalendarEvent, so any meeting that's been pulled in via Google / Outlook /
CalDAV calendar sync will match exactly. The matcher does **not** fall back
to fuzzy URL matching — if the transcript can't be tied to a synced calendar
event, the CallRecording is still created, just without a linked calendar
event. This avoids silently linking transcripts to the wrong event.
## What gets added to your Twenty workspace
The app writes to the standard **CallRecording** object (no new schema):
- One CallRecording per Fireflies call, with a deterministic id — the
`meeting.transcribed` webhook, the `meeting.summarized` webhook, and manual
syncs all converge on the same record.
- **Transcript** — the diarized transcript (speaker names and sentence-level
timestamps) stored in the CallRecording `transcript` field.
- **Summary** — the Fireflies AI summary (overview, action items, topics,
keywords) stored as rich text in the CallRecording `summary` field.
- **Title, start/end time, external recording id, and the CalendarEvent
link** filled from the Fireflies call metadata.
Plus three workflow tools — see [Workflow tools](#workflow-tools) below.
## Workflow tools
Once the API key is configured, three tools become available in the workflow
builder and the AI chat — covering the cases the webhook can't:
- **Sync Fireflies Call** — *"sync the Fireflies call `01HXYZ...` into a
CallRecording now"*. As a workflow step: provide `transcriptId`. Runs the
same pipeline as the webhook (fetch transcript + AI summary, find matching
CalendarEvent, upsert the CallRecording) on demand. Use cases:
**backfilling** historical calls that happened before the app was
installed; **recovering** from a missed webhook (e.g. the calendar event
hadn't synced yet when Fireflies pushed); or triggering a sync from a
workflow instead of waiting for Fireflies. Output includes
`callRecordingId`, `calendarEventId`, `updatedFields`, and a per-field
outcome breakdown so partial successes are visible.
- **List Fireflies Calls By Participant** — *"show me my last 5 calls with
john@acme.com"*. As a workflow step: provide `participantEmail` (and
optional `limit`, max 50). Returns recent Fireflies calls — newest first —
where that email was an attendee, with title, date, duration, host, and
transcript URL. The natural first step in workflows triggered on
`Person.created` — *"find what we've talked about with this contact"*.
- **Search Fireflies Calls** — *"find any call where we discussed pricing"*.
As a workflow step: provide `keyword` (and optional `limit`, max 50).
Matches the keyword against both meeting titles and the words actually
spoken in meetings. Returns the same call-summary shape as the
participant tool. Best for AI-chat-driven research.
The list-by-participant and search tools return the same compact call shape:
`id`, `title`, `date`, `durationMinutes`, `participants`, `hostEmail`,
`transcriptUrl`, `meetingLink`. To then sync any of those calls into a
CallRecording, pass the `id` from a list result into **Sync Fireflies Call**.
## Installing
1. Open **Settings → Applications** in your Twenty workspace.
2. Find **Fireflies** in the available apps and click **Install**.
3. Follow [Self-hosting setup](#self-hosting-setup-admin-only) below to wire
up the API key and webhook (admin-only, one-time).
> **Heads up:** if you see *"Fireflies is not configured"* on the first
> webhook, your Twenty admin needs to follow the
> [Self-hosting setup](#self-hosting-setup-admin-only) section.
## Upgrading from 0.1.x
Version 0.1.x stored transcripts and summaries as two rich-text fields on
CalendarEvent. Upgrading removes those fields **and their stored content** —
recordings now live on the standard CallRecording object instead.
The removed content is a cache of Fireflies data: any call still available
in Fireflies can be re-ingested as a CallRecording by passing its id to
**Sync Fireflies Call** (find ids with the list / search tools). An
automatic history backfill on install and upgrade is planned as a follow-up.
## Limitations
What this connector intentionally does **not** support in v1:
- **Audio / video media ingestion.** Fireflies' `audio_url` / `video_url`
are not downloaded yet; the CallRecording holds the transcript and summary
only. Media ingestion is planned for a follow-up.
- **Fireflies sentiment, speaker analytics, transcript chapters.** Only
the raw transcript and the AI summary (overview, action items, topics,
keywords) are synced today.
- **Calls without a matching CalendarEvent (orphan calls).** These are no
longer skipped — the CallRecording is created — but it stays unlinked
until the calendar event syncs; re-running **Sync Fireflies Call** after
the calendar sync fills the link.
- **Per-user Fireflies accounts.** All transcripts come through one
workspace-shared API key (set by the admin). Per-user OAuth-style
connections require extending Twenty's connection provider system and are
planned once we have evidence that workspace-shared is too coarse.
- **Editing transcripts or summaries in Twenty.** The CallRecording fields
are writable but the next Fireflies sync overwrites any manual edits —
treat them as read-only.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Webhook returns `Fireflies is not configured` | `FIREFLIES_API_KEY` not set | Admin: paste the API key in **Settings → Applications → Fireflies → Settings** |
| Webhook returns `Invalid webhook signature` | `FIREFLIES_WEBHOOK_SECRET` mismatch between Fireflies and Twenty | Re-copy the signing secret from the Fireflies webhook configuration and paste it into the Twenty app settings |
| CallRecording is created but has no linked CalendarEvent | The meeting was never on a synced calendar in Twenty, or the workspace has no Google/Outlook/CalDAV calendar connection set up | Connect the relevant calendar provider in **Settings → Accounts** so the calendar event lands in Twenty with `eventExternalId` and `iCalUid` populated, then re-run **Sync Fireflies Call** for that transcript. Manually-created CalendarEvents are intentionally not matched in v1 |
| Transcript appears empty | Fireflies returned no sentences (call too short, audio failed) | Check the call in the Fireflies dashboard; nothing this app can do |
| Summary appears empty | Fireflies hasn't summarized the call yet, or the call was too short to summarize | Fireflies sends `meeting.summarized` separately from `meeting.transcribed` (typically a minute or two later); ensure that event is subscribed to in your Webhooks V2 config |
| Summary is populated but Transcript isn't (or vice versa) | Only one of the two Fireflies events is subscribed to | Subscribe to both `meeting.transcribed` and `meeting.summarized` in your Fireflies Webhooks V2 configuration |
| Fireflies API errors with `401` | API key wrong, rotated, or revoked | Generate a new key in Fireflies → Integrations → Fireflies API → Regenerate, then update `FIREFLIES_API_KEY` |
| **Sync Fireflies Call** reports `No CallRecording was written` | Fireflies returned no transcript sentences and no summary for the call, or the per-field outcomes show transient Fireflies API failures | Check the `fieldOutcomes` array in the result — `skipped` means Fireflies had no content for that field; `error` means Fireflies-side failure (retry, or inspect the error message) |
| **List / Search** tools return `count: 0` for a contact you've definitely talked to | Email mismatch — Fireflies stores the address as the participant joined the meeting with, which may differ from the contact's primary address in Twenty (aliases, plus-addressing, work vs. personal) | Try the contact's other known email addresses; cross-check the `participants` list on a known matching call |
---
## Self-hosting setup (admin-only)
This section is for Twenty server admins. If you're on Twenty Cloud, skip
this — the credentials may already be configured.
### 1. Generate a Fireflies API key
1. Visit https://app.fireflies.ai and sign in.
2. Go to **Integrations → Fireflies API**.
3. Click **Generate API key** and copy the value (it's only shown once).
### 2. Configure a Webhooks V2 endpoint in Fireflies
This integration targets [Fireflies Webhooks V2](https://docs.fireflies.ai/graphql-api/webhooks-v2)
(snake_case payload, granular event subscriptions). The legacy V1 webhook
format (`meetingId` / `eventType: "Transcription completed"`) is **not**
supported.
1. Open the Webhooks V2 page: https://app.fireflies.ai/integrations/api/webhook
2. Set the **Webhook URL** to your Twenty deployment's webhook endpoint:
`https://<your-twenty-domain>/webhook/fireflies`. Twenty resolves the
target workspace from the request's `Host` header, so the URL must match
the workspace's public domain — `localhost` is not valid in the
Fireflies UI. For local development, expose your dev server with a
tunnel like `ngrok http 3000` and paste the HTTPS forwarding URL here,
or skip the Fireflies UI entirely and POST a signed payload directly to
your local endpoint.
3. Set a **Signing Secret** (a long random string — generate one with
`openssl rand -hex 32`). Save it; you'll paste it into Twenty next.
4. Under **Events**, subscribe to **both**:
- **`meeting.transcribed`** — fires when the transcript is ready and
writes it to the CallRecording's **Transcript** field.
- **`meeting.summarized`** — fires once Fireflies finishes its AI summary
and writes it to the CallRecording's **Summary** field.
Subscribing to only one is fine if you don't want the other field
populated; the app dispatches per event.
5. **Save** the configuration.
### 3. Wire the credentials into Twenty
1. In Twenty: **Settings → Applications → Fireflies → Settings tab**.
2. Paste the Fireflies API key into the `FIREFLIES_API_KEY` row.
3. Paste the signing secret into the `FIREFLIES_WEBHOOK_SECRET` row.
After saving, the next time Fireflies finishes processing a recording, a
CallRecording with the transcript will appear (linked to the matching
CalendarEvent) within a few seconds; the summary follows once Fireflies
finishes the AI summarization step (typically a minute or two later —
Fireflies sends two separate webhooks).

View File

@@ -0,0 +1,46 @@
{
"name": "@twentyhq/fireflies",
"version": "0.4.0",
"description": "Fireflies call-transcript connector for Twenty",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2",
"twenty": ">=2.37.0"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.13.0",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"typecheck": "tsgo --noEmit -p tsconfig.spec.json",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run --config vitest.unit.config.ts"
},
"dependencies": {
"@sniptt/guards": "^0.2.0",
"zod": "^4.1.11"
},
"devDependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"oxlint": "^0.16.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"twenty-client-sdk": "2.37.0",
"twenty-sdk": "2.37.0",
"twenty-ui": "^1.0.0-alpha.1",
"typescript": "^5.9.3",
"use-debounce": "^10.0.0",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

View File

@@ -0,0 +1,44 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
role="img"
>
<title>Fireflies</title>
<defs>
<linearGradient id="fireflies-tl" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#8E2A8B" />
<stop offset="100%" stop-color="#D02D7E" />
</linearGradient>
<linearGradient id="fireflies-tr" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#C2287D" />
<stop offset="100%" stop-color="#E83C8E" />
</linearGradient>
<linearGradient id="fireflies-mid" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#B0297B" />
<stop offset="100%" stop-color="#D9357F" />
</linearGradient>
<linearGradient id="fireflies-bl" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#7C1F6E" />
<stop offset="100%" stop-color="#C8327F" />
</linearGradient>
</defs>
<path
fill="url(#fireflies-tl)"
d="M4 12a8 8 0 0 1 8-8h14v22H4V12z"
/>
<path
fill="url(#fireflies-tr)"
d="M30 4h22a8 8 0 0 1 8 8v14H30V4z"
/>
<path
fill="url(#fireflies-mid)"
d="M30 30h18v18H30V30z"
/>
<path
fill="url(#fireflies-bl)"
d="M4 30h22v22a8 8 0 0 1-8 8H4V30z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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