Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/twenty-apps/public/fathom/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Fathom live sync

Each Twenty user connects Fathom through OAuth. Fathom remains the recording
permission boundary, so the two login email addresses do not need to match.

The connection hook registers a signed webhook at `TWENTY_FUNCTIONS_URL` and
stores its identifier and signing secret in workspace-scoped application KV.
The webhook subscribes to recordings owned by the connected user and recordings
available through their Fathom team.

Deliveries are verified before parsing. A deterministic Call Recording ID makes
replayed deliveries idempotent. Calendar events are linked only when normalized
meeting URL and scheduled time produce one unambiguous match.

Twenty currently invokes the disconnect hook after deleting the connection
token. The hook therefore marks the registration inactive so signed deliveries
in flight are acknowledged, but cannot delete the remote Fathom webhook. Fathom
publishes no token revocation endpoint either, so the webhook keeps delivering
until the user removes the app from their Fathom settings; every such delivery
is verified, then skipped. The uninstall hook still holds every connection
token, so it deletes the Fathom webhook of each connected account before the
app goes away.
4 changes: 3 additions & 1 deletion packages/twenty-apps/public/fathom/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ All notable changes to this application are documented in this file.

## 0.1.0

- Initial application scaffolded with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app)
- 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.
24 changes: 20 additions & 4 deletions packages/twenty-apps/public/fathom/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
# Fathom

Connect a Fathom account to Twenty through OAuth. Twenty stores and refreshes
the connection token for the person who connected it.
**Bring new Fathom meeting recordings, transcripts, and insights into Twenty.**

## What you get

- Fathom recordings saved as Call Recordings
- Transcripts, summaries, and action items
- Links to matching calendar events when the match is unambiguous
- Automatic sync for new recordings

## Requirements

The centrally managed Twenty OAuth app needs a Fathom client ID and client
secret. Development setup lives in [SETUP.md](SETUP.md).
Each person connects their Fathom account to Twenty. The connected Fathom
account decides which recordings Twenty can import; the Fathom and Twenty login
emails do not need to match.

## Heads up

- Twenty only links a recording to a calendar event when its meeting URL and
scheduled time identify one clear match.
- Disconnect and reconnect Fathom after changing the webhook URL or webhook
scopes so Fathom receives the new registration.

Development setup lives in [SETUP.md](SETUP.md).
17 changes: 16 additions & 1 deletion packages/twenty-apps/public/fathom/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ Follow these steps to get your app running locally.
- 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
```

Configure Twenty's `TWENTY_FUNCTIONS_URL` with the public functions base URL,
including its path when present, for example `https://<development-host>/s`.
The connection hook registers this destination automatically:

```text
https://<development-host>/s/webhook/fathom?connectionId=<connected-account-id>
```

## Steps

Expand All @@ -34,7 +49,7 @@ Follow these steps to get your app running locally.
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.
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.

## Verifying your setup

Expand Down
4 changes: 4 additions & 0 deletions packages/twenty-apps/public/fathom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
"test:watch": "vitest",
"test:unit": "vitest run --config vitest.unit.config.ts"
},
"dependencies": {
"@sniptt/guards": "^0.2.0",
"fathom-typescript": "0.0.43"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -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: '',
});
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { defineConnectionProvider } from 'twenty-sdk/define';

import { FATHOM_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
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',
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',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const FATHOM_PROVIDER_NAME = 'fathom';
export const FATHOM_WEBHOOK_ROUTE_PATH = '/webhook/fathom';
export const FATHOM_WEBHOOK_CONNECTION_QUERY_PARAMETER = 'connectionId';
export const CALENDAR_EVENT_PAGE_SIZE = 200;
export const MAX_CALENDAR_EVENT_PAGES = 10;
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,11 @@ 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_UNIVERSAL_IDENTIFIER =
'4047fcd1-8581-4188-bf35-e3e49a6235ca';
export const FATHOM_UNINSTALL_UNIVERSAL_IDENTIFIER =
'2f0b2b98-6ec1-4a3f-95bb-b2adf4bd4e33';
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
Comment thread
ehconitin marked this conversation as resolved.

import { buildFathomNotFoundError } from 'src/__tests__/utils/build-fathom-not-found-error.util';

const sdkMocks = vi.hoisted(() => ({
createWebhook: vi.fn(),
deleteWebhook: 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', () => ({
kv: { get: sdkMocks.kvGet, set: sdkMocks.kvSet },
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 INACTIVE_REGISTRATION = {
webhookId: 'stale-webhook',
secret: 'secret',
isActive: false,
};

describe('fathomRegisterConnectionHandler', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => undefined);
process.env.TWENTY_FUNCTIONS_URL = 'https://example.dev/s';
sdkMocks.getConnection.mockResolvedValue({ accessToken: 'token' });
sdkMocks.kvGet.mockResolvedValue(null);
sdkMocks.createWebhook.mockResolvedValue({
id: 'webhook-1',
secret: 'secret-1',
});
});

it('registers a webhook pointing at the connection-scoped route', async () => {
expect(await fathomRegisterConnectionHandler(HOOK_PAYLOAD)).toEqual({
success: true,
webhookId: 'webhook-1',
});
expect(sdkMocks.createWebhook).toHaveBeenCalledWith(
expect.objectContaining({
destinationUrl:
'https://example.dev/s/webhook/fathom?connectionId=connection-1',
}),
);
expect(sdkMocks.kvSet).toHaveBeenCalledWith('fathom-webhook:connection-1', {
webhookId: 'webhook-1',
secret: 'secret-1',
isActive: true,
});
});

it('deletes the webhook it just created when the registration cannot be stored', async () => {
sdkMocks.kvSet.mockRejectedValue(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 () => {
sdkMocks.kvSet.mockRejectedValue(new Error('Response lost'));
sdkMocks.kvGet.mockResolvedValueOnce(null).mockResolvedValueOnce({
webhookId: 'webhook-1',
secret: 'secret-1',
isActive: true,
});

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 () => {
sdkMocks.kvSet.mockRejectedValue(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({
...INACTIVE_REGISTRATION,
webhookId: 'existing-webhook',
isActive: true,
});

expect(await fathomRegisterConnectionHandler(HOOK_PAYLOAD)).toEqual({
success: true,
webhookId: 'existing-webhook',
});
expect(sdkMocks.createWebhook).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();
});
});
Loading
Loading