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

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

View File

@@ -0,0 +1,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,37 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
},
"overrides": [
{
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["twenty-shared", "twenty-shared/*"],
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
}
]
}
]
}
}
]
}

View File

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

View File

@@ -0,0 +1,70 @@
## Base documentation
- Getting started:
- https://docs.twenty.com/developers/extend/apps/getting-started/quick-start.md
- https://docs.twenty.com/developers/extend/apps/getting-started/concepts.md
- https://docs.twenty.com/developers/extend/apps/getting-started/project-structure.md
- https://docs.twenty.com/developers/extend/apps/getting-started/local-server.md
- https://docs.twenty.com/developers/extend/apps/getting-started/scaffolding.md
- https://docs.twenty.com/developers/extend/apps/getting-started/troubleshooting.md
- Config:
- https://docs.twenty.com/developers/extend/apps/config/overview.md
- https://docs.twenty.com/developers/extend/apps/config/application.md
- https://docs.twenty.com/developers/extend/apps/config/roles.md
- https://docs.twenty.com/developers/extend/apps/config/install-hooks.md
- https://docs.twenty.com/developers/extend/apps/config/public-assets.md
- Data:
- https://docs.twenty.com/developers/extend/apps/data/overview.md
- https://docs.twenty.com/developers/extend/apps/data/objects.md
- https://docs.twenty.com/developers/extend/apps/data/extending-objects.md
- https://docs.twenty.com/developers/extend/apps/data/relations.md
- Logic:
- https://docs.twenty.com/developers/extend/apps/logic/overview.md
- https://docs.twenty.com/developers/extend/apps/logic/logic-functions.md
- https://docs.twenty.com/developers/extend/apps/logic/skills-and-agents.md
- https://docs.twenty.com/developers/extend/apps/logic/connections.md
- Layout:
- https://docs.twenty.com/developers/extend/apps/layout/overview.md
- https://docs.twenty.com/developers/extend/apps/layout/views.md
- https://docs.twenty.com/developers/extend/apps/layout/navigation-menu-items.md
- https://docs.twenty.com/developers/extend/apps/layout/page-layouts.md
- https://docs.twenty.com/developers/extend/apps/layout/front-components.md
- https://docs.twenty.com/developers/extend/apps/layout/command-menu-items.md
- Operations:
- https://docs.twenty.com/developers/extend/apps/operations/overview.md
- https://docs.twenty.com/developers/extend/apps/operations/cli.md
- https://docs.twenty.com/developers/extend/apps/operations/testing.md
- https://docs.twenty.com/developers/extend/apps/operations/publishing.md
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
| Page layout tab | `yarn twenty dev:add pageLayoutTab` | `src/page-layout-tabs/<name>.ts` |
| Command menu item | `yarn twenty dev:add commandMenuItem` | `src/command-menu-items/<name>.ts` |
| View field | `yarn twenty dev:add viewField` | `src/view-fields/<name>.ts` |
| Connection provider | `yarn twenty dev:add connectionProvider` | `src/connection-providers/<name>.ts` |
This helps automatically generate required IDs etc.

View File

@@ -0,0 +1,32 @@
# Changelog
All notable changes to this application are documented in this file.
## 1.0.1
- No change, fix npm deployment
## 1.0.0
- Import available Fathom video, or audio for audio-only recordings, into the
Call Recording's media fields when it is within the 500 MB limit.
- Record why media could not be imported in Fathom's internal import state,
skip automatic retries of a settled failure, and let Sync Fathom Call clear
it to try again.
- Hold a recording in Processing until its media lands or is settled as
unavailable, rather than completing it as soon as the transcript arrives.
- Reconcile unfinished local media imports from the latest seven days daily,
without scanning Fathom history, and finish cleanup after disconnection.
- Leave rate-limited media imports recoverable after their bounded retries,
and retry saving a generated download ID before failing the job.
## 0.1.0
- Add per-user Fathom OAuth and signed webhook registration.
- Sync new Fathom recordings into idempotent Twenty Call Recordings.
- Delete registered Fathom webhooks when the app is uninstalled.
- Import the last 31 days of recordings when a Fathom account is connected
and accept manual history imports, paced per connected account.
- Add the Sync Fathom Call and List Fathom Calls By Participant workflow and AI
actions.

View File

@@ -0,0 +1,70 @@
## Base documentation
- Getting started:
- https://docs.twenty.com/developers/extend/apps/getting-started/quick-start.md
- https://docs.twenty.com/developers/extend/apps/getting-started/concepts.md
- https://docs.twenty.com/developers/extend/apps/getting-started/project-structure.md
- https://docs.twenty.com/developers/extend/apps/getting-started/local-server.md
- https://docs.twenty.com/developers/extend/apps/getting-started/scaffolding.md
- https://docs.twenty.com/developers/extend/apps/getting-started/troubleshooting.md
- Config:
- https://docs.twenty.com/developers/extend/apps/config/overview.md
- https://docs.twenty.com/developers/extend/apps/config/application.md
- https://docs.twenty.com/developers/extend/apps/config/roles.md
- https://docs.twenty.com/developers/extend/apps/config/install-hooks.md
- https://docs.twenty.com/developers/extend/apps/config/public-assets.md
- Data:
- https://docs.twenty.com/developers/extend/apps/data/overview.md
- https://docs.twenty.com/developers/extend/apps/data/objects.md
- https://docs.twenty.com/developers/extend/apps/data/extending-objects.md
- https://docs.twenty.com/developers/extend/apps/data/relations.md
- Logic:
- https://docs.twenty.com/developers/extend/apps/logic/overview.md
- https://docs.twenty.com/developers/extend/apps/logic/logic-functions.md
- https://docs.twenty.com/developers/extend/apps/logic/skills-and-agents.md
- https://docs.twenty.com/developers/extend/apps/logic/connections.md
- Layout:
- https://docs.twenty.com/developers/extend/apps/layout/overview.md
- https://docs.twenty.com/developers/extend/apps/layout/views.md
- https://docs.twenty.com/developers/extend/apps/layout/navigation-menu-items.md
- https://docs.twenty.com/developers/extend/apps/layout/page-layouts.md
- https://docs.twenty.com/developers/extend/apps/layout/front-components.md
- https://docs.twenty.com/developers/extend/apps/layout/command-menu-items.md
- Operations:
- https://docs.twenty.com/developers/extend/apps/operations/overview.md
- https://docs.twenty.com/developers/extend/apps/operations/cli.md
- https://docs.twenty.com/developers/extend/apps/operations/testing.md
- https://docs.twenty.com/developers/extend/apps/operations/publishing.md
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
| Page layout tab | `yarn twenty dev:add pageLayoutTab` | `src/page-layout-tabs/<name>.ts` |
| Command menu item | `yarn twenty dev:add commandMenuItem` | `src/command-menu-items/<name>.ts` |
| View field | `yarn twenty dev:add viewField` | `src/view-fields/<name>.ts` |
| Connection provider | `yarn twenty dev:add connectionProvider` | `src/connection-providers/<name>.ts` |
This helps automatically generate required IDs etc.

View File

@@ -0,0 +1,29 @@
# Fathom
**Every Fathom recording, transcript, and summary, right inside your CRM.**
## ✨ What you get
- **Fathom transcripts, summaries, and action items saved when available**
- **Recording video, or audio for audio-only calls, on the same record**
- **Saved to a Call Recording, with calendar links for clear matches**
- **New meetings synced as they happen**
- **Your last 31 days imported the moment you connect**
- **Built for AI & automation**
## 💳 Billing
**Free to run**: no per-seat, per-recording or per-import charge. AI actions use
AI credits on the model's token usage.
## 📌 Heads up
- **Everything you import is shared with your workspace.** Transcripts,
summaries, and media land on Call Recording records that every member with
access can read. Private syncing is coming later.
- **Media arrives after the transcript, and not always.** A recording stays in
Processing while Fathom prepares the download. Anything over 500 MB, and
view-only shares, never get media.
- **Calendar links need one clear match.** A Call Recording is linked to a
calendar event only when its meeting link and scheduled time point to exactly
one event.

View File

@@ -0,0 +1,103 @@
# Setup
Follow these steps to get your app running locally.
## Prerequisites
- Node.js (version specified in `.nvmrc`)
- Yarn 4
- Docker (to run the local Twenty server)
- A Fathom OAuth client
- A public HTTPS URL forwarding to the local Twenty server
Register this OAuth callback in Fathom:
```text
https://<development-host>/auth/apps/callback
```
Set Twenty's `SERVER_URL` to that same public host. The connection hook registers
this destination with Fathom automatically:
```text
https://<development-host>/webhooks/server/72b52885-e1ba-419f-8e2e-052700f2c9f2?connectionId=<connected-account-id>
```
The ID is the **Fathom webhook resolver** logic function. Deliveries are routed
to the workspace that made the connection, so no per-workspace domain is needed.
## Steps
1. Install dependencies:
```bash
yarn install
```
2. Start the local Twenty server:
```bash
yarn twenty docker:start
```
Check the server status at any time with `yarn twenty docker:status`.
3. Set `FATHOM_CLIENT_ID` and `FATHOM_CLIENT_SECRET` as server variables in
Twenty.
4. Start the development server and sync your app:
```bash
yarn twenty dev
```
5. Open [http://localhost:2020](http://localhost:2020), log in with the default development credentials: `tim@apple.dev` / `tim@apple.dev`, and connect Fathom from Settings. The connection hook registers the signed webhook and starts a 31-day import.
6. Import older history when needed with an authenticated request:
```bash
curl -X POST https://<development-host>/s/fathom/backfill \
-H "Authorization: Bearer <api-key-or-access-token>" \
-H "Content-Type: application/json" \
-d '{"days": 30}'
```
The import runs through the requesting user's Fathom connection.
## Media import behaviour
Fathom prepares the downloadable file after the transcript is ready, so a Call
Recording stays in Processing until its media lands or is settled as
unavailable.
- A daily reconciliation pass scans local Call Recordings started in the last
seven days and resumes media imports inactive for at least 30 minutes.
- Media imports respect Fathom's `Retry-After` delay for up to three retries.
Continued throttling leaves the import unfinished for the daily recovery
pass; recordings outside its seven-day window need Sync Fathom Call.
- Media is skipped for recordings above 500 MB, for recordings Fathom has no
downloadable media for, and for limited-access shares the connected account
may view but not download. The reason is recorded on the Fathom recording
import so automatic syncs do not retry a settled failure.
- Running Sync Fathom Call clears a recorded reason and tries once more, which
is how a re-shared recording gets its media.
Import pacing is best-effort: concurrent jobs can reserve overlapping slots.
After Fathom creates a download, Twenty retries saving its ID up to three total
attempts. A crash or failed save can still cause a later retry to request
another download generation; provider creation and local persistence are not
atomic.
## Verifying your setup
- `yarn lint` - Lint the project with oxlint
- `yarn typecheck` - Type-check the project
- `yarn test:unit` - Run unit tests
- `yarn test` - Run integration tests
## Troubleshooting
Disconnect and reconnect Fathom after changing the webhook URL or the webhook
scopes, so Fathom receives the new registration.
See the [troubleshooting guide](https://docs.twenty.com/developers/extend/apps/getting-started/troubleshooting) or ask on [Discord](https://discord.gg/cx5n4Jzs57).

View File

@@ -0,0 +1,40 @@
{
"name": "@twentyhq/fathom",
"description": "Fathom connector for Twenty: syncs meeting recordings, transcripts, summaries and action items into Call Recordings.",
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"twenty": ">=2.38.0",
"yarn": ">=4.0.2"
},
"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",
"fathom-typescript": "0.0.43",
"zod": "^4.1.11"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"oxlint": "^0.16.0",
"twenty-client-sdk": "2.38.0",
"twenty-sdk": "2.38.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 KiB

View File

@@ -0,0 +1,22 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 26 26"
fill="none"
role="img"
>
<title>Fathom</title>
<path
fill="#007299"
d="M1.336 17.2481V22.1875C1.336 23.4831 2.145 24.6563 3.361 25.0456C5.427 25.7052 7.325 24.1578 7.325 22.1606V20.301L1.336 17.2481Z"
/>
<path
fill="#00BEFF"
d="M21.965 16.2261C21.515 16.2261 21.059 16.1237 20.628 15.9055L3.053 6.9953C1.597 6.2568 0.92 4.4795 1.599 2.9791C2.307 1.4082 4.15 0.757 5.66 1.5223L23.233 10.4308C24.707 11.1777 25.382 13.049 24.644 14.5444C24.119 15.6101 23.062 16.2261 21.962 16.2261H21.965Z"
/>
<path
fill="#00BEFF"
d="M13.144 20.7273C12.694 20.7273 12.237 20.6249 11.807 20.4068L3.053 15.9693C1.597 15.2308 0.92 13.4535 1.599 11.953C2.307 10.3821 4.15 9.7309 5.662 10.4963L14.413 14.9321C15.887 15.6789 16.562 17.5502 15.824 19.0456C15.299 20.1114 14.242 20.7273 13.142 20.7273H13.144Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 928 B

View File

@@ -0,0 +1,14 @@
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('application identifiers', () => {
it('should expose the application metadata constants', () => {
expect(APP_DISPLAY_NAME).toBeTruthy();
expect(typeof APP_DESCRIPTION).toBe('string');
expect(APPLICATION_UNIVERSAL_IDENTIFIER).toBeTruthy();
});
});

View File

@@ -0,0 +1,136 @@
import * as fs from 'fs';
import * as path from 'path';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { functionExecute } from 'twenty-sdk/cli';
import { describe, expect, it } from 'vitest';
// These scenarios run the functions the way the server runs them: deployed,
// in the app runtime, against the real connection store and job queue. No
// Fathom account is connected on the test workspace, so every path ends at
// the connection lookup, after the bundle, the SDK calls and the input
// validation have run for real.
const APP_PATH = process.cwd();
const executeDeployedFunction = async (
functionName: string,
payload: Record<string, unknown>,
) => {
const result = await functionExecute({
appPath: APP_PATH,
functionName,
payload,
});
if (!result.success) {
throw new Error(
`Could not execute ${functionName}: ${result.error.message}`,
);
}
return result.data;
};
const buildRoutePayload = (body: Record<string, unknown>) => ({
headers: {},
queryStringParameters: {},
pathParameters: {},
body,
isBase64Encoded: false,
requestContext: { http: { method: 'POST', path: '/fathom/backfill' } },
});
const readManifestLogicFunctionNames = (): string[] => {
const manifest = JSON.parse(
fs.readFileSync(
path.join(APP_PATH, '.twenty', 'output', 'manifest.json'),
'utf8',
),
) as { logicFunctions: { name: string }[] };
return manifest.logicFunctions.map((logicFunction) => logicFunction.name);
};
describe('Fathom app deployed functions', () => {
it('deploys every logic function the manifest declares', async () => {
const deployedFunctionsResult = await new MetadataApiClient().query({
findManyLogicFunctions: { id: true, name: true },
});
const deployedFunctionNames: string[] =
deployedFunctionsResult.findManyLogicFunctions.map(
(logicFunction: { name: string }) => logicFunction.name,
);
for (const manifestFunctionName of readManifestLogicFunctionNames()) {
expect(deployedFunctionNames).toContain(manifestFunctionName);
}
});
it('rejects a backfill window outside the allowed range before touching the queue', async () => {
const execution = await executeDeployedFunction(
'fathom-backfill',
buildRoutePayload({ days: 0 }),
);
expect(execution.status).toBe('SUCCESS');
expect(execution.data).toEqual({
success: false,
error: expect.stringContaining('between 1 and'),
});
});
it('reports the missing Fathom connection from the deployed backfill route', async () => {
const execution = await executeDeployedFunction(
'fathom-backfill',
buildRoutePayload({ days: 30 }),
);
expect(execution.status).toBe('SUCCESS');
expect(execution.data).toEqual({
success: false,
error: expect.stringContaining('Fathom is not connected'),
});
});
it('rejects invalid workflow action input before looking up a connection', async () => {
const listExecution = await executeDeployedFunction(
'fathom-list-calls-by-participant',
{ participantEmail: ' ' },
);
const syncExecution = await executeDeployedFunction('fathom-sync-call', {
recordingId: '42',
});
expect(listExecution.status).toBe('SUCCESS');
expect(listExecution.data).toEqual({
success: false,
error: 'participantEmail is required',
});
expect(syncExecution.status).toBe('SUCCESS');
expect(syncExecution.data).toEqual({
success: false,
error: 'recordingId must be an integer',
});
});
it('reports the missing Fathom connection from the deployed workflow actions', async () => {
const listExecution = await executeDeployedFunction(
'fathom-list-calls-by-participant',
{ participantEmail: 'ada@example.com' },
);
const syncExecution = await executeDeployedFunction('fathom-sync-call', {
recordingId: 42,
});
expect(listExecution.status).toBe('SUCCESS');
expect(listExecution.data).toEqual({
success: false,
error: expect.stringContaining('Fathom is not connected'),
});
expect(syncExecution.status).toBe('SUCCESS');
expect(syncExecution.data).toEqual({
success: false,
error: expect.stringContaining('Fathom is not connected'),
});
});
});

View File

@@ -0,0 +1,100 @@
import { isNonEmptyString } from '@sniptt/guards';
import { randomUUID } from 'node:crypto';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { functionExecute } from 'twenty-sdk/cli';
import { describe, expect, it } from 'vitest';
import { createFathomApplicationCoreApiClient } from 'src/__tests__/utils/create-fathom-application-core-api-client.util';
describe('Fathom disconnected media cleanup', () => {
it.each([
{ description: 'null media', video: null, audio: null },
{ description: 'empty media arrays', video: [], audio: [] },
])(
'settles an empty transcript with $description',
async ({ video, audio }) => {
const coreApiClient = new CoreApiClient();
const applicationCoreApiClient =
await createFathomApplicationCoreApiClient();
const callRecordingId = randomUUID();
const connectedAccountId = randomUUID();
await coreApiClient.mutation({
createCallRecording: {
__args: {
data: {
id: callRecordingId,
title: 'Fathom disconnected cleanup integration fixture',
status: 'PROCESSING',
transcript: [],
video,
audio,
},
},
id: true,
},
});
await applicationCoreApiClient.mutation({
createFathomRecordingImport: {
__args: {
data: {
id: callRecordingId,
callRecordingId,
recordingId: randomUUID(),
connectedAccountId,
mediaDownloadId: randomUUID(),
},
},
id: true,
},
});
try {
const result = await functionExecute({
appPath: process.cwd(),
functionName: 'fathom-reconcile-media-imports',
payload: { disconnectedAccountId: connectedAccountId },
});
if (!result.success) {
throw new Error(result.error.message);
}
expect(result.data.status).toBe('SUCCESS');
expect(result.data.data).toEqual({
candidateCount: 1,
updatedRecordingCount: 1,
shouldContinue: false,
skipped: false,
});
const current = await coreApiClient.query({
callRecording: {
__args: { filter: { id: { eq: callRecordingId } } },
status: true,
},
fathomRecordingImport: {
__args: { filter: { id: { eq: callRecordingId } } },
mediaFailureReason: true,
mediaDownloadId: true,
mediaImportClaimedAt: true,
},
});
expect(current.callRecording?.status).toBe('FAILED');
expect(current.fathomRecordingImport).toMatchObject({
mediaFailureReason: 'connected_account_unavailable',
mediaImportClaimedAt: null,
});
expect(
isNonEmptyString(current.fathomRecordingImport?.mediaDownloadId),
).toBe(false);
} finally {
await coreApiClient.mutation({
destroyCallRecording: { __args: { id: callRecordingId }, id: true },
});
}
},
);
});

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,127 @@
import { randomUUID } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { createFathomApplicationCoreApiClient } from 'src/__tests__/utils/create-fathom-application-core-api-client.util';
import { claimFathomMediaImport } from 'src/logic-functions/utils/claim-fathom-media-import.util';
import { updateFathomRecordingImport } from 'src/logic-functions/utils/update-fathom-recording-import.util';
import { updateFathomMediaDownloadId } from 'src/logic-functions/utils/update-fathom-media-download-id.util';
describe('Fathom recording import ownership', () => {
it.each([
{
changedField: 'owner',
fields: { connectedAccountId: randomUUID() },
},
{
changedField: 'download',
fields: { mediaDownloadId: randomUUID() },
},
{
changedField: 'claim',
fields: { mediaImportClaimedAt: null },
},
])('rejects a worker after its $changedField changes', async ({ fields }) => {
const coreApiClient = await createFathomApplicationCoreApiClient();
const fathomRecordingImportId = randomUUID();
const connectedAccountId = randomUUID();
const downloadId = randomUUID();
await coreApiClient.mutation({
createFathomRecordingImport: {
__args: {
data: {
id: fathomRecordingImportId,
recordingId: randomUUID(),
connectedAccountId,
mediaDownloadId: downloadId,
},
},
id: true,
},
});
try {
const now = new Date();
const claims = await Promise.all(
[now, new Date(now.getTime() + 1)].map((claimTime) =>
claimFathomMediaImport({
coreApiClient,
fathomRecordingImportId,
now: claimTime,
}),
),
);
const successfulClaims = claims.filter((claim) => claim !== undefined);
expect(successfulClaims).toHaveLength(1);
const claim = successfulClaims[0];
if (!claim) {
throw new Error('Expected one worker to claim the recording import');
}
const writeContext = {
fathomRecordingImportId,
connectedAccountId,
downloadId,
claimedAt: claim.claimedAt,
};
expect(
await updateFathomRecordingImport({
coreApiClient,
writeContext,
fields: { mediaFailureReason: 'download_expired' },
}),
).toBe(true);
await coreApiClient.mutation({
updateFathomRecordingImport: {
__args: { id: fathomRecordingImportId, data: fields },
id: true,
},
});
expect(
await updateFathomRecordingImport({
coreApiClient,
writeContext,
fields: { mediaFailureReason: null },
}),
).toBe(false);
const replacementDownloadId = randomUUID();
expect(
await updateFathomMediaDownloadId({
coreApiClient,
writeContext,
downloadId: replacementDownloadId,
}),
).toBe(false);
const current = await coreApiClient.query({
fathomRecordingImport: {
__args: { filter: { id: { eq: fathomRecordingImportId } } },
mediaFailureReason: true,
mediaDownloadId: true,
},
});
expect(current.fathomRecordingImport?.mediaFailureReason).toBe(
'download_expired',
);
expect(current.fathomRecordingImport?.mediaDownloadId).not.toBe(
replacementDownloadId,
);
} finally {
await coreApiClient.mutation({
destroyFathomRecordingImport: {
__args: { id: fathomRecordingImportId },
id: true,
},
});
}
});
});

View File

@@ -0,0 +1,46 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CoreApiClient', () => {
it('should support CRUD on standard objects', async () => {
const client = new CoreApiClient();
const created = await client.mutation({
createNote: {
__args: { data: { title: 'Integration test note' } },
id: true,
},
});
expect(created.createNote.id).toBeDefined();
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
id: true,
},
});
});
});

View File

@@ -0,0 +1,18 @@
import { type Meeting } from 'fathom-typescript/sdk/models/shared';
// Mirrors listMeetings: the page for a cursor is the response itself, and each
// page names the cursor of the next one.
export const buildFathomMeetingPages =
(meetingPages: Meeting[][]) =>
async ({ cursor }: { cursor?: string }) => {
const pageIndex = cursor === undefined ? 0 : Number(cursor);
const isLastPage = pageIndex >= meetingPages.length - 1;
return {
result: {
items: meetingPages[pageIndex] ?? [],
limit: null,
nextCursor: isLastPage ? null : String(pageIndex + 1),
},
};
};

View File

@@ -0,0 +1,41 @@
import { type Meeting } from 'fathom-typescript/sdk/models/shared';
export const buildFathomMeeting = ({
recordingId,
recorderEmail = 'owner@example.com',
inviteeEmails = [],
recordingStartTime = '2026-08-20T10:00:00.000Z',
}: {
recordingId: number;
recorderEmail?: string;
inviteeEmails?: string[];
recordingStartTime?: string;
}): Meeting => ({
title: `Recording ${recordingId}`,
meetingTitle: `Meeting ${recordingId}`,
meetingType: null,
recordingId,
url: `https://fathom.video/calls/${recordingId}`,
meetingUrl: 'https://meet.example.com/customer-call',
shareUrl: `https://fathom.video/share/${recordingId}`,
createdAt: new Date(recordingStartTime),
scheduledStartTime: new Date(recordingStartTime),
scheduledEndTime: new Date(Date.parse(recordingStartTime) + 30 * 60_000),
recordingStartTime: new Date(recordingStartTime),
recordingEndTime: new Date(Date.parse(recordingStartTime) + 30 * 60_000),
calendarInviteesDomainsType: 'one_or_more_external',
sharedWith: 'single_team',
transcriptLanguage: 'en',
calendarInvitees: inviteeEmails.map((email) => ({
name: email,
email,
emailDomain: email.split('@')[1],
isExternal: true,
})),
recordedBy: {
name: recorderEmail,
email: recorderEmail,
emailDomain: recorderEmail.split('@')[1],
team: 'Sales',
},
});

View File

@@ -0,0 +1,8 @@
import { FathomError } from 'fathom-typescript/sdk/models/errors';
export const buildFathomNotFoundError = (): FathomError =>
new FathomError('Not found', {
response: new Response(null, { status: 404 }),
request: new Request('https://api.fathom.ai/external/v1/webhooks/1'),
body: '',
});

View File

@@ -0,0 +1,8 @@
import { FathomError } from 'fathom-typescript/sdk/models/errors';
export const buildFathomServerError = (): FathomError =>
new FathomError('Service unavailable', {
response: new Response(null, { status: 503 }),
request: new Request('https://api.fathom.ai/external/v1/webhooks'),
body: '',
});

View File

@@ -0,0 +1,37 @@
import { isDefined } from 'src/utils/is-defined';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const createFathomApplicationCoreApiClient =
async (): Promise<CoreApiClient> => {
const metadataApiClient = new MetadataApiClient();
const applicationsResult = await metadataApiClient.query({
findManyApplications: {
id: true,
universalIdentifier: true,
},
});
const fathomApplication = applicationsResult.findManyApplications.find(
(application) =>
application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
if (!isDefined(fathomApplication)) {
throw new Error('Expected the Fathom application to be installed');
}
const applicationTokenResult = await metadataApiClient.mutation({
generateApplicationToken: {
__args: { applicationId: fathomApplication.id },
applicationAccessToken: { token: true },
},
});
return new CoreApiClient({
headers: {
Authorization: `Bearer ${applicationTokenResult.generateApplicationToken.applicationAccessToken.token}`,
},
});
};

View File

@@ -0,0 +1,11 @@
import { type LogicFunctionExecutionContext } from 'twenty-sdk/logic-function';
export const buildLogicFunctionExecutionContext = (
userWorkspaceId: string | null,
): LogicFunctionExecutionContext => ({
retryCount: 0,
maxRetries: 3,
workspaceId: 'workspace-1',
userWorkspaceId,
workspaceMemberId: null,
});

View File

@@ -0,0 +1,36 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
logo: 'public/logo.svg',
author: 'Twenty',
category: 'Productivity',
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',
galleryImages: [
'public/gallery/fathom-cover-image-1.png',
'public/gallery/fathom-cover-image-2.png',
],
serverVariables: {
FATHOM_CLIENT_ID: {
description: 'OAuth client ID from the Twenty Fathom application.',
isSecret: false,
isRequired: true,
},
FATHOM_CLIENT_SECRET: {
description: 'OAuth client secret from the Twenty Fathom application.',
isSecret: true,
isRequired: true,
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineConnectionProvider } from 'twenty-sdk/define';
import { FATHOM_PROVIDER_NAME } from 'src/constants/fathom.constant';
import {
FATHOM_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER,
FATHOM_DISCONNECT_UNIVERSAL_IDENTIFIER,
FATHOM_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineConnectionProvider({
universalIdentifier: FATHOM_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER,
name: FATHOM_PROVIDER_NAME,
displayName: 'Fathom',
type: 'oauth',
onConnectLogicFunction: {
universalIdentifier: FATHOM_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER,
},
onDisconnectLogicFunction: {
universalIdentifier: FATHOM_DISCONNECT_UNIVERSAL_IDENTIFIER,
},
oauth: {
authorizationEndpoint: 'https://fathom.video/external/v1/oauth2/authorize',
tokenEndpoint: 'https://api.fathom.ai/external/v1/oauth2/token',
scopes: ['public_api'],
clientIdVariable: 'FATHOM_CLIENT_ID',
clientSecretVariable: 'FATHOM_CLIENT_SECRET',
tokenRequestContentType: 'form-urlencoded',
usePkce: false,
},
});

View File

@@ -0,0 +1,9 @@
export const FATHOM_MEDIA_FAILURE_REASON = {
FILE_TOO_LARGE: 'media_file_too_large',
NO_DOWNLOADABLE_MEDIA: 'no_downloadable_media',
DOWNLOAD_FORBIDDEN: 'download_forbidden',
DOWNLOAD_EXPIRED: 'download_expired',
DOWNLOAD_TIMED_OUT: 'download_timed_out',
CONNECTED_ACCOUNT_UNAVAILABLE: 'connected_account_unavailable',
COMPLETED_WITHOUT_FILE: 'completed_without_file',
};

View File

@@ -0,0 +1,28 @@
export const FATHOM_MEDIA_RECONCILIATION_SELECTION = {
pageInfo: { hasNextPage: true },
edges: {
node: {
id: true,
video: { fileId: true },
audio: { fileId: true },
fathomRecordingImports: {
__args: { first: 1 },
edges: {
node: {
id: true,
updatedAt: true,
recordingId: true,
connectedAccountId: true,
mediaDownloadId: true,
mediaFailureReason: true,
mediaImportClaimedAt: true,
mediaUploadCheckpoint: true,
},
},
},
transcript: true,
status: true,
updatedAt: true,
},
},
};

View File

@@ -0,0 +1,30 @@
export const FATHOM_PROVIDER_NAME = 'fathom';
export const FATHOM_WEBHOOK_CONNECTION_QUERY_PARAMETER = 'connectionId';
export const CALENDAR_EVENT_PAGE_SIZE = 200;
export const MAX_CALENDAR_EVENT_PAGES = 10;
export const FATHOM_BACKFILL_ROUTE_PATH = '/fathom/backfill';
export const FATHOM_BACKFILL_MAX_WINDOW_DAYS = 3_650;
export const FATHOM_INITIAL_BACKFILL_DAYS = 31;
export const FATHOM_HEALING_WINDOW_DAYS = 7;
export const FATHOM_BACKFILL_BATCH_SIZE = 5;
export const FATHOM_IMPORT_SLOT_INTERVAL_MILLISECONDS = 20_000;
export const FATHOM_BACKFILL_JOB_RETRY_LIMIT = 3;
export const FATHOM_REQUEST_RETRY_MAX_ELAPSED_MILLISECONDS = 10_000;
export const MAX_FATHOM_MEETING_PAGES = 20;
export const MAX_FATHOM_BACKFILL_PAGES = 1_000;
export const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1_000;
export const CALL_RECORDING_IDS_PER_QUERY = 200;
export const FATHOM_MEDIA_MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024;
export const FATHOM_MEDIA_DOWNLOAD_TIMEOUT_MILLISECONDS = 120_000;
export const FATHOM_MEDIA_UPLOAD_TIMEOUT_MILLISECONDS = 12 * 60 * 1_000;
export const FATHOM_MEDIA_IMPORT_CLAIM_DURATION_MILLISECONDS = 20 * 60 * 1_000;
export const FATHOM_MEDIA_FILE_FOLDER = 'FilesField';
export const FATHOM_MEDIA_DOWNLOAD_INITIAL_POLL_DELAY_MILLISECONDS = 60_000;
export const FATHOM_MEDIA_DOWNLOAD_MAX_POLL_DELAY_MILLISECONDS = 300_000;
export const FATHOM_MEDIA_DOWNLOAD_POLL_BACKOFF_EXPONENT = 1.5;
export const FATHOM_MEDIA_DOWNLOAD_MAX_POLL_ATTEMPTS = 12;
export const FATHOM_MEDIA_RECONCILIATION_GRACE_PERIOD_MILLISECONDS =
30 * 60 * 1_000;
export const FATHOM_MEDIA_RECONCILIATION_PAGE_SIZE = 200;
export const FATHOM_MEDIA_RECONCILIATION_CRON_PATTERN = '30 3 * * *';
export const FATHOM_MEDIA_RECONCILIATION_BATCH_SIZE = 5;

View File

@@ -0,0 +1,61 @@
export const APP_DISPLAY_NAME = 'Fathom';
export const APP_DESCRIPTION =
"Sync each connected user's Fathom recordings, transcripts, summaries, action items, and meeting metadata into Twenty.";
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'c5fb24b3-15b0-48fb-a9b5-f182ba231e6c';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'66c9ec61-dd73-4b1c-b3c5-ee54dffd8f05';
export const FATHOM_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER =
'eec1a27d-7678-43df-81e9-9f245ada8fe4';
export const FATHOM_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER =
'd6cb329f-8d0d-48f0-af30-e3ce2e11ddc6';
export const FATHOM_DISCONNECT_UNIVERSAL_IDENTIFIER =
'51edabd6-6698-4317-b134-f1d41633d76b';
export const FATHOM_WEBHOOK_RESOLVER_UNIVERSAL_IDENTIFIER =
'72b52885-e1ba-419f-8e2e-052700f2c9f2';
export const FATHOM_WEBHOOK_UNIVERSAL_IDENTIFIER =
'4047fcd1-8581-4188-bf35-e3e49a6235ca';
export const FATHOM_UNINSTALL_UNIVERSAL_IDENTIFIER =
'2f0b2b98-6ec1-4a3f-95bb-b2adf4bd4e33';
export const FATHOM_BACKFILL_UNIVERSAL_IDENTIFIER =
'b4ee8f0c-59e8-4586-8e5a-b2aca5624cbe';
export const FATHOM_BACKFILL_WORKER_UNIVERSAL_IDENTIFIER =
'5c2ca1f0-0b68-4dc3-b7f8-3c14c618a334';
export const FATHOM_BACKFILL_BATCH_UNIVERSAL_IDENTIFIER =
'f94eacc8-c611-4453-a865-e4650953ccec';
export const FATHOM_SYNC_CALL_UNIVERSAL_IDENTIFIER =
'606e8b64-d659-4e45-a292-0cafb08c06ed';
export const FATHOM_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER =
'24e37c59-c458-4cf9-9021-e16f904d62c1';
export const FATHOM_RECORDING_IMPORT_OBJECT_UNIVERSAL_IDENTIFIER =
'2be3fff9-38fb-4851-a5fe-ca0bdfd49c5e';
export const FATHOM_RECORDING_ID_FIELD_UNIVERSAL_IDENTIFIER =
'dfca5bef-bc80-4b6b-b4b7-5b5ad4609fcd';
export const FATHOM_RECORDING_IMPORT_CONNECTED_ACCOUNT_ID_FIELD_UNIVERSAL_IDENTIFIER =
'10b3209f-7116-4e0d-8924-e81ec12ee90d';
export const FATHOM_RECORDING_IMPORT_MEDIA_FAILURE_REASON_FIELD_UNIVERSAL_IDENTIFIER =
'fad1dd4f-a1c2-4f2a-a4eb-90e4b77e91a1';
export const FATHOM_RECORDING_IMPORT_MEDIA_DOWNLOAD_ID_FIELD_UNIVERSAL_IDENTIFIER =
'78184f31-9c3a-4d10-919a-6095d18dda7f';
export const FATHOM_RECORDING_IMPORT_MEDIA_CLAIMED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'2fa9cf0b-5989-430d-a64a-2c5d2da5d694';
export const FATHOM_RECORDING_IMPORT_MEDIA_UPLOAD_CHECKPOINT_FIELD_UNIVERSAL_IDENTIFIER =
'6c904f5d-3fd5-4b86-a4d6-4ebda3db0df8';
export const CALL_RECORDING_ON_FATHOM_RECORDING_IMPORT_FIELD_UNIVERSAL_IDENTIFIER =
'150d51e7-04a6-45fe-a4bc-eb9173249891';
export const FATHOM_RECORDING_IMPORTS_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER =
'55f3d725-2b40-4f99-80f5-25d73916aeed';
export const FATHOM_RECORDING_IMPORT_CALL_RECORDING_INDEX_UNIVERSAL_IDENTIFIER =
'f0c4e74a-a426-46f8-a76d-a78e00202f8a';
export const FATHOM_RECORDING_IMPORT_CALL_RECORDING_INDEX_FIELD_UNIVERSAL_IDENTIFIER =
'130da502-a1ff-4add-818f-8b272978d01c';
export const FATHOM_REQUEST_MEDIA_DOWNLOAD_UNIVERSAL_IDENTIFIER =
'87b12e47-2a6c-49e3-aafa-1bb29c0ce01f';
export const FATHOM_IMPORT_MEDIA_DOWNLOAD_UNIVERSAL_IDENTIFIER =
'4b46124e-ef53-4649-b414-cc5ae47111ff';
export const FATHOM_RECONCILE_MEDIA_IMPORTS_UNIVERSAL_IDENTIFIER =
'3670d009-0c34-454c-9b21-254fd8349f61';
export const CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER =
'bb9523d3-457e-4f4b-8c79-27a77afb87da';
export const CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER =
'2eafc2d0-8fec-430c-a939-65ca5fbc0f08';

View File

@@ -0,0 +1,63 @@
import {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
SystemPermissionFlag,
defineApplicationRole,
} from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
FATHOM_RECORDING_IMPORT_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default role`,
description:
'Reads calendar events, manages Fathom recording imports, and writes canonical recording data and media to CallRecording records.',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarChannelEventAssociation
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
FATHOM_RECORDING_IMPORT_OBJECT_UNIVERSAL_IDENTIFIER,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: true,
},
],
fieldPermissions: [],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE],
});

View File

@@ -0,0 +1,33 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
CALL_RECORDING_ON_FATHOM_RECORDING_IMPORT_FIELD_UNIVERSAL_IDENTIFIER,
FATHOM_RECORDING_IMPORT_OBJECT_UNIVERSAL_IDENTIFIER,
FATHOM_RECORDING_IMPORTS_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier:
FATHOM_RECORDING_IMPORTS_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
type: FieldType.RELATION,
name: 'fathomRecordingImports',
label: 'Fathom Recording Imports',
description: 'Internal Fathom import state associated with this recording.',
icon: 'IconDownload',
isNullable: true,
isUIEditable: false,
relationTargetObjectMetadataUniversalIdentifier:
FATHOM_RECORDING_IMPORT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
CALL_RECORDING_ON_FATHOM_RECORDING_IMPORT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

View File

@@ -0,0 +1,23 @@
import { defineIndex } from 'twenty-sdk/define';
import {
CALL_RECORDING_ON_FATHOM_RECORDING_IMPORT_FIELD_UNIVERSAL_IDENTIFIER,
FATHOM_RECORDING_IMPORT_CALL_RECORDING_INDEX_FIELD_UNIVERSAL_IDENTIFIER,
FATHOM_RECORDING_IMPORT_CALL_RECORDING_INDEX_UNIVERSAL_IDENTIFIER,
FATHOM_RECORDING_IMPORT_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineIndex({
universalIdentifier:
FATHOM_RECORDING_IMPORT_CALL_RECORDING_INDEX_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: FATHOM_RECORDING_IMPORT_OBJECT_UNIVERSAL_IDENTIFIER,
isUnique: true,
fields: [
{
universalIdentifier:
FATHOM_RECORDING_IMPORT_CALL_RECORDING_INDEX_FIELD_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier:
CALL_RECORDING_ON_FATHOM_RECORDING_IMPORT_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});

View File

@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildFathomNotFoundError } from 'src/__tests__/utils/build-fathom-not-found-error.util';
import { FATHOM_RECONCILE_MEDIA_IMPORTS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
const sdkMocks = vi.hoisted(() => ({
deleteWebhook: vi.fn(),
enqueueJobs: vi.fn(),
getConnection: vi.fn(),
kvDelete: vi.fn(),
kvGet: vi.fn(),
kvSet: vi.fn(),
}));
vi.mock('twenty-sdk/define', () => ({
defineLogicFunction: (config: unknown) => config,
}));
vi.mock('twenty-sdk/logic-function', async (importOriginal) => ({
...(await importOriginal<typeof import('twenty-sdk/logic-function')>()),
kv: { get: sdkMocks.kvGet, set: sdkMocks.kvSet, delete: sdkMocks.kvDelete },
enqueueJobs: sdkMocks.enqueueJobs,
getConnection: sdkMocks.getConnection,
}));
vi.mock('fathom-typescript', () => ({
Fathom: class Fathom {
deleteWebhook = sdkMocks.deleteWebhook;
},
}));
const { fathomDisconnectHandler } =
await import('src/logic-functions/fathom-disconnect');
const payload = {
connectionProviderId: 'provider-1',
connectionProviderName: 'fathom',
connectedAccountId: 'connection-1',
};
const registration = {
webhookId: 'webhook-1',
secret: 'secret',
isActive: true,
isInitialBackfillEnqueued: true,
};
const MEDIA_CLEANUP_JOB = {
logicFunctionUniversalIdentifier:
FATHOM_RECONCILE_MEDIA_IMPORTS_UNIVERSAL_IDENTIFIER,
payloads: [{ disconnectedAccountId: 'connection-1' }],
retryLimit: 3,
};
describe('fathomDisconnectHandler', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => undefined);
sdkMocks.kvGet.mockResolvedValue(registration);
sdkMocks.enqueueJobs.mockResolvedValue({ enqueued: true });
sdkMocks.getConnection.mockResolvedValue({
id: 'connection-1',
accessToken: 'token-1',
});
});
it('deletes the Fathom webhook, the registration and the server claim', async () => {
expect(await fathomDisconnectHandler(payload)).toEqual({ success: true });
expect(sdkMocks.deleteWebhook).toHaveBeenCalledWith({ id: 'webhook-1' });
expect(sdkMocks.kvDelete.mock.calls).toEqual([
['fathom-webhook:connection-1'],
['fathom-connection:connection-1', { scope: 'SERVER' }],
]);
});
it('schedules the media cleanup for the disconnected account', async () => {
expect(await fathomDisconnectHandler(payload)).toEqual({ success: true });
expect(sdkMocks.enqueueJobs).toHaveBeenCalledWith(MEDIA_CLEANUP_JOB);
});
it('releases the server claim when no webhook was ever registered', async () => {
sdkMocks.kvGet.mockResolvedValue(null);
expect(await fathomDisconnectHandler(payload)).toEqual({ success: true });
expect(sdkMocks.enqueueJobs).toHaveBeenCalledWith(MEDIA_CLEANUP_JOB);
expect(sdkMocks.getConnection).not.toHaveBeenCalled();
expect(sdkMocks.deleteWebhook).not.toHaveBeenCalled();
expect(sdkMocks.kvDelete.mock.calls).toEqual([
['fathom-connection:connection-1', { scope: 'SERVER' }],
]);
});
it('completes the cleanup when Fathom already dropped the webhook', async () => {
sdkMocks.deleteWebhook.mockRejectedValue(buildFathomNotFoundError());
expect(await fathomDisconnectHandler(payload)).toEqual({ success: true });
expect(sdkMocks.kvDelete).toHaveBeenCalledTimes(2);
expect(console.error).not.toHaveBeenCalled();
});
it('keeps the inactive registration and logs the leaked webhook when Fathom fails', async () => {
sdkMocks.deleteWebhook.mockRejectedValue(new Error('Fathom unavailable'));
await expect(fathomDisconnectHandler(payload)).rejects.toThrow(
'Fathom unavailable',
);
expect(sdkMocks.enqueueJobs).toHaveBeenCalledWith(MEDIA_CLEANUP_JOB);
expect(sdkMocks.kvSet).toHaveBeenCalledWith('fathom-webhook:connection-1', {
...registration,
isActive: false,
});
expect(sdkMocks.kvDelete).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('leaked webhook webhook-1'),
);
});
it('skips the webhook deletion when the media cleanup cannot be scheduled', async () => {
sdkMocks.enqueueJobs.mockResolvedValue({ enqueued: false });
await expect(fathomDisconnectHandler(payload)).rejects.toThrow(
'Failed to enqueue Fathom job',
);
expect(sdkMocks.deleteWebhook).not.toHaveBeenCalled();
expect(sdkMocks.kvDelete).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildFathomMeeting } from 'src/__tests__/utils/build-fathom-meeting.util';
import { buildFathomMeetingPages } from 'src/__tests__/utils/build-fathom-meeting-pages.util';
import { buildLogicFunctionExecutionContext } from 'src/__tests__/utils/logic-function-execution-context.util';
const mocks = vi.hoisted(() => ({
listFathomConnectionsForRequest: vi.fn(),
listMeetings: vi.fn(),
}));
vi.mock('twenty-sdk/define', () => ({
defineLogicFunction: (config: unknown) => config,
}));
vi.mock('twenty-sdk/logic-function', () => ({
jsonSchemaToInputSchema: () => [],
}));
vi.mock('fathom-typescript', () => ({
Fathom: class Fathom {
listMeetings = mocks.listMeetings;
},
}));
vi.mock(
'src/logic-functions/utils/list-fathom-connections-for-request.util',
() => ({
listFathomConnectionsForRequest: mocks.listFathomConnectionsForRequest,
}),
);
const { fathomListCallsByParticipantHandler } =
await import('src/logic-functions/fathom-list-calls-by-participant');
const CONTEXT = buildLogicFunctionExecutionContext('user-workspace-1');
const buildMeetingWith = (recordingId: number, startedAt: string) =>
buildFathomMeeting({
recordingId,
inviteeEmails: ['Ada@Example.com', 'owner@example.com'],
recordingStartTime: startedAt,
});
describe('fathomListCallsByParticipantHandler', () => {
beforeEach(() => {
vi.resetAllMocks();
mocks.listFathomConnectionsForRequest.mockResolvedValue([
{ id: 'connection-1', accessToken: 'token' },
]);
});
it('returns the calls whose invitees include the email, most recent first', async () => {
mocks.listMeetings.mockImplementation(
buildFathomMeetingPages([
[
{
...buildMeetingWith(1, '2026-08-18T10:00:00.000Z'),
meetingUrl: null,
},
buildFathomMeeting({
recordingId: 2,
inviteeEmails: ['someone@example.com'],
}),
buildMeetingWith(3, '2026-08-21T10:00:00.000Z'),
],
]),
);
expect(
await fathomListCallsByParticipantHandler(
{ participantEmail: ' ADA@example.com ' },
CONTEXT,
),
).toEqual({
success: true,
count: 2,
calls: [
{
recordingId: 3,
title: 'Meeting 3',
startedAt: '2026-08-21T10:00:00.000Z',
durationMinutes: 30,
participants: ['Ada@Example.com', 'owner@example.com'],
recordedBy: 'owner@example.com',
fathomUrl: 'https://fathom.video/calls/3',
meetingUrl: 'https://meet.example.com/customer-call',
},
expect.not.objectContaining({ meetingUrl: expect.anything() }),
],
});
});
it('merges the calls seen by several connected accounts without duplicates', async () => {
mocks.listFathomConnectionsForRequest.mockResolvedValue([
{ id: 'connection-1', accessToken: 'token-1' },
{ id: 'connection-2', accessToken: 'token-2' },
]);
mocks.listMeetings
.mockImplementationOnce(
buildFathomMeetingPages([
[buildMeetingWith(1, '2026-08-18T10:00:00.000Z')],
]),
)
.mockImplementationOnce(
buildFathomMeetingPages([
[
buildMeetingWith(1, '2026-08-18T10:00:00.000Z'),
buildMeetingWith(2, '2026-08-19T10:00:00.000Z'),
],
]),
);
expect(
await fathomListCallsByParticipantHandler(
{ participantEmail: 'ada@example.com' },
buildLogicFunctionExecutionContext(null),
),
).toEqual(
expect.objectContaining({
count: 2,
calls: [
expect.objectContaining({ recordingId: 2 }),
expect.objectContaining({ recordingId: 1 }),
],
}),
);
});
});

View File

@@ -0,0 +1,231 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { RetryableLogicFunctionError } from 'twenty-sdk/logic-function';
import { buildFathomNotFoundError } from 'src/__tests__/utils/build-fathom-not-found-error.util';
import { buildFathomServerError } from 'src/__tests__/utils/build-fathom-server-error.util';
import { FATHOM_BACKFILL_WORKER_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
const sdkMocks = vi.hoisted(() => ({
createWebhook: vi.fn(),
deleteWebhook: vi.fn(),
enqueueJobs: vi.fn(),
getConnection: vi.fn(),
kvGet: vi.fn(),
kvSet: vi.fn(),
}));
vi.mock('twenty-sdk/define', () => ({
defineLogicFunction: (config: unknown) => config,
}));
vi.mock('twenty-sdk/logic-function', async (importOriginal) => ({
...(await importOriginal<typeof import('twenty-sdk/logic-function')>()),
kv: { get: sdkMocks.kvGet, set: sdkMocks.kvSet },
enqueueJobs: sdkMocks.enqueueJobs,
getConnection: sdkMocks.getConnection,
}));
vi.mock('fathom-typescript', () => ({
Fathom: class Fathom {
createWebhook = sdkMocks.createWebhook;
deleteWebhook = sdkMocks.deleteWebhook;
},
}));
const { fathomRegisterConnectionHandler } =
await import('src/logic-functions/fathom-register-connection');
const HOOK_PAYLOAD = {
connectionProviderId: 'provider-1',
connectionProviderName: 'fathom',
connectedAccountId: 'connection-1',
};
const failRegistrationWrite = (error: Error) => {
let hasFailed = false;
sdkMocks.kvSet.mockImplementation((key: string) => {
if (hasFailed || !key.startsWith('fathom-webhook:')) {
return Promise.resolve();
}
hasFailed = true;
return Promise.reject(error);
});
};
const INACTIVE_REGISTRATION = {
webhookId: 'stale-webhook',
secret: 'secret',
isActive: false,
isInitialBackfillEnqueued: true,
};
const ACTIVE_REGISTRATION = {
...INACTIVE_REGISTRATION,
webhookId: 'existing-webhook',
isActive: true,
};
const INITIAL_BACKFILL_JOB = {
logicFunctionUniversalIdentifier: FATHOM_BACKFILL_WORKER_UNIVERSAL_IDENTIFIER,
payloads: [{ connectedAccountId: 'connection-1', days: 31 }],
retryLimit: 3,
};
describe('fathomRegisterConnectionHandler', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => undefined);
process.env.TWENTY_API_URL = 'https://example.dev';
sdkMocks.getConnection.mockResolvedValue({ accessToken: 'token' });
sdkMocks.kvGet.mockResolvedValue(null);
sdkMocks.createWebhook.mockResolvedValue({
id: 'webhook-1',
secret: 'secret-1',
});
sdkMocks.enqueueJobs.mockResolvedValue({ enqueued: true });
});
it('claims the connection and registers a webhook pointing at the server route', async () => {
expect(await fathomRegisterConnectionHandler(HOOK_PAYLOAD)).toEqual({
success: true,
webhookId: 'webhook-1',
});
expect(sdkMocks.kvSet).toHaveBeenCalledWith(
'fathom-connection:connection-1',
null,
{ scope: 'SERVER' },
);
expect(sdkMocks.createWebhook).toHaveBeenCalledWith(
expect.objectContaining({
destinationUrl:
'https://example.dev/webhooks/server/72b52885-e1ba-419f-8e2e-052700f2c9f2?connectionId=connection-1',
}),
);
expect(sdkMocks.kvSet).toHaveBeenCalledWith('fathom-webhook:connection-1', {
webhookId: 'webhook-1',
secret: 'secret-1',
isActive: true,
isInitialBackfillEnqueued: false,
});
});
it('starts the initial import once the registration is stored', async () => {
await fathomRegisterConnectionHandler(HOOK_PAYLOAD);
expect(sdkMocks.enqueueJobs).toHaveBeenCalledWith(INITIAL_BACKFILL_JOB);
expect(sdkMocks.kvSet).toHaveBeenLastCalledWith(
'fathom-webhook:connection-1',
expect.objectContaining({
webhookId: 'webhook-1',
isInitialBackfillEnqueued: true,
}),
);
});
it('enqueues the initial import on retry when the active registration is still missing it', async () => {
sdkMocks.kvGet.mockResolvedValue({
...ACTIVE_REGISTRATION,
isInitialBackfillEnqueued: false,
});
expect(await fathomRegisterConnectionHandler(HOOK_PAYLOAD)).toEqual({
success: true,
webhookId: 'existing-webhook',
});
expect(sdkMocks.createWebhook).not.toHaveBeenCalled();
expect(sdkMocks.enqueueJobs).toHaveBeenCalledWith(INITIAL_BACKFILL_JOB);
expect(sdkMocks.kvSet).toHaveBeenCalledWith('fathom-webhook:connection-1', {
...ACTIVE_REGISTRATION,
isInitialBackfillEnqueued: true,
});
});
it('asks the queue to retry when Fathom fails transiently', async () => {
sdkMocks.createWebhook.mockRejectedValue(buildFathomServerError());
await expect(
fathomRegisterConnectionHandler(HOOK_PAYLOAD),
).rejects.toBeInstanceOf(RetryableLogicFunctionError);
});
it('surfaces a rejected webhook without retrying', async () => {
sdkMocks.createWebhook.mockRejectedValue(buildFathomNotFoundError());
await expect(
fathomRegisterConnectionHandler(HOOK_PAYLOAD),
).rejects.not.toBeInstanceOf(RetryableLogicFunctionError);
});
it('deletes the webhook it just created when the registration cannot be stored', async () => {
failRegistrationWrite(new Error('Key value store unavailable'));
await expect(fathomRegisterConnectionHandler(HOOK_PAYLOAD)).rejects.toThrow(
'Key value store unavailable',
);
expect(sdkMocks.deleteWebhook).toHaveBeenCalledWith({ id: 'webhook-1' });
});
it('keeps the webhook when the failed write turns out to have committed', async () => {
failRegistrationWrite(new Error('Response lost'));
sdkMocks.kvGet.mockResolvedValueOnce(null).mockResolvedValueOnce({
webhookId: 'webhook-1',
secret: 'secret-1',
isActive: true,
isInitialBackfillEnqueued: false,
});
expect(await fathomRegisterConnectionHandler(HOOK_PAYLOAD)).toEqual({
success: true,
webhookId: 'webhook-1',
});
expect(sdkMocks.deleteWebhook).not.toHaveBeenCalled();
});
it('reports the storage failure even when the created webhook cannot be deleted', async () => {
failRegistrationWrite(new Error('Key value store unavailable'));
sdkMocks.deleteWebhook.mockRejectedValue(new Error('Fathom unavailable'));
await expect(fathomRegisterConnectionHandler(HOOK_PAYLOAD)).rejects.toThrow(
'Key value store unavailable',
);
});
it('reuses the webhook of an already active registration', async () => {
sdkMocks.kvGet.mockResolvedValue(ACTIVE_REGISTRATION);
expect(await fathomRegisterConnectionHandler(HOOK_PAYLOAD)).toEqual({
success: true,
webhookId: 'existing-webhook',
});
expect(sdkMocks.createWebhook).not.toHaveBeenCalled();
expect(sdkMocks.enqueueJobs).not.toHaveBeenCalled();
expect(sdkMocks.kvSet).not.toHaveBeenCalled();
});
it('replaces an inactive registration whose webhook Fathom already dropped', async () => {
sdkMocks.kvGet.mockResolvedValue(INACTIVE_REGISTRATION);
sdkMocks.deleteWebhook.mockRejectedValue(buildFathomNotFoundError());
expect(await fathomRegisterConnectionHandler(HOOK_PAYLOAD)).toEqual({
success: true,
webhookId: 'webhook-1',
});
expect(sdkMocks.deleteWebhook).toHaveBeenCalledWith({
id: 'stale-webhook',
});
});
it('does not replace an inactive registration when its webhook cannot be deleted', async () => {
sdkMocks.kvGet.mockResolvedValue(INACTIVE_REGISTRATION);
sdkMocks.deleteWebhook.mockRejectedValue(new Error('Fathom unavailable'));
await expect(fathomRegisterConnectionHandler(HOOK_PAYLOAD)).rejects.toThrow(
'Fathom unavailable',
);
expect(sdkMocks.createWebhook).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildFathomNotFoundError } from 'src/__tests__/utils/build-fathom-not-found-error.util';
const sdkMocks = vi.hoisted(() => ({
deleteWebhook: vi.fn(),
kvDelete: vi.fn(),
kvGet: vi.fn(),
listConnections: vi.fn(),
}));
vi.mock('twenty-sdk/define', () => ({
defineUninstallLogicFunction: (config: unknown) => config,
}));
vi.mock('twenty-sdk/logic-function', () => ({
kv: { get: sdkMocks.kvGet, delete: sdkMocks.kvDelete },
listConnections: sdkMocks.listConnections,
}));
vi.mock('fathom-typescript', () => ({
Fathom: class Fathom {
deleteWebhook = sdkMocks.deleteWebhook;
},
}));
const { fathomUninstallHandler } =
await import('src/logic-functions/fathom-uninstall');
const buildConnection = (id: string) => ({ id, accessToken: `token-${id}` });
const buildRegistration = (registrationKey: string) => ({
webhookId: `webhook-for-${registrationKey}`,
secret: 'secret',
isActive: true,
});
describe('fathomUninstallHandler', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => undefined);
sdkMocks.listConnections.mockResolvedValue([
buildConnection('connection-1'),
buildConnection('connection-2'),
]);
sdkMocks.kvGet.mockImplementation((key: string) =>
Promise.resolve(buildRegistration(key)),
);
});
it('deletes the Fathom webhook of every connected account', async () => {
expect(await fathomUninstallHandler()).toEqual({ deletedWebhookCount: 2 });
expect(sdkMocks.deleteWebhook.mock.calls).toEqual([
[{ id: 'webhook-for-fathom-webhook:connection-1' }],
[{ id: 'webhook-for-fathom-webhook:connection-2' }],
]);
expect(sdkMocks.kvDelete.mock.calls).toEqual([
['fathom-webhook:connection-1'],
['fathom-connection:connection-1', { scope: 'SERVER' }],
['fathom-webhook:connection-2'],
['fathom-connection:connection-2', { scope: 'SERVER' }],
]);
});
it('keeps deleting the remaining webhooks when Fathom rejects one', async () => {
sdkMocks.deleteWebhook.mockRejectedValueOnce(
new Error('Fathom unavailable'),
);
expect(await fathomUninstallHandler()).toEqual({ deletedWebhookCount: 1 });
expect(sdkMocks.deleteWebhook).toHaveBeenNthCalledWith(2, {
id: 'webhook-for-fathom-webhook:connection-2',
});
expect(sdkMocks.kvDelete).toHaveBeenCalledTimes(4);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('webhook-for-fathom-webhook:connection-1'),
);
});
it('counts a webhook Fathom already dropped as deleted', async () => {
sdkMocks.deleteWebhook.mockRejectedValueOnce(buildFathomNotFoundError());
expect(await fathomUninstallHandler()).toEqual({ deletedWebhookCount: 2 });
expect(console.error).not.toHaveBeenCalled();
});
it('keeps cleaning the remaining connections when one KV read fails', async () => {
sdkMocks.kvGet.mockRejectedValueOnce(
new Error('Key value store unavailable'),
);
expect(await fathomUninstallHandler()).toEqual({ deletedWebhookCount: 1 });
expect(sdkMocks.deleteWebhook).toHaveBeenCalledWith({
id: 'webhook-for-fathom-webhook:connection-2',
});
});
it('skips connections that never registered a webhook', async () => {
sdkMocks.kvGet.mockResolvedValue(null);
expect(await fathomUninstallHandler()).toEqual({ deletedWebhookCount: 0 });
expect(sdkMocks.deleteWebhook).not.toHaveBeenCalled();
expect(sdkMocks.kvDelete).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,83 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { getConnection } from 'twenty-sdk/logic-function';
import { isDefined } from 'src/utils/is-defined';
import { FATHOM_BACKFILL_BATCH_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { type SerializedFathomMeeting } from 'src/logic-functions/types/serialized-fathom-meeting.type';
import { buildRetryableFathomError } from 'src/logic-functions/utils/build-retryable-fathom-error.util';
import { createFathomClient } from 'src/logic-functions/utils/create-fathom-client.util';
import { hydrateFathomMeeting } from 'src/logic-functions/utils/hydrate-fathom-meeting.util';
import { isTransientFathomError } from 'src/logic-functions/utils/is-transient-fathom-error.util';
import { syncFathomMeetingToCallRecording } from 'src/logic-functions/utils/sync-fathom-meeting-to-call-recording.util';
import { toErrorMessage } from 'src/logic-functions/utils/to-error-message.util';
export const fathomBackfillBatchHandler = async (payload: {
connectedAccountId: string;
meetings: SerializedFathomMeeting[];
}) => {
const connection = await getConnection(payload.connectedAccountId);
const fathomClient = createFathomClient(connection.accessToken);
const coreApiClient = new CoreApiClient({ runAs: 'application' });
const results = [];
// Sequential on purpose: the batch is the unit of pacing against Fathom.
for (const serializedMeeting of payload.meetings) {
const meeting = await hydrateFathomMeeting({
fathomClient,
serializedMeeting,
}).catch((error: unknown) => {
// Only a RetryableLogicFunctionError makes the platform retry, so a rate
// limit or a Fathom outage is rethrown as one.
if (isTransientFathomError(error)) {
throw buildRetryableFathomError({
operation: `hydrate recording ${serializedMeeting.recordingId}`,
error,
});
}
// One unreadable recording must not cost the rest of the batch.
console.error(
`[fathom] skipped recording ${serializedMeeting.recordingId}: ${toErrorMessage(error)}`,
);
return undefined;
});
if (!isDefined(meeting)) {
continue;
}
// A failed upsert retries the whole batch. The replay re-fetches the
// meetings already imported, which costs calls but cannot duplicate
// records: the CallRecording id is derived from the recording.
results.push(
await syncFathomMeetingToCallRecording({
coreApiClient,
meeting,
connectedAccountId: payload.connectedAccountId,
}).catch((error: unknown) => {
throw buildRetryableFathomError({
operation: `sync recording ${serializedMeeting.recordingId}`,
error,
});
}),
);
}
return {
success: true,
importedMeetingCount: results.length,
failedMeetingCount: payload.meetings.length - results.length,
results,
};
};
export default defineLogicFunction({
universalIdentifier: FATHOM_BACKFILL_BATCH_UNIVERSAL_IDENTIFIER,
name: 'fathom-backfill-batch',
description:
'Fetches the transcript and summary of one paced batch of Fathom meetings and upserts their CallRecordings.',
timeoutSeconds: 300,
handler: fathomBackfillBatchHandler,
});

View File

@@ -0,0 +1,128 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { getConnection } from 'twenty-sdk/logic-function';
import { isDefined } from 'src/utils/is-defined';
import {
FATHOM_BACKFILL_BATCH_SIZE,
MAX_FATHOM_BACKFILL_PAGES,
MILLISECONDS_PER_DAY,
} from 'src/constants/fathom.constant';
import {
FATHOM_BACKFILL_BATCH_UNIVERSAL_IDENTIFIER,
FATHOM_BACKFILL_WORKER_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { type FathomBackfillWorkerPayload } from 'src/logic-functions/types/fathom-backfill-worker-payload.type';
import { createFathomClient } from 'src/logic-functions/utils/create-fathom-client.util';
import { excludeDeletedFathomMeetings } from 'src/logic-functions/utils/exclude-deleted-fathom-meetings.util';
import { enqueueFathomJobsOrThrow } from 'src/logic-functions/utils/enqueue-fathom-jobs-or-throw.util';
import { listFathomMeetingPage } from 'src/logic-functions/utils/list-fathom-meeting-page.util';
import { reserveFathomImportSlots } from 'src/logic-functions/utils/reserve-fathom-import-slots.util';
import { serializeFathomMeeting } from 'src/logic-functions/utils/serialize-fathom-meeting.util';
import { chunkIntoBatches } from 'src/utils/chunk-into-batches.util';
const getCreatedAfter = ({
payload,
now,
}: {
payload: FathomBackfillWorkerPayload;
now: number;
}): string => {
if (isNonEmptyString(payload.createdAfter)) {
return payload.createdAfter;
}
if (isDefined(payload.days) && Number.isInteger(payload.days)) {
return new Date(now - payload.days * MILLISECONDS_PER_DAY).toISOString();
}
throw new Error('Fathom backfill worker requires a days window');
};
export const fathomBackfillWorkerHandler = async (
payload: FathomBackfillWorkerPayload,
) => {
if (!isNonEmptyString(payload.connectedAccountId)) {
throw new Error('Fathom backfill worker requires a connectedAccountId');
}
const createdAfter = getCreatedAfter({ payload, now: Date.now() });
const pageIndex = payload.pageIndex ?? 0;
const connection = await getConnection(payload.connectedAccountId);
const meetingPage = await listFathomMeetingPage({
fathomClient: createFathomClient(connection.accessToken),
createdAfter,
cursor: payload.cursor,
});
const serializedMeetings = meetingPage.meetings.map(serializeFathomMeeting);
const importableMeetings = await excludeDeletedFathomMeetings({
coreApiClient: new CoreApiClient({ runAs: 'application' }),
meetings: serializedMeetings,
});
const meetingBatches = chunkIntoBatches(
importableMeetings,
FATHOM_BACKFILL_BATCH_SIZE,
);
const { slotDelays, continuationDelay } = await reserveFathomImportSlots({
connectedAccountId: payload.connectedAccountId,
slotCount: meetingBatches.length,
});
for (const [batchIndex, meetings] of meetingBatches.entries()) {
await enqueueFathomJobsOrThrow({
logicFunctionUniversalIdentifier:
FATHOM_BACKFILL_BATCH_UNIVERSAL_IDENTIFIER,
payloads: [{ connectedAccountId: payload.connectedAccountId, meetings }],
delayMs: slotDelays[batchIndex],
});
}
// A cursor that repeats or cycles would chain this worker forever; the page
// bound is far above any real history and only exists to end such a chain.
const hasMoreMeetings =
isNonEmptyString(meetingPage.nextCursor) &&
meetingPage.nextCursor !== payload.cursor;
const isPageBoundReached = pageIndex + 1 >= MAX_FATHOM_BACKFILL_PAGES;
if (hasMoreMeetings && isPageBoundReached) {
console.error(
`[fathom] backfill for connected account ${payload.connectedAccountId} stopped after ${MAX_FATHOM_BACKFILL_PAGES} pages with cursor ${meetingPage.nextCursor} still pending`,
);
}
if (hasMoreMeetings && !isPageBoundReached) {
await enqueueFathomJobsOrThrow({
logicFunctionUniversalIdentifier:
FATHOM_BACKFILL_WORKER_UNIVERSAL_IDENTIFIER,
payloads: [
{
connectedAccountId: payload.connectedAccountId,
createdAfter,
cursor: meetingPage.nextCursor,
pageIndex: pageIndex + 1,
},
],
delayMs: continuationDelay,
});
}
return {
success: true,
createdAfter,
discoveredMeetingCount: meetingPage.meetings.length,
skippedDeletedMeetingCount:
serializedMeetings.length - importableMeetings.length,
enqueuedBatchCount: meetingBatches.length,
hasMoreMeetings: hasMoreMeetings && !isPageBoundReached,
};
};
export default defineLogicFunction({
universalIdentifier: FATHOM_BACKFILL_WORKER_UNIVERSAL_IDENTIFIER,
name: 'fathom-backfill-worker',
description:
"Discovers one page of Fathom meetings, schedules paced import batches, and continues from Fathom's cursor.",
timeoutSeconds: 120,
handler: fathomBackfillWorkerHandler,
});

View File

@@ -0,0 +1,65 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { type LogicFunctionExecutionContext } from 'twenty-sdk/logic-function';
import { isDefined } from 'src/utils/is-defined';
import {
FATHOM_BACKFILL_MAX_WINDOW_DAYS,
FATHOM_BACKFILL_ROUTE_PATH,
} from 'src/constants/fathom.constant';
import { FATHOM_BACKFILL_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { enqueueFathomBackfillWorker } from 'src/logic-functions/utils/enqueue-fathom-backfill-worker.util';
import { findFathomConnectionForRequester } from 'src/logic-functions/utils/find-fathom-connection-for-requester.util';
type FathomBackfillResult =
| { success: true; connectedAccountId: string; days: number }
| { success: false; error: string };
export const fathomBackfillHandler = async (
routePayload: RoutePayload<{ days?: unknown }>,
context: LogicFunctionExecutionContext,
): Promise<FathomBackfillResult> => {
const days = routePayload.body?.days;
if (
typeof days !== 'number' ||
!Number.isInteger(days) ||
days < 1 ||
days > FATHOM_BACKFILL_MAX_WINDOW_DAYS
) {
return {
success: false,
error: `Fathom backfill requires a days window between 1 and ${FATHOM_BACKFILL_MAX_WINDOW_DAYS}`,
};
}
const connection = await findFathomConnectionForRequester(context);
if (!isDefined(connection)) {
return {
success: false,
error:
'Fathom is not connected for this user. Open the Fathom app settings and add a connection first.',
};
}
await enqueueFathomBackfillWorker({
connectedAccountId: connection.id,
days,
});
return { success: true, connectedAccountId: connection.id, days };
};
export default defineLogicFunction({
universalIdentifier: FATHOM_BACKFILL_UNIVERSAL_IDENTIFIER,
name: 'fathom-backfill',
description:
"Starts a Fathom history import through the requesting user's connected account.",
timeoutSeconds: 15,
handler: fathomBackfillHandler,
httpRouteTriggerSettings: {
path: FATHOM_BACKFILL_ROUTE_PATH,
httpMethod: 'POST',
isAuthRequired: true,
},
});

View File

@@ -0,0 +1,77 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { getConnection, kv } from 'twenty-sdk/logic-function';
import { isDefined } from 'src/utils/is-defined';
import {
FATHOM_DISCONNECT_UNIVERSAL_IDENTIFIER,
FATHOM_RECONCILE_MEDIA_IMPORTS_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { type FathomConnectionHookPayload } from 'src/logic-functions/types/fathom-connection-hook-payload.type';
import { type FathomWebhookRegistration } from 'src/logic-functions/types/fathom-webhook-registration.type';
import { createFathomClient } from 'src/logic-functions/utils/create-fathom-client.util';
import { deleteStaleFathomWebhook } from 'src/logic-functions/utils/delete-stale-fathom-webhook.util';
import { enqueueFathomJobsOrThrow } from 'src/logic-functions/utils/enqueue-fathom-jobs-or-throw.util';
import { getFathomConnectionClaimKey } from 'src/logic-functions/utils/get-fathom-connection-claim-key.util';
import { getFathomWebhookRegistrationKey } from 'src/logic-functions/utils/get-fathom-webhook-registration-key.util';
import { toErrorMessage } from 'src/logic-functions/utils/to-error-message.util';
export const fathomDisconnectHandler = async (
payload: FathomConnectionHookPayload,
): Promise<{ success: true }> => {
const registrationKey = getFathomWebhookRegistrationKey(
payload.connectedAccountId,
);
const registration = await kv.get<FathomWebhookRegistration>(registrationKey);
// Enqueued before the Fathom call because a webhook deletion failure throws
// to retry the hook, and the media imports still have to be settled.
await enqueueFathomJobsOrThrow({
logicFunctionUniversalIdentifier:
FATHOM_RECONCILE_MEDIA_IMPORTS_UNIVERSAL_IDENTIFIER,
payloads: [{ disconnectedAccountId: payload.connectedAccountId }],
});
if (!isDefined(registration)) {
await kv.delete(getFathomConnectionClaimKey(payload.connectedAccountId), {
scope: 'SERVER',
});
return { success: true };
}
await kv.set(registrationKey, { ...registration, isActive: false });
const connection = await getConnection(payload.connectedAccountId);
try {
await deleteStaleFathomWebhook({
fathomClient: createFathomClient(connection.accessToken),
webhookId: registration.webhookId,
});
} catch (error) {
// Twenty deletes the connected account right after this hook, so the
// registration left behind is keyed on an id that never comes back and this
// log line is the only record of the webhook still live in Fathom.
console.error(
`[fathom] leaked webhook ${registration.webhookId} for connected account ${payload.connectedAccountId}: ${toErrorMessage(error)}`,
);
throw error;
}
await kv.delete(registrationKey);
await kv.delete(getFathomConnectionClaimKey(payload.connectedAccountId), {
scope: 'SERVER',
});
return { success: true };
};
export default defineLogicFunction({
universalIdentifier: FATHOM_DISCONNECT_UNIVERSAL_IDENTIFIER,
name: 'fathom-disconnect',
description:
'Deletes the registered Fathom webhook and schedules media cleanup after a user removes their Fathom connection.',
timeoutSeconds: 30,
handler: fathomDisconnectHandler,
});

View File

@@ -0,0 +1,14 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { FATHOM_IMPORT_MEDIA_DOWNLOAD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { runFathomMediaImport } from 'src/logic-functions/utils/run-fathom-media-import.util';
export const fathomImportMediaDownloadHandler = runFathomMediaImport;
export default defineLogicFunction({
universalIdentifier: FATHOM_IMPORT_MEDIA_DOWNLOAD_UNIVERSAL_IDENTIFIER,
name: 'fathom-import-media-download',
description: 'Resumes media generation and upload for one Fathom recording.',
timeoutSeconds: 900,
handler: fathomImportMediaDownloadHandler,
});

View File

@@ -0,0 +1,148 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type Meeting } from 'fathom-typescript/sdk/models/shared';
import { defineLogicFunction } from 'twenty-sdk/define';
import {
type InputJsonSchema,
jsonSchemaToInputSchema,
type LogicFunctionExecutionContext,
} from 'twenty-sdk/logic-function';
import { FATHOM_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { type FathomCallSummary } from 'src/logic-functions/types/fathom-call-summary.type';
import { createFathomClient } from 'src/logic-functions/utils/create-fathom-client.util';
import { doesFathomMeetingIncludeInvitee } from 'src/logic-functions/utils/does-fathom-meeting-include-invitee.util';
import { listFathomConnectionsForRequest } from 'src/logic-functions/utils/list-fathom-connections-for-request.util';
import { listFathomMeetings } from 'src/logic-functions/utils/list-fathom-meetings.util';
import { mapFathomMeetingToCallSummary } from 'src/logic-functions/utils/map-fathom-meeting-to-call-summary.util';
const DEFAULT_RESULT_LIMIT = 10;
const MAXIMUM_RESULT_LIMIT = 50;
const fathomListCallsByParticipantInputSchema: InputJsonSchema = {
type: 'object',
properties: {
participantEmail: {
type: 'string',
label: 'Participant email',
description:
'Email address of a calendar invitee. Returns the Fathom recordings whose invitees include this email, most recent first.',
},
limit: {
type: 'integer',
label: 'Maximum number of calls',
description: `Optional. Defaults to ${DEFAULT_RESULT_LIMIT}, capped at ${MAXIMUM_RESULT_LIMIT}.`,
minimum: 1,
maximum: MAXIMUM_RESULT_LIMIT,
},
},
required: ['participantEmail'],
additionalProperties: false,
};
type FathomListCallsByParticipantResult =
| { success: true; count: number; calls: FathomCallSummary[] }
| { success: false; error: string };
const clampLimit = (limit: unknown): number =>
typeof limit === 'number' && Number.isFinite(limit)
? Math.max(1, Math.min(MAXIMUM_RESULT_LIMIT, Math.trunc(limit)))
: DEFAULT_RESULT_LIMIT;
export const fathomListCallsByParticipantHandler = async (
parameters: { participantEmail?: unknown; limit?: unknown },
context: LogicFunctionExecutionContext,
): Promise<FathomListCallsByParticipantResult> => {
const participantEmail =
typeof parameters.participantEmail === 'string'
? parameters.participantEmail.trim().toLowerCase()
: '';
if (!isNonEmptyString(participantEmail)) {
return { success: false, error: 'participantEmail is required' };
}
const limit = clampLimit(parameters.limit);
const connections = await listFathomConnectionsForRequest(context);
if (connections.length === 0) {
return {
success: false,
error:
'Fathom is not connected for this user. Open the Fathom app settings and add a connection first.',
};
}
const hasParticipant = (meeting: Meeting): boolean =>
doesFathomMeetingIncludeInvitee({
meeting,
normalizedInviteeEmail: participantEmail,
});
const matchingMeetingsByRecordingId = new Map<number, Meeting>();
for (const connection of connections) {
const meetings = await listFathomMeetings({
fathomClient: createFathomClient(connection.accessToken),
stopWhen: (listedMeetings) =>
listedMeetings.filter(hasParticipant).length >= limit,
});
for (const meeting of meetings.filter(hasParticipant)) {
matchingMeetingsByRecordingId.set(meeting.recordingId, meeting);
}
}
const calls = [...matchingMeetingsByRecordingId.values()]
.sort(
(firstMeeting, secondMeeting) =>
secondMeeting.recordingStartTime.getTime() -
firstMeeting.recordingStartTime.getTime(),
)
.slice(0, limit)
.map(mapFathomMeetingToCallSummary);
return { success: true, count: calls.length, calls };
};
export default defineLogicFunction({
universalIdentifier: FATHOM_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER,
name: 'fathom-list-calls-by-participant',
description:
'List accessible Fathom recordings whose calendar invitees include a given email. Returns each recording ID, title, start time, duration, participants, recorder, Fathom URL and meeting URL. Use the recordingId with Sync Fathom Call.',
timeoutSeconds: 30,
handler: fathomListCallsByParticipantHandler,
toolTriggerSettings: {
inputSchema: fathomListCallsByParticipantInputSchema,
},
workflowActionTriggerSettings: {
label: 'List Fathom Calls By Participant',
inputSchema: jsonSchemaToInputSchema(
fathomListCallsByParticipantInputSchema,
),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
error: { type: 'string' },
count: { type: 'number' },
calls: {
type: 'array',
items: {
type: 'object',
properties: {
recordingId: { type: 'number' },
title: { type: 'string' },
startedAt: { type: 'string' },
durationMinutes: { type: 'number' },
participants: { type: 'array', items: { type: 'string' } },
recordedBy: { type: 'string' },
fathomUrl: { type: 'string' },
meetingUrl: { type: 'string' },
},
},
},
},
},
],
},
});

View File

@@ -0,0 +1,138 @@
import { isNonEmptyArray } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { kv } from 'twenty-sdk/logic-function';
import { z } from 'zod';
import { isDefined } from 'src/utils/is-defined';
import {
FATHOM_HEALING_WINDOW_DAYS,
FATHOM_MEDIA_RECONCILIATION_CRON_PATTERN,
FATHOM_MEDIA_RECONCILIATION_GRACE_PERIOD_MILLISECONDS,
MILLISECONDS_PER_DAY,
} from 'src/constants/fathom.constant';
import { FATHOM_RECONCILE_MEDIA_IMPORTS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { type FathomMediaReconciliationRun } from 'src/logic-functions/types/fathom-media-reconciliation-plan.type';
import { buildRetryableFathomError } from 'src/logic-functions/utils/build-retryable-fathom-error.util';
import { cleanupDisconnectedFathomMediaImports } from 'src/logic-functions/utils/cleanup-disconnected-fathom-media-imports.util';
import { enqueueFathomJobsOrThrow } from 'src/logic-functions/utils/enqueue-fathom-jobs-or-throw.util';
import { reconcileFathomMediaImports } from 'src/logic-functions/utils/reconcile-fathom-media-imports.util';
const FATHOM_MEDIA_RECONCILIATION_CHECKPOINT_KEY =
'fathom:media-reconciliation:checkpoint';
const fathomMediaReconciliationRunSchema = z.object({
startedAfter: z.string().datetime(),
staleBefore: z.string().datetime(),
afterId: z.string().uuid().optional(),
});
const fathomMediaReconciliationPayloadSchema = z.object({
disconnectedAccountId: z.string().uuid().optional(),
recoveryRun: fathomMediaReconciliationRunSchema.optional(),
});
const buildFathomMediaReconciliationRun = (
now: Date,
): FathomMediaReconciliationRun => ({
startedAfter: new Date(
now.getTime() - FATHOM_HEALING_WINDOW_DAYS * MILLISECONDS_PER_DAY,
).toISOString(),
staleBefore: new Date(
now.getTime() - FATHOM_MEDIA_RECONCILIATION_GRACE_PERIOD_MILLISECONDS,
).toISOString(),
});
export const fathomReconcileMediaImportsHandler = async (payload: unknown) => {
const payloadParseResult = fathomMediaReconciliationPayloadSchema.safeParse(
payload ?? {},
);
if (!payloadParseResult.success) {
throw new Error('Fathom media reconciliation requires a valid payload');
}
if (
isDefined(payloadParseResult.data.disconnectedAccountId) &&
isDefined(payloadParseResult.data.recoveryRun)
) {
throw new Error('Fathom media reconciliation payload is ambiguous');
}
try {
const coreApiClient = new CoreApiClient({ runAs: 'application' });
const { disconnectedAccountId } = payloadParseResult.data;
if (isDefined(disconnectedAccountId)) {
const result = await cleanupDisconnectedFathomMediaImports({
coreApiClient,
connectedAccountId: disconnectedAccountId,
});
if (result.shouldContinue) {
await enqueueFathomJobsOrThrow({
logicFunctionUniversalIdentifier:
FATHOM_RECONCILE_MEDIA_IMPORTS_UNIVERSAL_IDENTIFIER,
payloads: [{ disconnectedAccountId }],
});
}
return result;
}
const storedRunParseResult = fathomMediaReconciliationRunSchema.safeParse(
await kv.get<unknown>(FATHOM_MEDIA_RECONCILIATION_CHECKPOINT_KEY),
);
const run =
payloadParseResult.data.recoveryRun ??
(storedRunParseResult.success
? storedRunParseResult.data
: buildFathomMediaReconciliationRun(new Date()));
const result = await reconcileFathomMediaImports({ coreApiClient, run });
if (isNonEmptyArray(result.disconnectedAccountIds)) {
await enqueueFathomJobsOrThrow({
logicFunctionUniversalIdentifier:
FATHOM_RECONCILE_MEDIA_IMPORTS_UNIVERSAL_IDENTIFIER,
payloads: result.disconnectedAccountIds.map((accountId) => ({
disconnectedAccountId: accountId,
})),
});
}
if (result.hasNextPage && isDefined(result.nextAfterId)) {
const nextRun = {
...run,
afterId: result.nextAfterId,
};
await kv.set(FATHOM_MEDIA_RECONCILIATION_CHECKPOINT_KEY, nextRun);
await enqueueFathomJobsOrThrow({
logicFunctionUniversalIdentifier:
FATHOM_RECONCILE_MEDIA_IMPORTS_UNIVERSAL_IDENTIFIER,
payloads: [{ recoveryRun: nextRun }],
});
} else {
await kv.delete(FATHOM_MEDIA_RECONCILIATION_CHECKPOINT_KEY);
}
return result;
} catch (error) {
throw buildRetryableFathomError({
operation: 'media import reconciliation',
error,
});
}
};
export default defineLogicFunction({
universalIdentifier: FATHOM_RECONCILE_MEDIA_IMPORTS_UNIVERSAL_IDENTIFIER,
name: 'fathom-reconcile-media-imports',
description:
'Recovers unfinished local Fathom media imports and settles imports for disconnected accounts.',
timeoutSeconds: 300,
handler: fathomReconcileMediaImportsHandler,
cronTriggerSettings: {
pattern: FATHOM_MEDIA_RECONCILIATION_CRON_PATTERN,
},
});

View File

@@ -0,0 +1,144 @@
import { isNonEmptyString } from '@sniptt/guards';
import { defineLogicFunction } from 'twenty-sdk/define';
import { getConnection, kv } from 'twenty-sdk/logic-function';
import { isDefined } from 'src/utils/is-defined';
import { FATHOM_INITIAL_BACKFILL_DAYS } from 'src/constants/fathom.constant';
import { FATHOM_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { type FathomConnectionHookPayload } from 'src/logic-functions/types/fathom-connection-hook-payload.type';
import { type FathomWebhookRegistration } from 'src/logic-functions/types/fathom-webhook-registration.type';
import { buildRetryableFathomError } from 'src/logic-functions/utils/build-retryable-fathom-error.util';
import { createFathomClient } from 'src/logic-functions/utils/create-fathom-client.util';
import { deleteStaleFathomWebhook } from 'src/logic-functions/utils/delete-stale-fathom-webhook.util';
import { enqueueFathomBackfillWorker } from 'src/logic-functions/utils/enqueue-fathom-backfill-worker.util';
import { getFathomConnectionClaimKey } from 'src/logic-functions/utils/get-fathom-connection-claim-key.util';
import { getFathomWebhookDestinationUrl } from 'src/logic-functions/utils/get-fathom-webhook-destination-url.util';
import { getFathomWebhookRegistrationKey } from 'src/logic-functions/utils/get-fathom-webhook-registration-key.util';
import { isTransientFathomError } from 'src/logic-functions/utils/is-transient-fathom-error.util';
import { storeFathomWebhookRegistration } from 'src/logic-functions/utils/store-fathom-webhook-registration.util';
// The hook retries as a whole, so the flag keeps a retry after a failed enqueue
// from skipping the import behind the already active registration.
const enqueueInitialBackfillOnce = async ({
connectedAccountId,
registrationKey,
registration,
}: {
connectedAccountId: string;
registrationKey: string;
registration: FathomWebhookRegistration;
}): Promise<void> => {
if (registration.isInitialBackfillEnqueued) {
return;
}
await enqueueFathomBackfillWorker({
connectedAccountId,
days: FATHOM_INITIAL_BACKFILL_DAYS,
});
await kv.set(registrationKey, {
...registration,
isInitialBackfillEnqueued: true,
});
};
export const fathomRegisterConnectionHandler = async (
payload: FathomConnectionHookPayload,
): Promise<{ success: true; webhookId: string }> => {
if (!isNonEmptyString(payload.connectedAccountId)) {
throw new Error(
'Fathom connection registration requires a connectedAccountId',
);
}
const apiUrl = process.env.TWENTY_API_URL;
if (!isNonEmptyString(apiUrl)) {
throw new Error('TWENTY_API_URL is required to register a Fathom webhook');
}
const connection = await getConnection(payload.connectedAccountId);
const fathomClient = createFathomClient(connection.accessToken);
const registrationKey = getFathomWebhookRegistrationKey(
payload.connectedAccountId,
);
const existingRegistration =
await kv.get<FathomWebhookRegistration>(registrationKey);
if (existingRegistration?.isActive) {
await enqueueInitialBackfillOnce({
connectedAccountId: payload.connectedAccountId,
registrationKey,
registration: existingRegistration,
});
return { success: true, webhookId: existingRegistration.webhookId };
}
if (isDefined(existingRegistration)) {
await deleteStaleFathomWebhook({
fathomClient,
webhookId: existingRegistration.webhookId,
});
}
await kv.set(getFathomConnectionClaimKey(payload.connectedAccountId), null, {
scope: 'SERVER',
});
const webhook = await fathomClient
.createWebhook({
destinationUrl: getFathomWebhookDestinationUrl({
apiUrl,
connectedAccountId: payload.connectedAccountId,
}),
triggeredFor: [
'my_recordings',
'my_shared_with_team_recordings',
'shared_team_recordings',
],
includeTranscript: true,
includeSummary: true,
includeActionItems: true,
})
.catch((error: unknown) => {
if (isTransientFathomError(error)) {
throw buildRetryableFathomError({
operation: 'webhook registration',
error,
});
}
throw error;
});
const registration: FathomWebhookRegistration = {
webhookId: webhook.id,
secret: webhook.secret,
isActive: true,
isInitialBackfillEnqueued: false,
};
await storeFathomWebhookRegistration({
fathomClient,
connectedAccountId: payload.connectedAccountId,
registrationKey,
registration,
});
await enqueueInitialBackfillOnce({
connectedAccountId: payload.connectedAccountId,
registrationKey,
registration,
});
return { success: true, webhookId: webhook.id };
};
export default defineLogicFunction({
universalIdentifier: FATHOM_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER,
name: 'fathom-register-connection',
description:
"Registers a Fathom webhook using the connected account's permissions and starts the initial history import.",
timeoutSeconds: 30,
handler: fathomRegisterConnectionHandler,
});

View File

@@ -0,0 +1,14 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { FATHOM_REQUEST_MEDIA_DOWNLOAD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { runFathomMediaImport } from 'src/logic-functions/utils/run-fathom-media-import.util';
export const fathomRequestMediaDownloadHandler = runFathomMediaImport;
export default defineLogicFunction({
universalIdentifier: FATHOM_REQUEST_MEDIA_DOWNLOAD_UNIVERSAL_IDENTIFIER,
name: 'fathom-request-media-download',
description: 'Requests and imports media for one Fathom recording.',
timeoutSeconds: 900,
handler: fathomRequestMediaDownloadHandler,
});

View File

@@ -0,0 +1,121 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import {
type InputJsonSchema,
jsonSchemaToInputSchema,
type LogicFunctionExecutionContext,
} from 'twenty-sdk/logic-function';
import { isDefined } from 'src/utils/is-defined';
import { FATHOM_SYNC_CALL_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { createFathomClient } from 'src/logic-functions/utils/create-fathom-client.util';
import { hydrateFathomMeeting } from 'src/logic-functions/utils/hydrate-fathom-meeting.util';
import { listFathomConnectionsForRequest } from 'src/logic-functions/utils/list-fathom-connections-for-request.util';
import { listFathomMeetings } from 'src/logic-functions/utils/list-fathom-meetings.util';
import { serializeFathomMeeting } from 'src/logic-functions/utils/serialize-fathom-meeting.util';
import { syncFathomMeetingToCallRecording } from 'src/logic-functions/utils/sync-fathom-meeting-to-call-recording.util';
const fathomSyncCallInputSchema: InputJsonSchema = {
type: 'object',
properties: {
recordingId: {
type: 'integer',
label: 'Fathom recording ID',
description:
'The numeric recording_id of the Fathom meeting, as returned by List Fathom Calls By Participant or shown at the end of the Fathom call URL.',
},
},
required: ['recordingId'],
additionalProperties: false,
};
type FathomSyncCallResult =
| {
success: true;
recordingId: number;
callRecordingId: string;
calendarEventId?: string;
created: boolean;
}
| { success: false; error: string };
export const fathomSyncCallHandler = async (
parameters: { recordingId?: unknown },
context: LogicFunctionExecutionContext,
): Promise<FathomSyncCallResult> => {
const recordingId = parameters.recordingId;
if (typeof recordingId !== 'number' || !Number.isSafeInteger(recordingId)) {
return { success: false, error: 'recordingId must be an integer' };
}
const connections = await listFathomConnectionsForRequest(context);
if (connections.length === 0) {
return {
success: false,
error:
'Fathom is not connected for this user. Open the Fathom app settings and add a connection first.',
};
}
for (const connection of connections) {
const fathomClient = createFathomClient(connection.accessToken);
const meetings = await listFathomMeetings({
fathomClient,
stopWhen: (listedMeetings) =>
listedMeetings.some((meeting) => meeting.recordingId === recordingId),
});
const meeting = meetings.find(
(listedMeeting) => listedMeeting.recordingId === recordingId,
);
if (!isDefined(meeting)) {
continue;
}
const syncResult = await syncFathomMeetingToCallRecording({
coreApiClient: new CoreApiClient({ runAs: 'application' }),
meeting: await hydrateFathomMeeting({
fathomClient,
serializedMeeting: serializeFathomMeeting(meeting),
}),
connectedAccountId: connection.id,
retryMedia: true,
});
return { success: true, recordingId, ...syncResult };
}
return {
success: false,
error: `No accessible Fathom recording found for ${recordingId}`,
};
};
export default defineLogicFunction({
universalIdentifier: FATHOM_SYNC_CALL_UNIVERSAL_IDENTIFIER,
name: 'fathom-sync-call',
description:
'Sync one Fathom recording into a CallRecording on demand: fetches its transcript, summary and action items and upserts them onto the record linked to the matching CalendarEvent. Useful to recover a missed webhook or to sync from a workflow.',
timeoutSeconds: 60,
handler: fathomSyncCallHandler,
toolTriggerSettings: { inputSchema: fathomSyncCallInputSchema },
workflowActionTriggerSettings: {
label: 'Sync Fathom Call',
inputSchema: jsonSchemaToInputSchema(fathomSyncCallInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
error: { type: 'string' },
recordingId: { type: 'number' },
callRecordingId: { type: 'string' },
calendarEventId: { type: 'string' },
created: { type: 'boolean' },
},
},
],
},
});

View File

@@ -0,0 +1,85 @@
import { defineUninstallLogicFunction } from 'twenty-sdk/define';
import {
type AppConnection,
kv,
listConnections,
} from 'twenty-sdk/logic-function';
import { isDefined } from 'src/utils/is-defined';
import { FATHOM_PROVIDER_NAME } from 'src/constants/fathom.constant';
import { FATHOM_UNINSTALL_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { type FathomWebhookRegistration } from 'src/logic-functions/types/fathom-webhook-registration.type';
import { getFathomConnectionClaimKey } from 'src/logic-functions/utils/get-fathom-connection-claim-key.util';
import { createFathomClient } from 'src/logic-functions/utils/create-fathom-client.util';
import { getFathomWebhookRegistrationKey } from 'src/logic-functions/utils/get-fathom-webhook-registration-key.util';
import { isFathomNotFoundError } from 'src/logic-functions/utils/is-fathom-not-found-error.util';
import { toErrorMessage } from 'src/logic-functions/utils/to-error-message.util';
const deleteWebhookForConnection = async (
connection: AppConnection,
): Promise<boolean> => {
const registrationKey = getFathomWebhookRegistrationKey(connection.id);
const registration = await kv.get<FathomWebhookRegistration>(registrationKey);
if (!isDefined(registration)) {
return false;
}
let isWebhookGone = false;
try {
await createFathomClient(connection.accessToken).deleteWebhook({
id: registration.webhookId,
});
isWebhookGone = true;
} catch (error) {
isWebhookGone = isFathomNotFoundError(error);
if (!isWebhookGone) {
// Uninstall runs once and takes the app's KV with it, so this log line is
// the only record of the webhook left behind in Fathom.
console.error(
`[fathom] leaked webhook ${registration.webhookId} for connected account ${connection.id}: ${toErrorMessage(error)}`,
);
}
}
await kv.delete(registrationKey);
await kv.delete(getFathomConnectionClaimKey(connection.id), {
scope: 'SERVER',
});
return isWebhookGone;
};
export const fathomUninstallHandler = async (): Promise<{
deletedWebhookCount: number;
}> => {
const connections = await listConnections({
providerName: FATHOM_PROVIDER_NAME,
});
let deletedWebhookCount = 0;
for (const connection of connections) {
try {
if (await deleteWebhookForConnection(connection)) {
deletedWebhookCount++;
}
} catch (error) {
console.error(
`[fathom] webhook cleanup failed for connected account ${connection.id}: ${toErrorMessage(error)}`,
);
}
}
return { deletedWebhookCount };
};
export default defineUninstallLogicFunction({
universalIdentifier: FATHOM_UNINSTALL_UNIVERSAL_IDENTIFIER,
name: 'fathom-uninstall',
description:
'Deletes the Fathom webhooks registered for every connected account when the app is uninstalled.',
timeoutSeconds: 30,
handler: fathomUninstallHandler,
});

View File

@@ -0,0 +1,65 @@
import { isNonEmptyString } from '@sniptt/guards';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import {
kv,
Response,
type ServerRouteResolverResult,
} from 'twenty-sdk/logic-function';
import { FATHOM_WEBHOOK_CONNECTION_QUERY_PARAMETER } from 'src/constants/fathom.constant';
import {
FATHOM_WEBHOOK_RESOLVER_UNIVERSAL_IDENTIFIER,
FATHOM_WEBHOOK_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { getFathomConnectionClaimKey } from 'src/logic-functions/utils/get-fathom-connection-claim-key.util';
export const fathomWebhookResolverHandler = async (
routePayload: RoutePayload<unknown>,
): Promise<ServerRouteResolverResult> => {
const connectedAccountId =
routePayload.queryStringParameters?.[
FATHOM_WEBHOOK_CONNECTION_QUERY_PARAMETER
];
if (!isNonEmptyString(connectedAccountId)) {
return new Response(
{ error: 'Missing Fathom connection identifier' },
{ status: 400 },
);
}
const workspaceId = await kv.get<string>(
getFathomConnectionClaimKey(connectedAccountId),
{ scope: 'SERVER' },
);
if (!isNonEmptyString(workspaceId)) {
return new Response(
{ error: 'Unknown Fathom connection' },
{ status: 404 },
);
}
return {
workspaceId,
targetLogicFunctionUniversalIdentifier: FATHOM_WEBHOOK_UNIVERSAL_IDENTIFIER,
payload: routePayload,
};
};
export default defineLogicFunction({
universalIdentifier: FATHOM_WEBHOOK_RESOLVER_UNIVERSAL_IDENTIFIER,
name: 'fathom-webhook-resolver',
description:
'Routes a Fathom webhook delivery to the workspace that claimed the connection named in its query string.',
timeoutSeconds: 15,
handler: fathomWebhookResolverHandler,
serverRouteTriggerSettings: {
httpMethods: ['POST'],
forwardedRequestHeaders: [
'webhook-id',
'webhook-timestamp',
'webhook-signature',
],
},
});

View File

@@ -0,0 +1,105 @@
import { isNonEmptyString } from '@sniptt/guards';
import { Fathom } from 'fathom-typescript';
import { meetingFromJSON } from 'fathom-typescript/sdk/models/shared';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { kv } from 'twenty-sdk/logic-function';
import { isDefined } from 'src/utils/is-defined';
import { FATHOM_WEBHOOK_CONNECTION_QUERY_PARAMETER } from 'src/constants/fathom.constant';
import { FATHOM_WEBHOOK_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { type FathomWebhookRegistration } from 'src/logic-functions/types/fathom-webhook-registration.type';
import { getFathomWebhookRegistrationKey } from 'src/logic-functions/utils/get-fathom-webhook-registration-key.util';
import { syncFathomMeetingToCallRecording } from 'src/logic-functions/utils/sync-fathom-meeting-to-call-recording.util';
type FathomWebhookResult =
| {
success: true;
callRecordingId: string;
calendarEventId?: string;
created: boolean;
}
| { success: true; skipped: true; reason: string }
| { success: false; error: string };
export const fathomWebhookHandler = async (
routePayload: RoutePayload<unknown>,
): Promise<FathomWebhookResult> => {
const connectedAccountId =
routePayload.queryStringParameters?.[
FATHOM_WEBHOOK_CONNECTION_QUERY_PARAMETER
];
if (!isNonEmptyString(connectedAccountId)) {
return { success: false, error: 'Missing Fathom connection identifier' };
}
const registration = await kv.get<FathomWebhookRegistration>(
getFathomWebhookRegistrationKey(connectedAccountId),
);
if (!isDefined(registration)) {
return { success: false, error: 'Unknown Fathom webhook registration' };
}
if (!isNonEmptyString(routePayload.rawBody)) {
return { success: false, error: 'Missing raw Fathom webhook body' };
}
const webhookId = routePayload.headers['webhook-id'];
const webhookTimestamp = routePayload.headers['webhook-timestamp'];
const webhookSignature = routePayload.headers['webhook-signature'];
if (
!isNonEmptyString(webhookId) ||
!isNonEmptyString(webhookTimestamp) ||
!isNonEmptyString(webhookSignature)
) {
return { success: false, error: 'Missing Fathom webhook signature' };
}
try {
Fathom.verifyWebhook(
registration.secret,
{
'webhook-id': webhookId,
'webhook-timestamp': webhookTimestamp,
'webhook-signature': webhookSignature,
},
routePayload.rawBody,
);
} catch {
return { success: false, error: 'Invalid Fathom webhook signature' };
}
if (!registration.isActive) {
return {
success: true,
skipped: true,
reason: 'The Fathom connection has been removed',
};
}
const meetingParseResult = meetingFromJSON(routePayload.rawBody);
if (!meetingParseResult.ok) {
return { success: false, error: 'Invalid Fathom meeting payload' };
}
const syncResult = await syncFathomMeetingToCallRecording({
coreApiClient: new CoreApiClient({ runAs: 'application' }),
meeting: meetingParseResult.value,
connectedAccountId,
});
return { success: true, ...syncResult };
};
export default defineLogicFunction({
universalIdentifier: FATHOM_WEBHOOK_UNIVERSAL_IDENTIFIER,
name: 'fathom-webhook',
description:
'Verifies Fathom meeting webhooks and upserts accessible recordings into CallRecording records.',
timeoutSeconds: 60,
handler: fathomWebhookHandler,
});

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import {
fathomImportMediaDownloadPayloadSchema,
fathomRequestMediaDownloadPayloadSchema,
} from 'src/logic-functions/schemas/fathom-media-download-payload.schema';
describe('fathom media download payload schemas', () => {
it('accepts a local call recording identifier', () => {
expect(
fathomRequestMediaDownloadPayloadSchema.safeParse({
callRecordingId: 'call-recording-id',
}).success,
).toBe(true);
});
it('accepts a generation-fenced poll payload', () => {
expect(
fathomImportMediaDownloadPayloadSchema.safeParse({
callRecordingId: 'call-recording-id',
downloadId: 'download-id',
attempt: 0,
}).success,
).toBe(true);
});
it('rejects a poll payload without its download generation', () => {
expect(
fathomImportMediaDownloadPayloadSchema.safeParse({
callRecordingId: 'call-recording-id',
attempt: 0,
}).success,
).toBe(false);
});
it.each([-1, 0.5, Number.NaN])(
'rejects invalid poll attempt %s',
(attempt) => {
expect(
fathomImportMediaDownloadPayloadSchema.safeParse({
callRecordingId: 'call-recording-id',
downloadId: 'download-id',
attempt,
}).success,
).toBe(false);
},
);
});

View File

@@ -0,0 +1,68 @@
import { z } from 'zod';
import { fathomMediaKindSchema } from 'src/logic-functions/schemas/fathom-media-kind.schema';
const fileSchema = z.object({
fileId: z.string().nullable().optional(),
});
export const fathomMediaUploadCheckpointSchema = z.object({
downloadId: z.string().trim().min(1),
fileId: z.string().trim().min(1),
kind: fathomMediaKindSchema,
});
const fathomRecordingImportSchema = z.object({
id: z.string(),
updatedAt: z.string(),
recordingId: z.string().nullable().optional(),
connectedAccountId: z.string().nullable().optional(),
mediaDownloadId: z.string().nullable().optional(),
mediaFailureReason: z.string().nullable().optional(),
mediaImportClaimedAt: z.string().nullable().optional(),
mediaUploadCheckpoint: z.unknown().nullable().optional(),
});
const fathomRecordingImportsConnectionSchema = z
.object({
edges: z.array(
z
.object({
node: fathomRecordingImportSchema.nullable().optional(),
})
.nullable(),
),
})
.nullable()
.optional();
export const callRecordingMediaStateNodeSchema = z.object({
id: z.string(),
updatedAt: z.string(),
video: z.array(fileSchema).nullable().optional(),
audio: z.array(fileSchema).nullable().optional(),
fathomRecordingImports: fathomRecordingImportsConnectionSchema,
transcript: z.unknown().nullable().optional(),
summary: z
.object({
markdown: z.string().nullable().optional(),
blocknote: z.unknown().nullable().optional(),
})
.nullable()
.optional(),
});
export const callRecordingMediaStateQueryResultSchema = z.object({
callRecordings: z
.object({
edges: z.array(
z
.object({
node: callRecordingMediaStateNodeSchema.nullable().optional(),
})
.nullable(),
),
})
.nullable()
.optional(),
});

View File

@@ -0,0 +1,26 @@
import { z } from 'zod';
export const fathomRequestMediaDownloadPayloadSchema = z.object({
callRecordingId: z.string().trim().min(1),
rateLimitAttempt: z.number().int().nonnegative().optional(),
});
export const fathomImportMediaDownloadPayloadSchema =
fathomRequestMediaDownloadPayloadSchema.extend({
downloadId: z.string().trim().min(1),
attempt: z.number().int().nonnegative(),
});
export const fathomMediaImportPayloadSchema =
fathomRequestMediaDownloadPayloadSchema.extend({
downloadId: z.string().trim().min(1).optional(),
attempt: z.number().int().nonnegative().default(0),
});
export type FathomRequestMediaDownloadPayload = z.infer<
typeof fathomRequestMediaDownloadPayloadSchema
>;
export type FathomImportMediaDownloadPayload = z.infer<
typeof fathomImportMediaDownloadPayloadSchema
>;

View File

@@ -0,0 +1,5 @@
import { z } from 'zod';
export const fathomMediaKindSchema = z.enum(['video', 'audio']);
export type FathomMediaKind = z.infer<typeof fathomMediaKindSchema>;

View File

@@ -0,0 +1,22 @@
import { z } from 'zod';
import { callRecordingMediaStateNodeSchema } from 'src/logic-functions/schemas/call-recording-media-state-query-result.schema';
export const fathomMediaReconciliationQueryResultSchema = z.object({
callRecordings: z
.object({
pageInfo: z.object({
hasNextPage: z.boolean(),
endCursor: z.string().nullable().optional(),
}),
edges: z.array(
z.object({
node: callRecordingMediaStateNodeSchema.extend({
status: z.string(),
}),
}),
),
})
.nullable()
.optional(),
});

View File

@@ -0,0 +1,5 @@
export type CalendarEventCandidate = {
id: string;
startsAt: string;
conferenceLink: { primaryLinkUrl?: string | null } | null;
};

View File

@@ -0,0 +1 @@
export type CallRecordingMediaFile = { fileId: string; label: string };

View File

@@ -0,0 +1,17 @@
import { type FathomMediaUploadCheckpoint } from 'src/logic-functions/types/fathom-media-upload-checkpoint.type';
export type CallRecordingMediaState = {
id: string;
updatedAt: string;
fathomRecordingImportId: string | undefined;
fathomRecordingImportUpdatedAt: string | undefined;
recordingId: string | undefined;
hasVideo: boolean;
hasAudio: boolean;
hasTranscript: boolean;
hasSummary: boolean;
failureReason: string | undefined;
connectedAccountId: string | undefined;
downloadId: string | undefined;
uploadCheckpoint: FathomMediaUploadCheckpoint | undefined;
};

View File

@@ -0,0 +1,15 @@
import { type CallRecordingMediaFile } from 'src/logic-functions/types/call-recording-media-file.type';
import { type TranscriptEntry } from 'src/logic-functions/types/transcript-entry.type';
export type CallRecordingSyncFields = {
title?: string;
status?: 'PROCESSING' | 'COMPLETED';
recordingRequestStatus?: 'REQUESTED';
startedAt?: string;
endedAt?: string;
transcript?: TranscriptEntry[];
video?: CallRecordingMediaFile[];
audio?: CallRecordingMediaFile[];
summary?: { markdown: string; blocknote: null };
calendarEventId?: string;
};

View File

@@ -0,0 +1,7 @@
export type FathomBackfillWorkerPayload = {
connectedAccountId: string;
days?: number;
createdAfter?: string;
cursor?: string;
pageIndex?: number;
};

View File

@@ -0,0 +1,10 @@
export type FathomCallSummary = {
recordingId: number;
title: string;
startedAt: string;
durationMinutes: number;
participants: string[];
recordedBy: string;
fathomUrl: string;
meetingUrl?: string;
};

View File

@@ -0,0 +1,5 @@
export type FathomConnectionHookPayload = {
connectionProviderId: string;
connectionProviderName: string;
connectedAccountId: string;
};

View File

@@ -0,0 +1,38 @@
import { type CallRecordingMediaState } from 'src/logic-functions/types/call-recording-media-state.type';
import { type FathomRecordingImportReference } from 'src/logic-functions/types/fathom-recording-import-reference.type';
export type FathomMediaReconciliationPlan = {
callRecordingsToComplete: FathomRecordingImportReference[];
importGroups: Array<{
connectedAccountId: string;
callRecordingIdsToRequest: string[];
downloadsToPoll: Array<{
callRecordingId: string;
downloadId: string;
}>;
}>;
disconnectedAccountIds: string[];
};
export type FathomMediaReconciliationCandidate = CallRecordingMediaState & {
status: string;
};
export type FathomMediaReconciliationRun = {
startedAfter: string;
staleBefore: string;
afterId?: string;
};
export type FathomMediaReconciliationPage = {
callRecordings: FathomMediaReconciliationCandidate[];
hasNextPage: boolean;
};
export type DisconnectedFathomMediaReconciliationPlan = {
callRecordingsToSettle: FathomRecordingImportReference[];
callRecordingsToSettleAndComplete: FathomRecordingImportReference[];
callRecordingsToSettleAndFail: FathomRecordingImportReference[];
callRecordingsToComplete: FathomRecordingImportReference[];
callRecordingsToFail: FathomRecordingImportReference[];
};

View File

@@ -0,0 +1,7 @@
import { type FathomMediaKind } from 'src/logic-functions/schemas/fathom-media-kind.schema';
export type FathomMediaUploadCheckpoint = {
downloadId: string;
fileId: string;
kind: FathomMediaKind;
};

View File

@@ -0,0 +1,6 @@
export type FathomMediaWriteContext = {
fathomRecordingImportId: string;
connectedAccountId: string;
claimedAt: string;
downloadId: string | null;
};

View File

@@ -0,0 +1,10 @@
import { type ListMeetingsRequest } from 'fathom-typescript/sdk/models/operations';
import { type MeetingListResponse } from 'fathom-typescript/sdk/models/shared';
// The page utilities read one response at a time, so a test double does not
// have to implement the SDK's async page iterator.
export type FathomMeetingLister = {
listMeetings: (
request: ListMeetingsRequest,
) => Promise<{ result: MeetingListResponse }>;
};

View File

@@ -0,0 +1,11 @@
import { type FathomMediaUploadCheckpoint } from 'src/logic-functions/types/fathom-media-upload-checkpoint.type';
export type FathomRecordingImportFields = {
callRecordingId?: string;
recordingId?: string;
connectedAccountId?: string;
mediaDownloadId?: string | null;
mediaFailureReason?: string | null;
mediaImportClaimedAt?: string | null;
mediaUploadCheckpoint?: FathomMediaUploadCheckpoint | null;
};

View File

@@ -0,0 +1,6 @@
export type FathomRecordingImportReference = {
callRecordingId: string;
callRecordingUpdatedAt: string;
fathomRecordingImportId: string;
fathomRecordingImportUpdatedAt: string;
};

View File

@@ -0,0 +1,6 @@
export type FathomWebhookRegistration = {
webhookId: string;
secret: string;
isActive: boolean;
isInitialBackfillEnqueued: boolean;
};

View File

@@ -0,0 +1,20 @@
import { type Meeting } from 'fathom-typescript/sdk/models/shared';
// Job payloads travel as JSON, so the Date fields of a Meeting are carried as
// ISO strings and the artifacts fetched separately are left out.
export type SerializedFathomMeeting = Omit<
Meeting,
| 'createdAt'
| 'scheduledStartTime'
| 'scheduledEndTime'
| 'recordingStartTime'
| 'recordingEndTime'
| 'transcript'
| 'defaultSummary'
> & {
createdAt: string;
scheduledStartTime: string;
scheduledEndTime: string;
recordingStartTime: string;
recordingEndTime: string;
};

View File

@@ -0,0 +1,7 @@
export type TranscriptEntry = {
participant: { name: string };
words: Array<{
text: string;
start_timestamp?: { relative: number };
}>;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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