Compare commits

...
Author SHA1 Message Date
Charles BochetandClaude Opus 4.7 d64f23894e docs: fix audit module README title
The file lives under audit/ and documents AuditService, but was titled
"Analytics Module". Renaming to match the actual module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:43:02 +02:00
martmullandGitHub 8998009805 Stop reseting isListed and is featured after each sync (#20111)
isListed and isFeatured are manually updated by the admin, it should not
be updated if the application already exists
2026-04-28 13:23:42 +00:00
Abdul RahmanandGitHub a710c105cf Fix orphan views by deferring record table widget view creation to dashboard save (#20006) 2026-04-28 10:06:26 +00:00
Sai Sathwik PandGitHub a5cd64daf5 refactor: standardize JsonStringified casing (#20101)
## Summary
- Rename safeParseRelativeDateFilterJSONStringified to
safeParseRelativeDateFilterJsonStringified
- Update the matching utility file, exports, tests, and workflow usages

Part of #19839.

## Validation
- CI passed
2026-04-28 09:01:26 +00:00
df3e217d64 fix(ai-billing): bill executeAgent in a finally block so failed runs don't leak (#20065)
## Summary

`AgentAsyncExecutorService.executeAgent` consumes Anthropic tokens at
two points (the main `generateText` and the optional structured-output
sub-call). Billing was previously the **caller's** responsibility,
executed only after `executeAgent` returned. If `executeAgent` threw —
e.g. when `structuredResult.output == null` for a schema-mismatched
response, or anything caught by the catch-and-rethrow — we paid
Anthropic but never recorded a `usageEvent`. Likely the dominant source
of the 716M-token-vs-3.27-credits discrepancy seen on the affected
workspace in the 2026-04-26 incident.

## What changed

- Inject `AiBillingService` into `AgentAsyncExecutorService`. Add
`workspaceId` (required), `userWorkspaceId`, and `operationType`
(default `AI_WORKFLOW_TOKEN`) to `executeAgent`'s args.
- Capture `accumulatedUsage`, `cacheCreationTokens`, and
`nativeWebSearchCallCount` into mutable locals as each `generateText`
resolves. A throw between the main and structured-output calls still
bills the first call's tokens; the schema-validation throw still bills
the merged usage.
- Wrap the body in `try { ... } finally { ... }`. The finally calls
`calculateAndBillUsage` and `billNativeWebSearchUsage`, each guarded by
its own `try/catch + logger.error` so a billing exception can't mask the
original execution error or block the second emit.
- `ai-agent.workflow-action.ts`: pass the new args; drop the
now-redundant billing calls and `AiBillingService` injection.
`AiBillingModule` removed from this action's module imports.
- `run-evaluation-input.job.ts`: pass `workspaceId` (already in `data`)
and `userWorkspaceId: null`. **As a side effect, the eval pipeline now
bills correctly** — closing an additional billing leak from the audit
(`RunEvaluationInputJob` previously called `executeAgent` and discarded
`executionResult.usage`).

## Behavior change worth calling out

Previously, failed agent executions were silently free. They will now be
billed for the tokens Anthropic charged us. This is intentional and
correct.

## Test plan
- [ ] Trigger a workflow agent action that succeeds — `usageEvent` count
should match what was previously emitted.
- [ ] Trigger a workflow agent with a JSON response schema and ambiguous
input that produces a non-schema-conforming output
(`structuredResult.output == null`) — verify a `usageEvent` row is now
written for the consumed tokens (was 0 rows previously).
- [ ] Trigger a `runEvaluationInput` GraphQL mutation — verify a
`usageEvent` row is written (was 0 rows previously).

## Notes for review
- Conflicts trivially with the Sentry-context PR on
`run-evaluation-input.job.ts`. Recommend merging Sentry-context first;
this PR's 2-line argument addition rebases inside that PR's
`aiCallContextService.run(...)` callback wrapper.
- A small follow-up after this lands: thread `billingContext` through
the `experimental_repairToolCall` callback in
`agent-async-executor.service.ts:198` (currently marked with a TODO from
the title-gen+repair-tool PR).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:43:53 +02:00
1c06506256 chore: sync AI model catalog from models.dev (#20106)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.

**Please review before merging** — verify no critical models were
incorrectly deprecated.

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-04-28 08:49:33 +02:00
Mohamed Houssein DouiciandGitHub d271ba06f1 chore: use navigation menu item type into the generated navigation menu layout (#20099)
This is a small PR to fix an issue that came up when I created an object
with the default navigation menu item.

<img width="811" height="461" alt="image"
src="https://github.com/user-attachments/assets/70c7f55d-3d41-48ed-8cf9-5f649ea41f49"
/>
2026-04-28 06:23:00 +00:00
7d35979fcf i18n - docs translations (#20100)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-28 00:31:22 +02:00
8d3047c1fc i18n - docs translations (#20097)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-27 22:44:15 +02:00
Félix MalfaitGitHubClaude Opus 4.7claude[bot] <41898282+claude[bot]@users.noreply.github.com>
e632b7dbb9 fix(ai-billing): bill POST /rest/ai/generate-text usage to ClickHouse (#20066)
## Summary

`POST /rest/ai/generate-text` calls `generateText` and returns `usage`
to the client without emitting a `usageEvent`. Authenticated, gated only
by `PermissionFlagType.AI` — any workspace user with that permission
could call it in a loop without billing. Identified during the
2026-04-26 incident audit.

## What changed

- Inject `AiBillingService` into `AiGenerateTextController`.
- Add `@AuthUserWorkspaceId() userWorkspaceId: string` to source the
user-workspace identifier.
- Wrap the `generateText` call in `try { ... return ... } finally { ...
}` so billing fires even if the controller throws after Anthropic was
paid.
- Bill with `UsageOperationType.AI_WORKFLOW_TOKEN` and
`cacheCreationTokens: result.usage.inputTokenDetails?.cacheWriteTokens
?? 0`.
- Inner `try/catch` around the billing emit so a billing error can't
break the response.
- One-line module change: `AiGenerateTextModule` imports
`AiBillingModule` (NestJS DI requirement).

## Test plan
- [ ] Call `POST /rest/ai/generate-text` with a small prompt; verify a
`usageEvent` row appears in ClickHouse for the workspace with the
correct token count and `operationType = AI_WORKFLOW_TOKEN`.
- [ ] Call with a malformed model id that throws after the API key is
validated — verify no spurious billing call occurs (no Anthropic call
was made).

## Notes for review
- Response shape unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-04-27 21:16:19 +02:00
5b3ee3f1a7 fix(ai-billing): bill thread title generation and tool-call repair (#20067)
## Summary

Two `generateText` call sites were unbilled — identified during the
2026-04-26 incident audit:

- **Thread title generation**
(`AgentTitleGenerationService.generateThreadTitle`) — fires once per new
chat thread; low-volume but completeness matters.
- **Tool-call repair** (`repairToolCall` util, used inside
`experimental_repairToolCall` callbacks) — can fire `MAX_STEPS` times
per agent turn if a model gets stuck producing malformed tool calls.

## What changed

- `AgentTitleGenerationService` — inject `AiBillingService`, expand
`generateThreadTitle` signature to accept `workspaceId` and
`userWorkspaceId`, bill in a `finally` block with
`UsageOperationType.AI_CHAT_TOKEN`. Restructured so the no-default-model
short-circuit happens before the `try`, avoiding a fake billing call.
- `repair-tool-call.util.ts` — added an optional `billingContext` arg
containing `aiBillingService`, `modelId`, `workspaceId`,
`userWorkspaceId`, `operationType`. Wraps the `generateText` call in
`try/finally`; bills in finally with the operation type provided by the
caller. Optional so existing callers keep compiling.
- `chat-execution.service.ts` — threads `billingContext`
(`AI_CHAT_TOKEN`) into the repair callback.
- `agent-chat.service.ts` — passes `workspaceId` and
`thread.userWorkspaceId` to `generateThreadTitle`.
- `agent-async-executor.service.ts` — TODO comment marking that the
repair callback should thread billing once `executeAgent` accepts
`workspaceId` (depends on the executeAgent-finally PR).

## Follow-up

After the executeAgent-finally PR lands, `workspaceId` is in scope at
the `experimental_repairToolCall` callback in
`agent-async-executor.service.ts:198`. Thread `billingContext` through
to fire repair-call billing for workflow agents too, and remove the
TODO. Tiny follow-up.

## Test plan
- [ ] Create a new chat thread; verify a `usageEvent` row is written for
the title generation call.
- [ ] Send a chat message that triggers tool-call repair (e.g. force a
malformed tool call); verify a `usageEvent` row for the repair sub-call.

## Notes for review
- `billingContext` arg made **optional** to allow incremental rollout —
the executeAgent-finally PR plus a small follow-up will close the
agent-async-executor side.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:54:40 +02:00
875795cc30 feat(sentry): propagate workspace context to all spans (#20064)
## Summary

After the 2026-04-26 token-usage incident, identifying the responsible
workspace from a Sentry trace required a Postgres scavenger hunt —
Vercel AI SDK auto-instrumentation captures token counts and model name
but no twenty-specific identifiers, and that same gap exists for every
other auto-instrumented span (HTTP outbound, Postgres queries, GraphQL
resolvers, Redis, etc.).

This PR plugs that gap globally, not just for AI:

- A small utility
(`packages/twenty-server/src/engine/core-modules/sentry/utils/sentry-workspace-context.util.ts`)
that writes workspace identifiers onto Sentry's active isolation scope
as a `twenty` context block plus filterable tags and a `Sentry.setUser`
call.
- Two hook points covering all server traffic:
- **`WorkspaceAuthContextMiddleware`** — already runs after token
hydration on the GraphQL, metadata, admin-panel, and REST routes. It now
calls the utility once per authenticated request, before delegating to
`withWorkspaceAuthContext`.
- **`BullMQDriver.work` and `SyncDriver.processJob`** — every queue job
now runs inside `Sentry.withIsolationScope` and applies workspace
context from `job.data.workspaceId` (skipping silently for system jobs
that don't carry one).
- A `beforeSendSpan` hook in `instrument.ts` that reads the scope's
`twenty` context block back and projects it onto every span as
`twenty.workspace.id` and (when available) `twenty.user_workspace.id` —
dotted-namespace naming consistent with OTel/Sentry conventions like
`user.id` and `http.response.status_code`. Spans without a workspace
context (unauthenticated traffic) pass through untouched.

## Why this shape

Sentry's docs position `beforeSendSpan` as a per-span hook. The previous
iteration set context only at AI-specific call sites, which left non-AI
spans (DB queries, outbound HTTP, regular GraphQL queries, workflow
steps not touching AI) entirely unenriched. Hooking the two existing
global boundaries — auth middleware for HTTP/GraphQL/REST, and the queue
driver `work()` callback for background jobs — covers every
authenticated span across the app with no per-handler instrumentation.

## What's not in this PR

AI-specific identifiers (`twenty.agent.id`, `twenty.thread.id`,
`twenty.turn.id`, `twenty.workflow_run.id`) are out of scope here.
They're useful additions but require either propagating the IDs through
the call stack or a more fine-grained scope (per-step, per-turn) than
the request/job boundary, which is best handled in follow-up PRs that
target those specific call sites.

## Test plan

- [ ] Make any authenticated GraphQL request locally and confirm the
resulting span(s) in Sentry carry `twenty.workspace.id` and (for
user-authenticated routes) `twenty.user_workspace.id`.
- [ ] Make any authenticated REST request and confirm the same.
- [ ] Trigger a queue job (chat stream, agent turn evaluation, workflow
run, etc.) and confirm spans produced inside the worker carry
`twenty.workspace.id`.
- [ ] Confirm that DB and outbound HTTP spans produced under the
request/job also carry the workspace tag — these previously had no
twenty-specific identifiers.
- [ ] In the Sentry UI, filter events by the `twenty.workspace.id` tag
and confirm matching events appear.

## Notes for review

- Sentry init lives in `instrument.ts`, loaded before Nest bootstraps,
so `beforeSendSpan` runs outside Nest DI and reads context off the
isolation scope rather than holding a service reference.
- The middleware change is three lines; the BullMQ wrap is a single
`Sentry.withIsolationScope` around the existing job handler body; the
SyncDriver wrap mirrors it for the dev/test path. No new modules or DI
providers.
- The previous iteration's `AiCallContextService` and per-handler
`setContext` / `withContext` calls have been removed in favor of these
two hooks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:53:50 +02:00
350b835fbc i18n - docs translations (#20095)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-27 20:48:05 +02:00
ef49dad0bc i18n - docs translations (#20093)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-27 18:51:56 +02:00
Mohamed Houssein DouiciandGitHub e3a668a054 chore: export the enum view calendar layout to avoid typing errors on apps (#20090)
This is a small PR to fix an issue that came up when I created an app
with a custom object. I think the enum `ViewCalendarLayout` was not
caught as part of the exports.

<img width="1624" height="1114" alt="image"
src="https://github.com/user-attachments/assets/7bca3d0d-0e68-48ef-ac5f-6a3375733499"
/>
2026-04-27 16:35:22 +00:00
74536e6f98 Gate AI chat navigation entries by AI_SETTINGS permission (#20089)
## Summary

The AI chat history tab in the navigation drawer (and the corresponding
"New chat" icon in the mobile bottom bar) were rendered for every
authenticated user, regardless of whether they had `AI_SETTINGS`
permission. The actual chat content load is already gated — see
`AgentChatThreadInitializationEffect` — but the entry points were not,
so users without AI access saw a tab they could open and find empty.

This regressed when the `IS_AI_ENABLED` feature flag was removed in
#19916. The flag check was the only gate; nothing replaced it with a
permission check.

## Fix

Three small changes, all using
`useHasPermissionFlag(PermissionFlagType.AI_SETTINGS)`:

- **`MainNavigationDrawerTabsRow`** — return `null` when the user
  lacks AI access. This row only contains AI controls (the chat
  history tab pill + the "New chat" button), so without AI access
  there is nothing meaningful to render.
- **`MainNavigationDrawer`** — only render
`NavigationDrawerAiChatContent`
  when the user has AI access **and** the active tab is
  `AI_CHAT_HISTORY`. This is defensive: with the tabs row hidden the
  user can no longer switch to AI, but the active-tab atom could still
  carry `AI_CHAT_HISTORY` from a previous state (notably right after
  stopping impersonation of a user who had AI). Without this guard,
  the AI panel would briefly stay rendered.
- **`MobileNavigationBar`** — drop the `newAiChat` item when the user
  lacks AI access.

Surfaced by the same customer report that motivated #20088
(stop-impersonation
state cleanup), but this is a separate, pre-existing bug — admins
without AI permission see the tab regardless of impersonation.

## Test plan

- [ ] As a user **with** `AI_SETTINGS` permission, verify the AI chat
      tab and "New chat" button still appear and work in both the
      desktop sidebar and mobile bottom bar
- [ ] As a user **without** `AI_SETTINGS` permission, verify:
  - the tabs row at the top of the sidebar is gone (only the
    navigation menu shows below)
  - the "New chat" mobile bottom-bar icon is gone
  - the AI chat panel does not appear even after navigating away from
    a state where it was previously active

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:21:14 +02:00
b6ec2acb56 i18n - docs translations (#20087)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-27 16:59:00 +02:00
nitinandGitHub bb5f294c5a [AI] Collapse NativeToolBinder to a single bind() entry (#20051)
The binder doesnt need an agent or full tool context -- just a model and
options.
Single `bind(model, options)` entry!


Builds on #20022.
2026-04-27 16:35:59 +02:00
13aaea597e i18n - docs translations (#20083)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-27 14:56:00 +02:00
ec893c2767 i18n - translations (#20081)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-27 14:34:12 +02:00
Paul RastoinandGitHub f8c1ad5b3c Filtered upgrade logs when stopping before starting next instance segment (#20078)
# Introduction
In a nutshell, added a more readable logs that relates that when
performing a workspace fitlered workspace you can only browser the
workspace commands sequence

This PR is not a fix, but a log improvement
As before when cross-upgrading a single workspace you would be facing a
`instance` sync barrier error as not all your workspaces would have been
updated

Now logging and early returning instead of letting the guard hard throw

## Tests
Created a dedicated test for instance prevention on filtered upgrade
Standardized `migrationRecordToKey` usage across all upgrade integration
tests suites
2026-04-27 12:12:04 +00:00
Abdullah.andGitHub a90895e167 [Website] locale-segment routing and shared Lingui factory (#20079)
**1. Shared Lingui factory in `twenty-shared`**

- Extracted `createI18nInstanceFactory` into
`packages/twenty-shared/src/i18n/create-i18n-instance-factory.ts` so
every package gets the same per-render Lingui bootstrap with a
per-locale singleton cache and a `SOURCE_LOCALE` fallback.
- `twenty-emails/src/utils/i18n.utils.ts` now consumes the shared
factory.

**2. `twenty-website-new` Lingui bootstrap + Crowdin wiring**

- `lingui.config.ts`, `src/lib/i18n/*`, `nx run
twenty-website-new:lingui:{extract,compile}`.
- 31 locale PO files generated; minified compiled output kept out of
Prettier and Oxlint.
- `i18n-{push,pull}.yaml` workflows updated to include
`twenty-website-new` in Crowdin sync.

**3. `app/[locale]/...` segment routing with English at the root**

- All marketing routes moved under `src/app/[locale]/`; static
generation preserved (15 routes × 31 locales = 465 prerendered URLs).
- Middleware behavior:
  - `/{en}/...`         → 301 redirect to unprefixed canonical.
  - `/{non-en}/...`     → pass through, set `NEXT_LOCALE` cookie.

### What this PR explicitly does not do (deferred)

- Lingui-wrapping the actual marketing copy. Keys, build pipeline, and
  runtime are wired; copy migration is a separate, reviewer-friendlier
  PR.
2026-04-27 12:11:30 +00:00
Charles BochetandGitHub 9b7f7d059a docs: document rawBody on RoutePayload (#20080)
## Summary

Follow-up to #20061, which added `rawBody?: string` to
`LogicFunctionEvent` / `RoutePayload` so logic functions can verify
HMAC-style webhook signatures (GitHub's `X-Hub-Signature-256`, Stripe,
…) against the exact bytes that were received.

That PR did not update the public SDK docs, so the new field is
effectively invisible to developers consuming `RoutePayload` from
`twenty-sdk`. This PR adds a single row to the `RoutePayload` structure
table in `logic-functions.mdx` so the field is discoverable.

Addresses the review feedback on
https://github.com/twentyhq/twenty/pull/20061#discussion_r3147065014.
2026-04-27 12:06:55 +00:00
95fee18126 fix(server): match IMAP \Noselect attribute case-insensitively (#20043)
## Summary

`ImapGetAllFoldersService.isMailboxSelectable` checked
`mailbox.flags?.has('\\Noselect')`, which is case-sensitive. Per [RFC
3501 §6.3.8](https://www.rfc-editor.org/rfc/rfc3501#section-6.3.8), IMAP
attribute names are case-insensitive — different servers spell the flag
differently:

| Server | Spelling |
|---|---|
| Dovecot | `\Noselect` |
| Stalwart | `\NoSelect` |
| Cyrus | `\Noselect` |
| RFC examples | `\NOSELECT` |

The previous check only caught Dovecot's spelling. On other servers,
virtual namespace placeholders (e.g. Stalwart's `Shared Folders` parent)
passed through `isMailboxSelectable` and got persisted as folders. When
`MessagingMessageListFetchJob` later ran, it issued `SELECT "Shared
Folders"`, the server correctly rejected it with `NO [NONEXISTENT]
Mailbox does not exist.`, and the entire message-list fetch failed for
the channel.

## Reproduction

1. Connect an IMAP account whose server advertises a `\NoSelect` (or
`\NOSELECT`) namespace placeholder. Stalwart Mail v0.15.x exhibits this
with shared mailboxes:
   ```
   * LIST (\NoSelect) "/" "Shared Folders"
   ```
2. The folder discovery job persists it as a syncable folder.
3. `MessagingMessageListFetchJob` runs and fails:
   ```
   IMAP: Error fetching message list: D0 SELECT "Shared Folders"
   responseStatus: NO  serverResponseCode: NONEXISTENT
   ```

## Fix

Iterate the flag set and lowercase-compare against `'\\noselect'`. This
matches every legal spelling without changing behavior for compliant
`\Noselect` clients.

```diff
 private isMailboxSelectable(mailbox: ListResponse): boolean {
-  return !mailbox.flags?.has('\\Noselect');
+  if (!mailbox.flags) return true;
+  for (const flag of mailbox.flags) {
+    if (flag.toLowerCase() === '\\noselect') return false;
+  }
+  return true;
 }
```

## Test plan

- [x] Existing `\Noselect` test cases (`should not issue STATUS against
a \Noselect folder`, parent-reference preservation, Sent-folder
exclusion) still pass — the lowercase comparison subsumes them.
- [x] New parameterized test covers `\Noselect`, `\NoSelect`,
`\NOSELECT`, `\noselect` spellings against a Stalwart-style `Shared
Folders` namespace placeholder, asserting Twenty does **not** issue
`STATUS` on the placeholder and does **not** include it in the
discovered folder set.

---------

Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
2026-04-27 11:58:02 +00:00
neo773andGitHub 5b8804ff06 gmail extract body from deeply nested MIME parts (#19989)
/closes #19879
2026-04-27 11:56:50 +00:00
neo773andGitHub 6545ca274c fix(messaging): refactor SentMessagePersistenceService (#20077)
refactored `SentMessagePersistenceService` to be thin wrapper over
`saveMessagesAndEnqueueContactCreation`
this fixes a bug where the old logic did not call match participants
causing messages to not show up
2026-04-27 11:56:41 +00:00
15c52d3a39 Documentation update (#20059)
Add missing info about how to edit navigation bar, add many-to-many
relation and get Enterprise key for Org license on self-hosted

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-27 11:51:42 +00:00
3db1af9a17 fix(logic-function): forward raw request body for HMAC signature verification (#20061)
## Summary

- Add optional `rawBody?: string` to `LogicFunctionEvent` and forward it
from the route trigger so HMAC-based webhook signatures (GitHub's
`X-Hub-Signature-256`, Stripe, …) can be verified by user logic
functions.
- Update `github-connector`'s `getRawBodyForSignature` to prefer
`event.rawBody` (with the existing string/base64/null fallbacks kept for
older runtimes).

## Why

GitHub computes `X-Hub-Signature-256` over the **raw bytes** of the
request body. The receiver must verify against those exact bytes — key
order, whitespace and unicode escaping all matter, so the parsed JSON
body cannot be re-serialized to them.

Today the route trigger calls `extractBody(request)` which returns the
parsed object only. NestJS already preserves the raw body on
`request.rawBody` (the app is bootstrapped with `rawBody: true` in
`main.ts`), but it was never propagated into `LogicFunctionEvent`.

As a result the github-connector's webhook handler always took the "raw
body unavailable" branch and rejected every delivery (after #19961 /
962c2b3c14). With this change, signature verification can succeed
end-to-end.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:36:02 +00:00
Paul RastoinandGitHub 1e7c1691f4 [CI] Prevent previous version upgrade sequence mutation (#20075)
# Introduction
Prevent any PR to target a previous already released twenty version by
mistake.

Especially useful for existing opened PR introducing commands into an
upgrade that has just been released leading to a
`TWENTY_CURRENT_VERSION` bump

<img width="3150" height="1158" alt="image"
src="https://github.com/user-attachments/assets/b83d211f-a061-4d63-ae7a-354d7851ec08"
/>

## Bypass
If intentional add `ci:allow-previous-version-upgrade-mutation` label to
the PR and re-run the failed job

<img width="3150" height="1158" alt="image"
src="https://github.com/user-attachments/assets/f94ee630-d87b-4477-9e50-bf6773a8a280"
/>

This will require a brand new ci from a commit introduced after the
label has been added
2026-04-27 11:15:49 +00:00
577312f121 chore: sync AI model catalog from models.dev (#20073)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.

**Please review before merging** — verify no critical models were
incorrectly deprecated.

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-04-27 08:46:56 +02:00
fb8b9cb86c Add admin avatars and app logos (#20001)
## Summary
- Add user avatars and workspace logos to the admin general table and
workspace member detail view
- Show app icons in the admin app registrations table and reuse the
shared application display component
- Expose the needed avatar and logo fields through admin GraphQL queries
and backend lookup/statistics services
- Keep workspace fallback behavior consistent when no logo is set and
clean up a few local table styling duplicates

## Testing
- `./node_modules/.bin/tsc -p packages/twenty-front/tsconfig.json
--noEmit --pretty false`
- Manual UI verification of the admin general, workspace detail, and
apps tables

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-26 17:15:20 +00:00
Charles BochetandGitHub 499067ae14 fix(logic-function): serialize LocalDriver layer builds with cache lock (#20054)
## Summary

Fixes a race condition in `LocalDriver` where concurrent `execute()`
calls for the same application can trash each other's layer build,
causing logic function executions to fail with either:

- `Error: ENOENT: process.cwd failed with error no such file or
directory, the current working directory was likely removed without
changing the working directory, uv_cwd` (the yarn-install child's `cwd`
got wiped mid-run), or
- `ENOENT: no such file or directory, open '…/<pkg>/<file>.js.map'`
raised from yarn's berry link step (files under the deps layer vanish
while yarn is extracting).

Both are thrown from `…/deps/<checksum>/.yarn/releases/yarn-4.9.2.cjs` —
i.e. the yarn process `copyYarnEngineAndBuildDependencies` spawns with
`cwd: buildDirectory`.

## Root cause

`LocalDriver.createLayerIfNotExist` (and `ensureSdkLayer`) both follow a
check-then-act pattern with no mutual exclusion:

```ts
if (await pathExists(depsNodeModulesPath)) return;
await fs.rm(depsLayerPath, { recursive: true, force: true });
await copyDependenciesInMemory(...);
await copyYarnEngineAndBuildDependencies(depsLayerPath); // spawns yarn with cwd=depsLayerPath
```

The deps layer path is shared across all `execute()` calls that match a
given `yarnLockChecksum`. Two concurrent callers (e.g. a webhook
invocation + a cron-triggered logic function firing while the first
run's layer is still being built) both see no `node_modules`, both
`fs.rm` the directory, and one's yarn child ends up with its `cwd` or
its extraction target gone.

## Fix

Wrap the critical sections with `CacheLockService.withLock` — the same
cache-backed lock the Lambda driver already uses for its layer/executor
builds:

- `createLayerIfNotExist` → lock key
`local-driver-deps-layer:${yarnLockChecksum ?? 'default'}`
- `ensureSdkLayer` → lock key
`local-driver-sdk-layer:${workspaceId}:${applicationUniversalIdentifier}`

A fast-path `pathExists` check is kept outside the lock (so warm
executions still skip lock acquisition entirely), and the existence +
staleness check is **repeated inside the lock** so followers no-op after
the leader finishes.

Lock parameters mirror the Lambda driver's layer-build lock (`ttl=120s`,
`retry=500ms`, `maxRetries=240`).

`cacheLockService` is now passed to `LocalDriver` from
`LogicFunctionDriverFactory` (it was already injected there for the
Lambda driver).
2026-04-26 12:44:40 +00:00
nitinandGitHub ca84d28157 [AI] ai usage line chart date gap filling (#20048)
https://github.com/user-attachments/assets/ecd37dfb-daae-41c3-8316-a9d923463eca
2026-04-26 12:41:16 +00:00
nitinandGitHub d1a4902460 [AI] Drop 'serialization' from tool output naming (#20052)
didnt made sense anymore -- we dont really 'serialize'
2026-04-26 14:10:01 +02:00
Paul RastoinandGitHub 89ad87aa64 Make twenty-front build env agnostic (#20055)
## Introduction
In aim to reduce and optimize the number of twenty-front build we do
during our cd process and allow twenty-front build promotion

### Build time

**Nothing is baked.** The `build/` directory is a clean, env-agnostic
artifact. `index.html` contains the empty placeholder:

```html
<script id="twenty-env-config">
  window._env_ = {
    // This will be overwritten
  };
</script>
```

The JS bundles contain no hardcoded server URL.

---

### Deploy mode 1: Frontend served by the backend (Docker / NestJS)

1. Container starts, NestJS boots in `main.ts`
2. `generateFrontConfig()` runs, reads `process.env.SERVER_URL`
3. Rewrites `dist/front/index.html`, replacing the placeholder with:
   ```html
   <script id="twenty-env-config">
     window._env_ = {
       REACT_APP_SERVER_BASE_URL: "https://api.example.com"
     };
   </script>
   ```
4. NestJS serves the static `dist/front/` directory
5. Browser loads `index.html`, `window._env_` is set before the app JS
executes
6. `src/config/index.ts` reads `window._env_.REACT_APP_SERVER_BASE_URL`
and uses it

---

### Deploy mode 2: Frontend served standalone (CDN / nginx / static
server)

1. Take the `build/` artifact as-is
2. Before serving, run at deploy time:
   ```bash
REACT_APP_SERVER_BASE_URL=https://api.example.com sh
./scripts/inject-runtime-env.sh
   ```
3. This does the same `sed` replacement on `build/index.html`
4. Serve the `build/` directory with your static server of choice
5. Same resolution in the browser:
`window._env_.REACT_APP_SERVER_BASE_URL` is picked up by
`src/config/index.ts`

---

### Fallback: no injection at all

If neither mechanism runs (e.g. local dev with `vite dev`),
`window._env_.REACT_APP_SERVER_BASE_URL` is `undefined`, and
`getDefaultUrl()` kicks in:
- **Localhost**: returns `http://localhost:3000`
- **Non-localhost**: returns same-origin (`window.location.origin`)
2026-04-26 07:05:54 +00:00
Charles BochetandGitHub 0c5c9c844e feat(github-connector): exclude self-reviews from review counts (#20050)
## Summary

- Adds an `isSelfReview` boolean to the consolidated `PullRequestReview`
record (`true` when the reviewer is the same contributor as the PR
author).
- Filters `isSelfReview === true` rows out of the top-reviewers
leaderboard (`top-contributors`) and per-contributor review stats
(`contributor-stats`) so contributors are credited only for reviews on
**other people's** PRs.
- Both the live ingestion path (`fetch-prs`) and the recompute job
(`recompute-pull-request-reviews`) now thread `prAuthorId` to
`buildConsolidatedRow`, so re-running either is sufficient to backfill
existing rows — no extra migration needed.

## Test plan

- [x] `yarn test
src/modules/github/pull-request-review/utils/consolidate-reviews.integration-test.ts`
— 20 tests passing, including new coverage for the `isSelfReview` helper
and `buildConsolidatedRow` payload.
- [ ] After merge: re-run `fetch-prs` (or
`recompute-pull-request-reviews`) once on a target workspace to backfill
`isSelfReview` on existing `PullRequestReview` rows, then verify the
top-reviewers leaderboard no longer credits self-reviews.


Made with [Cursor](https://cursor.com)
2026-04-25 23:34:01 +02:00
Charles BochetandGitHub 41571ea377 feat(front-component-renderer): forward offset/movement coordinates on serialised events (#20046)
## Summary

Adds `offsetX`, `offsetY`, `movementX`, `movementY` to
`SerializedEventData` and the host event serialiser so apps can reason
about element-relative pointer positions without trying to read the host
element's bounding rect (which is impossible from a remote-DOM worker).

## Motivation

I was building a front-component with click-to-drop-pin and trackpad
pan/zoom (custom OSM tile renderer). Two real bugs surfaced from the
current event-serialisation surface:

1. **Wheel pan/zoom was broken.** The host already forwards
`deltaX`/`deltaY`, but app authors naturally read them off the
React-style event handler argument as `e.deltaX`/`e.deltaY`. Because
remote-DOM bridges everything via `RemoteEvent extends
CustomEvent<Detail>`, the payload actually arrives at `e.detail.deltaX`.
Reading the wrong place gives `undefined`, and `undefined < 0 ===
false`, so every wheel notch zoomed in the same direction. App code now
uses `e.detail`, but this was a sharp papercut worth flagging in docs /
a helper (separate change).

2. **Element-local click coords are unobtainable from a worker.** With
only `clientX/Y`, an app needs the stage's bounding rect to translate
viewport coordinates to local — which can't be read across the worker
boundary. `offsetX`/`offsetY` close that gap with a one-read solution.
`movementX`/`movementY` round out the set for any future drag-style
interactions if `mousemove` later joins the allow-list.
2026-04-25 10:12:28 +00:00
570038ad65 chore: sync AI model catalog from models.dev (#20045)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.

**Please review before merging** — verify no critical models were
incorrectly deprecated.

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-04-25 08:26:54 +02:00
Charles BochetandGitHub 88add35f0b Bump twenty-sdk, twenty-client-sdk, create-twenty-app to 2.1.0 (#20041)
## Summary

- Bumps `twenty-sdk` from `2.1.0-canary.1` to `2.1.0`.
- Bumps `twenty-client-sdk` from `2.1.0-canary.1` to `2.1.0`.
- Bumps `create-twenty-app` from `2.1.0-canary.1` to `2.1.0`.
2026-04-24 23:10:40 +02:00
Charles BochetandGitHub d74e3fa3b5 chore(server): bump current version to 2.2.0 (#20040)
## Summary

We are releasing Twenty v2.2.0. This PR sets up the
upgrade-version-command machinery for the new release line:

- Promote `2.1.0` into `TWENTY_PREVIOUS_VERSIONS` (it just shipped)
- Set `TWENTY_CURRENT_VERSION` to `2.2.0`
- Reset `TWENTY_NEXT_VERSIONS` to `[]`
- Refresh the `InstanceCommandGenerationService` snapshots to reflect
the new current version (`2.2.0` / `2-2-` slug)
- Update the failing-sequence-runner snapshot to include `2.2.0` in the
covered versions list

The `2-2/` upgrade-version-command module is already in place and wired
into `WorkspaceCommandProviderModule`, so future upgrade commands
targeting `2.2.0` can land directly under `2-2/` (or be generated
against `--version 2.2.0`).
2026-04-24 22:19:16 +02:00
Charles BochetandGitHub aad77b5315 Bump twenty-sdk, twenty-client-sdk, create-twenty-app to 2.1.0-canary.1 (#20038)
## Summary

- Bumps `twenty-sdk` from `2.1.0` to `2.1.0-canary.1`.
- Bumps `twenty-client-sdk` from `2.0.0` to `2.1.0-canary.1`.
- Bumps `create-twenty-app` from `2.0.0` to `2.1.0-canary.1`.
2026-04-24 19:57:02 +00:00
bba102efe3 chore: remove accidentally committed .claude-pr/ directory (#20036)
## Summary

Removes the `.claude-pr/` directory at the repo root, which contains
byte-identical duplicates of `CLAUDE.md` and `.mcp.json`.

## Why

- Added in #19517 (a PR about file-attachment support for agent chat),
unrelated to its content — looks like an accidental commit of a local
scratch directory.
- Not referenced from any workflow, script, or doc in the repository.
`.github/workflows/claude.yml` uses the root `CLAUDE.md` / `.mcp.json`,
not the copies under `.claude-pr/`.
- Because it's a duplicate of the source of truth, it will silently
drift out of sync over time.

## Test plan

- [x] Confirmed no references to `.claude-pr` anywhere in `*.yml`,
`*.yaml`, `*.json`, `*.md`, `*.sh`, `*.ts`, `*.tsx`
- [x] Confirmed `.github/workflows/claude.yml` does not reference it
- [x] `.claude-pr/CLAUDE.md` and `.claude-pr/.mcp.json` are
byte-identical to the root copies at the time of this PR

If this directory is actually needed by an internal tool I missed, feel
free to close — happy to be corrected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:53:16 +02:00
Charles BochetandGitHub 80c0e5603d Move isPreInstalled applicationRegistration instance command to 2.1 (#20037)
## Summary

The fast instance command adding `isPreInstalled` to
`core.applicationRegistration` was added after 2.0 was released, so it
must run as part of the 2.1 upgrade rather than 2.0.

- Renamed
`2-0/2-0-instance-command-fast-1776886452831-add-is-pre-installed-to-application-registration.ts`
to
`2-1/2-1-instance-command-fast-1776886452831-add-is-pre-installed-to-application-registration.ts`
- Updated `@RegisteredInstanceCommand` version from `'2.0.0'` to
`'2.1.0'`
- Updated the import path in `instance-commands.constant.ts`

The original timestamp (`1776886452831`) was kept; it is smaller than
the existing 2.1 fast command timestamp, so this command will simply run
first within the 2.1.0 batch.
2026-04-24 17:52:58 +00:00
Abdullah.andGitHub 47710908a8 [Website] Architecture, hardening, and perf pass. (#20020)
This PR is the result of a critical architectural review of
`twenty-website-new` and the
follow-on cleanup work. It does not touch any other package. Scope is
everything except
the enterprise/billing routes (deferred to a separate PR) and CI wiring
(also deferred).

### Why

The package had grown organically: ad-hoc scroll/motion/halftone code
per section,
WebGL renderers instantiated raw, sections without a shared shape
contract, route-scoped
files leaking across layers, public API routes without rate limiting /
timeouts /
schema validation, unbounded module-level caches in visual code,
drag/resize handlers
re-rendering React 60x/sec, no security headers, and a Lottie
scroll-mapping that would
silently drift if the asset got re-exported. We needed contracts and
primitives in
place before further work (i18n, decomposition of the giant visual
files, MDX migration
of customer/legal pages) is safe to do.

### What changed

**Layering and contracts (now enforced, not just documented).**
- New layering rule: `app → sections → lib → design-system / theme /
icons`.
Cross-section reuse goes through `lib/`. Flipped the design-system
import rule
  from warn to error.
- Extracted shared primitives into `lib/`: `scroll`, `motion`,
`halftone`, `customers`,
`partner-application`, `api`, `seo`, `semver`, `community`,
`visual-runtime`.
- Lifted route-scoped data/types out of `app/` into `lib/` +
`sections/`.
- Section shape contract: every section exposes a single compound export
from
`components/index.ts(x)`; non-leaf sections own the outer `<section>`
from
`Root.tsx`; named slots are matched by `displayName` (no
`Children.toArray`
  positional indexing). Enforced by `scripts/check-section-shape.mjs`.
- WebGL boundary: `new THREE.WebGLRenderer(...)` is forbidden outside
`src/lib/visual-runtime/`. Everything goes through
`createSiteWebGlRenderer`,
which enforces the site-wide context cap, the
`NEXT_PUBLIC_DISABLE_HEAVY_VISUALS`
  kill switch, and GPU/power-preference defaults. Enforced by
`scripts/check-boundaries.mjs` with per-line
`boundary-allow-next-line:<rule-id>`
  escape hatches and stale-directive detection.

**Design system grew to cover real cases.**
- Added Modal, Form, and Layout primitives (Stack / Inline / Grid) so
sections stop
reinventing them. Built on `@base-ui/react` for accessibility + focus
management.

**Public API hardening (non-enterprise).**
- New `lib/api/` primitives: `createRateLimiter` (in-memory token
bucket),
`fetchWithTimeout`, `readJsonBody` (Zod-validated). Applied to
newsletter,
community, and partner-application routes. `/api/partner-application`
specifically
  got a per-IP rate limit, body cap, and timeout.

**Performance.**
- `DraggableTerminal` and `DraggableAppWindow`: `pointermove` now
mutates `transform`
(via `translate3d`) and `width`/`height` directly on the DOM ref. React
state
commits only on `pointerup`. Eliminates per-frame re-renders during
interaction.
- `createBoundedFailureCache` (FIFO, 256 entries) replaces unbounded
module-level
failure caches in four visual components. Bounds memory growth from bad
asset URLs.

**Lottie frame-map guard.**
- `dotlottie-react`'s `player.totalFrames` returns a raw float (`op -
ip`), not an
integer. The HomeStepper scroll → frame map is keyed to the authored
timeline,
  so silent drift would desync every step boundary.
- Reads now `Math.floor(player.totalFrames)` consistently.
- `scripts/check-lottie-frames.mjs` extracts `op - ip` from
`public/lottie/stepper/stepper.lottie` at build time and asserts it
against
`HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES`. If anyone re-exports the
Lottie,
the build fails until both that constant and every `STEP_*_END` are
updated together.

**Security headers (`next.config.ts`).**
- HSTS, `X-Content-Type-Options: nosniff`, `Referrer-Policy:
strict-origin-when-cross-origin`,
`Permissions-Policy` (camera/mic/geolocation/payment off),
`X-Frame-Options: DENY`,
  `Content-Security-Policy: frame-ancestors 'none'`.
- Full CSP intentionally deferred until we enumerate all third-party
origins
  (Cal.com, Stripe, GitHub avatars, twenty-icons.com, etc.).

**Build / config quirks documented in code.**
- `tsconfig.json` is standalone (does NOT extend the monorepo base) —
Next.js +
  React Compiler require options that conflict with the base config.
- `sharp` moved from `devDependencies` to `dependencies` so production
image
  optimization works.

### What's deliberately NOT in this PR

- **Enterprise / billing routes** — open redirect on
`/api/enterprise/checkout`,
indefinite-bearer JWT, non-idempotent Stripe seat updates, unpinned
Stripe
`apiVersion`, missing webhook reconciliation, inconsistent error
envelope.
  Going out as a separate, security-focused PR.
- **CI workflow for `twenty-website-new`** (`lint` / `typecheck` /
`test` / `build`
  targets) — separate follow-up PR.
- **i18n via Lingui** — decision made (we need internationalization and
we already
use Lingui in `twenty-front` / `twenty-emails`); 4-phase migration plan
exists
  but does not land here.
- **Decomposition of giant visual files** (HomeVisual, ThreeCards
visuals) — blocked
  on the i18n landing first; otherwise we'd rebase the world twice.
- **Customer / legal pages → MDX** — same reason.
- **Selective memoization pass** — needs browser profiling, not blind
`useMemo`.
- **Pre-existing lint errors / typecheck noise** (~44 errors, ~41
warnings, plus
generated Next.js types and `@ts-nocheck` files) are unchanged. The
cleanup
  did not introduce new ones.

### Test plan

- [ ] `yarn install`
- [ ] `yarn nx run twenty-website-new:dev` — homepage, customers,
partner,
enterprise activate, blog, why-twenty, plans/pricing, legal pages
render.
- [ ] HomeStepper: scroll through, confirm the Lottie animation lines up
with
every step boundary. Console must NOT log a `totalFrames` mismatch.
- [ ] HomeVisual: drag and resize the terminal + app window; verify
smoothness
(no per-frame React re-renders) and that final position/size persists on
release.
- [ ] Public API endpoints: hit `/api/newsletter`, `/api/community`,
`/api/partner-application` with bad payloads → expect 4xx with Zod
errors,
not 500s. Hammer `/api/partner-application` past the per-IP limit → 429.
- [ ] Response headers on any page include HSTS, nosniff, referrer
policy,
permissions policy, X-Frame-Options, and `frame-ancestors 'none'` CSP.
- [ ] `yarn nx run twenty-website-new:lint` — error/warning count must
not exceed
      the pre-existing baseline.
- [ ] `yarn nx run twenty-website-new:typecheck` — same baseline rule.
- [ ] `node packages/twenty-website-new/scripts/check-boundaries.mjs` —
passes,
      no stale directives.
- [ ] `node packages/twenty-website-new/scripts/check-section-shape.mjs`
— passes.
- [ ] `node packages/twenty-website-new/scripts/check-lottie-frames.mjs`
— passes.
- [ ] `yarn nx run twenty-website-new:build` — green, including the
three checks
      above if wired into the build target.
2026-04-24 16:40:09 +00:00
Charles BochetandGitHub 0bb3660844 chore(twenty-sdk): shrink logic-function bundles via stubbing (#20033)
## Summary

Logic-function bundles produced by the SDK CLI were ~1.2 MB each (source
maps ~3.1 MB) because esbuild was inlining `twenty-sdk/define` and its
transitive dependencies (zod + locales, twenty-shared, etc.). Those
`define*` factories are pure build-time metadata used only by the
manifest extractor — the Lambda runtime only ever invokes
`default.config.handler`, so the factories are dead weight at runtime.

This PR shrinks the bundles to ~9.5 KB each (~99% reduction) without
changing runtime behaviour.

## What changes

- **Stub `twenty-sdk/define` at user-app build time.** New esbuild
plugin
(`packages/twenty-sdk/src/cli/utilities/build/common/plugins/stub-twenty-sdk-define.plugin.ts`)
intercepts every import of `twenty-sdk/define` during user-app builds
and replaces it with a tiny virtual module:
- Factory functions (`defineLogicFunction`,
`definePostInstallLogicFunction`, …) become `(config) => ({ success:
true, config, errors: [] })`.
  - Enums and helpers become `Proxy`-based no-ops.
- Wired into both the one-shot build (`build-application.ts`) and the
watcher (`esbuild-watcher.ts`), for logic functions and front
components.
- **New runtime barrel `twenty-sdk/logic-function`.** Re-exports only
the types logic-function authors need (`InstallPayload`, `RoutePayload`,
`CronPayload`, `DatabaseEventPayload`, `LogicFunctionConfig`,
`InputJsonSchema`, …). Compiled `.mjs` is 36 bytes. Wired into Vite,
Rollup `.d.ts` bundling, `package.json#exports`, and `typesVersions`.
- **Lint enforcement.** Added an oxlint `no-restricted-imports` rule
that forbids `twenty-shared` / `twenty-shared/*` imports from
`**/*.logic-function.ts` and `**/logic-functions/**/*.ts`, with a help
message pointing at the new barrel. Applied to the `create-twenty-app`
template and to `github-connector`, `hello-world`, `postcard`.
- **Migrated existing sources.** All logic-function files across
`community/{github-connector, apollo-enrich}`, `examples/{hello-world,
postcard}`, and `internal/{twenty-for-twenty, self-hosting, exa}` now
import types from `twenty-sdk/logic-function` instead of
`twenty-sdk/define` or `twenty-shared/*`. Renamed leftover
`InstallLogicFunctionPayload` references to `InstallPayload`.

## Why this is safe

- `define*` exports from `twenty-sdk/define` are metadata factories
whose call expressions are statically inspected by the manifest
extractor (`manifest-extract-config.ts`). They're never evaluated at
runtime — the Lambda executor only walks `default.config.handler`
(`logic-function-drivers/constants/executor/index.mjs`).
- The stub keeps the same call shape (`{ success, config, errors }`), so
any logic-function module that re-exports
`defineX(config).config.handler` still resolves to the user's handler at
runtime.
- Front-component bundles are unaffected by the stub because the
pre-existing JSX transform plugin
(`jsx-transform-to-remote-dom-worker-format-plugin.ts`) unwraps
`defineFrontComponent(...)` earlier in the pipeline. That's intentional
— front-component bloat is React/Preact, not in scope here.

## Measurements (github-connector)

| Asset | Before | After |
|---|---|---|
| `*.logic-function.mjs` | ~1.2 MB | ~9.5 KB |
| `*.logic-function.mjs.map` | ~3.1 MB | ~22 KB |
2026-04-24 17:51:35 +02:00
WeikoandGitHub 2ccc293f99 Gate export/import command menu items by permission flag (#19991)
## Summary
- Hides the `exportRecords`, `exportView`, and `importRecords` command
menu actions from
users whose role does not hold the matching `EXPORT_CSV` / `IMPORT_CSV`
permission flag.
- Exposes the current user's role permission flags to
`conditionalAvailabilityExpression`
by adding `permissionFlags: Record<string, boolean>` to
`CommandMenuContextApi`, mirroring
 how `featureFlags` is already accessible.
- Adds a `2.1.0` workspace upgrade command that rewrites the three
existing rows on every
 active/suspended workspace.

## Before
<img width="1294" height="287" alt="Screenshot 2026-04-22 at 19 37 40"
src="https://github.com/user-attachments/assets/11ca8635-14d7-40a0-9ca0-76329c54e3c6"
/>

## After
<img width="1283" height="285" alt="Screenshot 2026-04-22 at 19 32 25"
src="https://github.com/user-attachments/assets/5e49fa8a-4541-42ee-96da-4c1de7d00aae"
/>
2026-04-24 15:22:44 +00:00
nitinGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>Félix MalfaitFélix Malfait
6c1c0737b0 Clarify registry tools vs native model tool binding (#20022)
## Intent

This is a small foundation cleanup for the tool architecture.

The main decision is: registry tools and native SDK/model tools are
different things.

- Registry tools have descriptors, schemas, catalog entries, and execute
through `ToolExecutorService`
- Native model tools are opaque AI SDK objects, bound directly into the
model `ToolSet`
- Surfaces still own their policy: chat, MCP, and workflow agents decide
what they expose

## What changed

- Removed `NATIVE_MODEL` from `ToolCategory`
- Kept `ToolRegistryService` focused on registry-backed tools only
- Moved native model tool binding through `NativeToolBinderService`
- Reused native binding from chat instead of duplicating
provider-specific web-search logic
- Kept MCP local execution exclusions in a dedicated constant
- Moved surface-specific constants into dedicated constant files

## What comes next

- Move hardcoded chat app preloads, like Exa web search, into
app/manifest metadata
- Decide a clearer policy for local runtime tools like code interpreter
and HTTP request
- Gradually document the three tool shapes: registry tools, native model
tools, and local runtime tools

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-04-24 17:18:19 +02:00
251f5deab6 [breaking, deploy server first] fix(ai-chat): persist providerExecuted flag on tool parts (#20030)
## Summary
Fixes Sentry errors of the form:

> \`messages.3: \`tool_use\` ids were found without \`tool_result\`
blocks immediately after: srvtoolu_…. Each \`tool_use\` block must have
a corresponding \`tool_result\` block in the next message.\`

### Root cause
When the model invokes a **provider-hosted tool** (e.g. Anthropic's
native \`web_search\` — note the \`srvtoolu_\` ID prefix), the AI SDK
marks the resulting \`UIMessagePart\` with \`providerExecuted: true\`.
\`convertToModelMessages\` uses that flag to emit the
tool_use/tool_result pair *inside the same assistant message* — the
format Anthropic requires for server-side tools.

Our \`AgentMessagePart\` persistence was dropping \`providerExecuted\`
on the way to the DB (and re-hydration didn't know to set it). On the
next turn, \`convertToModelMessages\` treated the rehydrated part as a
client-side tool call, splitting it into \`assistant(tool_use)\` +
\`user(tool_result)\` — which Anthropic then rejects with the error
above.

### Fix
- Add nullable \`providerExecuted BOOLEAN\` column on
\`core.agentMessagePart\` via a fast instance command.
- Surface the field on \`AgentMessagePartDTO\` (GraphQL).
- Preserve it through \`mapUIMessagePartsToDBParts\` (server) and both
\`mapDBPartToUIMessagePart\` mappers (server + frontend).
- Include it in \`GET_CHAT_MESSAGES\` and \`GET_AGENT_TURNS\`
selections.
- Regenerate \`generated-metadata/graphql.ts\`.

### Backwards compatibility
Existing rows have \`NULL providerExecuted\` and round-trip as the
omitted flag — which is exactly the pre-fix behaviour for tool parts
that were never provider-executed. Only *new* assistant messages using
\`web_search\` (or other provider-hosted tools) will write \`true\`, and
those are the only ones that were breaking.

## Test plan
- [x] \`npx tsgo\` typecheck — server + front clean
- [x] \`oxlint\` + \`prettier --check\` on all touched files — clean
- [x] \`npx nx run twenty-server:database:migrate:prod\` runs the new
instance command locally; \`providerExecuted\` column present on
\`core.agentMessagePart\`
- [x] Regenerated \`generated-metadata/graphql.ts\` —
\`providerExecuted\` wired into both queries and \`AgentMessagePart\`
type
- [ ] Manual: start a chat with Anthropic web_search enabled, invoke the
tool in turn 1, reply in turn 2 — should not throw the srvtoolu error

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:58:26 +02:00
WeikoandGitHub 5f604a503a backfill widget position from gridPosition (phase 1 of gridPosition removal) (#20032)
## Context
Phase 1 of removing the legacy `gridPosition` field from
`PageLayoutWidget` in favor of the new `position` discriminated union
(`grid` / `vertical-list` / `canvas`). This PR is purely additive —
`gridPosition` is still required and read everywhere; we just guarantee
that every widget now also has a non-null `position` so a follow-up PR
can drop `gridPosition` cleanly.

## Changes
- **Slow instance command**
`BackfillPageLayoutWidgetPositionSlowInstanceCommand` (2.1.0):
for every `core.pageLayoutWidget` row where `position IS NULL`, copies
`gridPosition`
into `position` with `layoutMode: 'GRID'`. Historically only grid
widgets used
`gridPosition`, so a single SQL update covers every existing row.
- **`PageLayoutDuplicationService`**: when duplicating a widget, also
forwards
`originalWidget.position` (was previously only forwarding
`gridPosition`).
- **Frontend default layouts** (10 `Default*PageLayout.ts` files): added
a `position`
sibling to every widget, matching the parent tab's `layoutMode` —
`VERTICAL_LIST` widgets
get `{ layoutMode, index }`, `CANVAS` widgets get `{ layoutMode }`

<img width="338" height="226" alt="Screenshot 2026-04-24 at 16 23 17"
src="https://github.com/user-attachments/assets/c3319318-f1b8-4271-96b4-196b209a1f5e"
/>
2026-04-24 14:35:56 +00:00
Paul RastoinandGitHub 9771725cc4 Dockerfile twenty-server target (#20028)
# Introduction
Creating a target funnel that allow bypassing front build and injection
in the server

## New targets
- `twenty-server` only ships server
- `twenty` ships both front and back in the same image
- `twenty-server-aws` only ships server and `aws-cli`
- `twenty-aws` ships both front and back in the same image with the
`aws-image`
2026-04-24 14:24:32 +00:00
WeikoandGitHub 2a3c759928 Removing community apps in favor of npm-distributed apps (#20029)
## Context
With the registry pattern around `Twenty Applications` and the upcoming
marketplace, app
distribution can be decoupled from the core repo. Pulling apps from npm
instead of
shipping them in-tree means:

- Contributors don't clone / index / build code for apps they'll never
touch
- Each app can version, release, and iterate independently of the core
cadence
- We stop carrying apps that are effectively unmaintained in the
monorepo

## How
This PR removes the 11 community apps from
`packages/twenty-apps/community/`

## Note
- **Marketplace apps** must be published on npm and pulled from there —
no app code living in this repo just to be distributed
- **Showcase / example apps** stay as living examples of what the SDK
can do under `example/`
- **Officially-maintained marketplace apps** (like the new
`github-connector`?) stay in the repo under `internal/`. Some may be
pre-installed on new workspaces alongside the Standard app
2026-04-24 13:50:08 +00:00
a36c67a500 i18n - translations (#20027)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-24 12:33:40 +02:00
nitinandGitHub 097432d3a2 [Command Menu] Refactor layout customization conditional availability [Warning] (#19974)
closes
https://discord.com/channels/1130383047699738754/1494312529286004837
2026-04-24 10:18:02 +00:00
WeikoandGitHub 3deb467845 Fix layout edition mode dark mode text color - 2 (#20024)
Followup https://github.com/twentyhq/twenty/pull/19992

Added button { --tw-button-color: ${GRAY_SCALE_LIGHT.gray1}; } to
StyledContainer,
mirroring the existing color override. This forces the loader's color
(which reads
var(--tw-button-color) in Loader.tsx) to stay white on the blue bar
regardless of theme —
in dark mode, the button's inline --tw-button-color previously resolved
to a dark
font.color.inverted, making the spinner nearly invisible.

## Before
<img width="137" height="37" alt="Screenshot 2026-04-24 at 11 01 51"
src="https://github.com/user-attachments/assets/a870f1a3-83bd-4094-bc78-f9416fce1e0e"
/>

## After
<img width="139" height="31" alt="Screenshot 2026-04-24 at 11 00 14"
src="https://github.com/user-attachments/assets/1bff4411-aee2-4ad4-bae3-8118fa117368"
/>
2026-04-24 09:44:16 +00:00
WeikoandGitHub 9a9daf77ca Keep fallback record page layouts read-only in edition mode (#20023)
When a record has no associated `pageLayoutId`, the FE falls back to a
hardcoded default page layout (`DEFAULT_*_RECORD_PAGE_LAYOUT`). These
mocks use non-UUID ids for the layout, tabs, and widgets
(e.g. `default-person-page-layout`).

Today nothing distinguishes these fallbacks from real layouts at edit
time. Clicking "Edit layout" flips the global customization mode,
`PageLayoutRecordPageCustomizationSessionRegistrationEffect` registers
the mock id, and on Save `useSaveLayoutCustomization` fires
`UpdatePageLayoutWithTabsAndWidgets` with those mock ids — the BE
rejects them and we end up in a partial-save state (nav / command menu
commit while the page layout fails).

This PR keeps fallback record page layouts in read mode even when
global layout-edition mode is on, and defends the save loop so mock
ids can never be sent to the BE.

## Non editable "base" record page layout (=mock)
<img width="1508" height="616" alt="Screenshot 2026-04-24 at 10 35 02"
src="https://github.com/user-attachments/assets/34b093d1-f4bb-4076-ab8c-ce97ecb945a4"
/>

## Editable record page layout
<img width="1512" height="597" alt="Screenshot 2026-04-24 at 10 35 20"
src="https://github.com/user-attachments/assets/5ff6f3ff-79a5-4e6f-aa1c-01c7e09273b3"
/>
2026-04-24 08:54:37 +00:00
e8f58fd23a chore: sync AI model catalog from models.dev (#20018)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.

**Please review before merging** — verify no critical models were
incorrectly deprecated.

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-04-24 08:34:13 +02:00
d51d982a5f Fix: Database query on opportunity table (#20017)
## Automated fix for [bug
30463](https://sonarly.com/issue/30463?type=bug)

**Severity:** `critical`

### Summary
When createMany is called with upsert:true and records lack pre-assigned
IDs, findExistingRecords() executes a SELECT with no WHERE clause,
scanning the entire table. This caused an 11-second transaction on the
opportunity table (2.9s query + 7.7s JS processing).

### Root Cause
1. WHY was the transaction 11 seconds? Because a SELECT on the
opportunity table took 2.9s and its result processing took 7.7s.
2. WHY did the SELECT take 2.9s? Because it was a full table scan with
NO WHERE clause, fetching every record (including soft-deleted).
3. WHY was there no WHERE clause? Because findExistingRecords() in
common-create-many-query-runner.service.ts calls buildWhereConditions()
which returned an empty array, so no .orWhere() was applied to the query
builder before .getMany() was called.
4. WHY did buildWhereConditions return empty? Because the input records
had no pre-assigned IDs and the opportunity object has no other unique
fields, so there were no conflicting field values to build conditions
from.
5. WHY wasn't the empty-conditions case guarded? The
findExistingRecords() method was written without an early-return check
for empty whereConditions — it always executes the query regardless.

**Introduced by:** etiennejouan on 2025-10-15 in commit
[`4ae2999`](https://github.com/twentyhq/twenty/commit/4ae299973bcea3470252c4c68540d33a4df59cc7)
> Common Api - createOne/Many (#15083)

### Suggested Fix
Added an early return in findExistingRecords() when
buildWhereConditions() returns an empty array. When no WHERE conditions
exist (because input records lack values for any unique/conflicting
field), the method now returns an empty array immediately instead of
executing a SELECT with no WHERE clause that scans the entire table.
This prevents the O(n) full table scan + O(n) JS result processing that
caused the 11-second transaction. The downstream categorizeRecords()
correctly handles empty existingRecords by classifying all input records
as recordsToInsert.

---
*Generated by [Sonarly](https://sonarly.com)*

Co-authored-by: Sonarly Claude Code <claude-code@sonarly.com>
2026-04-24 06:18:57 +00:00
dbc350fafe i18n - translations (#20016)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-23 22:18:46 +02:00
085c0b9b7f feat(admin-panel): add read-only Billing tab and workspace logos (#20012)
## Summary
- Adds a **Billing** tab on the admin-panel workspace detail page that
surfaces Stripe customer + active subscription details (status, plan,
interval, current period, trial, cancellation, line items, credit
balance). Tab is gated on `IS_BILLING_ENABLED` both in the backend
service and in the frontend tab list — completely hidden on instances
where billing is disabled.
- Renders a **workspace avatar next to the name** in the admin Top
Workspaces list by plumbing the workspace `logo` field through the admin
DTO, statistics SQL query, and generated admin GraphQL types.
- **Read-only** by design: no Stripe API calls, no mutations — data
comes from the existing \`BillingCustomerEntity\` /
\`BillingSubscriptionEntity\` / \`BillingPriceEntity\` tables via
\`BillingSubscriptionService.getCurrentBillingSubscription\`.

### What the tab shows
- **Customer** container — Stripe customer ID (with link to the Stripe
dashboard, monospaced), credit balance (formatted, from
\`creditBalanceMicro\`).
- **Subscription** container — status tag (color-coded), plan tag,
billing interval, current period range, trial range (if trialing),
\`cancelAtPeriodEnd\` / \`cancelAt\` / \`canceledAt\` (only when set),
Stripe subscription ID (external link).
- **Line items** — one card per subscription item with product name,
product key tag, seats (if quantity), credits per period (for metered),
unit price (formatted with currency).

### Design choices
- Styling matches the user-facing billing page
(\`SubscriptionInfoContainer\` + \`Tag\` + \`H2Title\` + \`Section\`) —
no new UI primitives.
- Currency is rendered inline with amounts via \`Intl.NumberFormat\`
(e.g. \`\$19.00\`) instead of as a separate row.
- Uses the generated admin GraphQL types
(\`WorkspaceBillingAdminPanelQuery\`, \`SubscriptionStatus\`,
\`SubscriptionInterval\`) — no hand-typed response shapes.

## Test plan
- [x] \`npx nx typecheck twenty-server\` — passes
- [x] \`npx nx typecheck twenty-front\` — passes
- [x] oxlint + prettier on all touched files — clean
- [x] \`graphql:generate --configuration=admin\` — regenerated; new
\`workspaceBillingAdminPanel\` query + \`logo\` field on
\`AdminPanelTopWorkspace\` appear in \`generated-admin/graphql.ts\`
- [x] Backend GraphQL schema introspection shows
\`workspaceBillingAdminPanel\` query on \`/admin-panel\`
- [x] Direct GraphQL call with seeded \`BillingCustomer\` +
\`BillingSubscription\` + \`BillingPrice\` rows returns the expected
shape (\`status: "Trialing"\`, plan \`PRO\`, items with
quantity/unitAmount/includedCredits, trial period dates)
- [x] With \`IS_BILLING_ENABLED=false\` (default) the Billing tab is
hidden — verified in the admin panel UI
- [x] Top Workspaces list renders workspace avatars next to names —
verified in the admin panel UI
- [ ] Smoke test the Billing tab render in a real instance that has
\`IS_BILLING_ENABLED=true\` + live Stripe data (skipped locally due to
dev-env auth friction after toggling billing/multi-workspace; recommend
a reviewer check)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:12:05 +02:00
9ce9e2bc12 i18n - translations (#20015)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-23 22:05:44 +02:00
4f938aa097 feat(app): infrastructure for pre-installed apps (#19973)
**PR 1 of 2.** Follow-up PR ships the Exa app, sets it as a default
pre-installed app, and removes the current `WebSearchTool` /
`WebSearchService` / `ExaDriver`. This PR adds the plumbing; no
user-visible change yet.

## Summary

- Server admins can declare a list of npm app packages to auto-install
on every new workspace and backfill onto existing workspaces via CLI.
- Server-level secrets (like Exa's API key) live on the
`ApplicationRegistration` (one row per server, encrypted) and are
injected into logic function execution env at runtime. No more
per-workspace storage of global secrets.
- A generic `POST /app/billing/charge` endpoint lets app logic functions
emit workspace usage events for metered features. Exa uses it in PR 2;
future apps (call recorder, etc.) reuse it.
- `LogicFunctionToolProvider` tool name prefix changes `logic_function_`
→ `app_`. Shorter, accurate (they come from installed apps).

## What's in this PR

**Logic function executor — server-level variables**
- `LogicFunctionExecutorService.getExecutionEnvVariables` now resolves
env vars in the order: hardcoded defaults →
`ApplicationRegistrationVariable[]` (server-level) →
`ApplicationVariable[]` (workspace-level override). The manifest
`serverVariables` schema has existed; this closes the loop.

**Config**
- `PRE_INSTALLED_APPS` — comma-separated list of npm packages. Default:
empty.

**\`PreInstalledAppsService\`** (new module)
- \`onApplicationBootstrap()\` — fetches each package's manifest from
the app registry CDN, upserts an \`ApplicationRegistration\`, and seeds
declared \`serverVariables\` from matching env vars (e.g.
\`EXA_API_KEY\` env → encrypted registration variable).
- \`installOnWorkspace(workspaceId)\` — installs all pre-installed apps
on a single workspace. Tolerates per-app failures.

**Auto-install on new workspace activation**
- \`WorkspaceService.prefillCreatedWorkspaceRecords\` invokes
\`installOnWorkspace\` after prefilling standard records. Non-blocking
on failure.

**Backfill CLI command**
- \`install-pre-installed-apps\` — iterates active and suspended
workspaces, installs pre-installed apps that aren't yet installed.
Idempotent. Run after changing \`PRE_INSTALLED_APPS\`.

**App billing endpoint**
- \`POST /app/billing/charge\`. Authenticated via \`APPLICATION_ACCESS\`
token (already injected into logic function execution env as
\`DEFAULT_APP_ACCESS_TOKEN\`). Body: \`{ creditsUsedMicro, quantity,
unit, operationType, resourceContext? }\`. Emits \`USAGE_RECORDED\` with
\`applicationId\` as \`resourceId\`. Generic — reusable by any app.

**Tool name prefix**
- \`LogicFunctionToolProvider.buildLogicFunctionToolName\` now produces
\`app_<name>\` instead of \`logic_function_<name>\`. Only affects tools
sourced from logic functions; other tool providers unchanged.

## Stats

- 16 files, +501 / −2
- 7 new files (1 command, 1 service × 2, 1 controller, 1 DTO, 2 modules)
- Typecheck: 7 pre-existing errors, zero new
- Prettier clean

## Behavior deltas

- **\`PRE_INSTALLED_APPS\` default = empty**: existing servers see no
change on merge.
- **\`ApplicationRegistrationVariable\` is now read by the executor**:
apps that were using manifest \`serverVariables\` but expecting them to
be ignored by the executor will now see them injected. No apps ship with
\`isTool: true\` logic functions today, so this is latent — first
consumer is Exa in PR 2.
- **Tool prefix**: currently no logic-function tools are named
\`logic_function_*\` in any production flow. The prefix change affects
only future tools emitted by \`LogicFunctionToolProvider\`.

## Risks

- **CDN unavailability at startup**: if the app registry CDN is down,
\`ensureRegistrationsExist\` logs warnings but doesn't block server
start. Installation on new workspaces during this window will find no
registrations and log a non-blocking error. Backfill command can retry
after CDN recovers.
- **Cold-start overhead**: \`ensureRegistrationsExist\` is called once
per process on bootstrap. Current configurable default is empty, so zero
overhead. When an admin sets \`PRE_INSTALLED_APPS\`, they accept one
HTTP call per package at boot.
- **Server-level variables flow**:
\`ApplicationRegistrationVariable.encryptedValue\` is shared by all
workspaces of a server. Appropriate for a single-tenant Exa key. Not
appropriate for per-tenant keys — those go in workspace-level
\`ApplicationVariable\` and override.

## Test plan

- [ ] \`npx nx typecheck twenty-server\` passes (verified: 7
pre-existing unrelated errors, zero new)
- [ ] Set \`PRE_INSTALLED_APPS=@twenty-apps/hello-world\` (or any real
npm-published app), \`HELLO_WORLD_API_KEY=xxx\`, restart server:
\`ApplicationRegistration\` row is upserted,
\`ApplicationRegistrationVariable\` for HELLO_WORLD_API_KEY is populated
(encrypted).
- [ ] Create a new workspace: the app is auto-installed,
\`ApplicationEntity\` row created, \`LogicFunctionEntity\` rows created.
- [ ] Existing workspace: run \`yarn nx run twenty-server:command
install-pre-installed-apps\`: apps install across all workspaces,
idempotent on re-run.
- [ ] Trigger a logic function that reads
\`process.env.HELLO_WORLD_API_KEY\`: value resolves from the
server-level \`ApplicationRegistrationVariable\`.
- [ ] Log a charge from the handler: \`POST /app/billing/charge\` with
\`Authorization: Bearer \$DEFAULT_APP_ACCESS_TOKEN\` body
\`{creditsUsedMicro: 1000, quantity: 1, unit: "INVOCATION",
operationType: "WEB_SEARCH"}\` → returns \`{success: true}\`,
\`USAGE_RECORDED\` event emitted with correct
\`resourceId=applicationId\`.
- [ ] Tool name generated by \`LogicFunctionToolProvider\` starts with
\`app_\`.

## What's NOT in this PR (PR 2 scope)

- The Exa app itself (\`packages/twenty-apps/...\` directory)
- Removing \`WebSearchTool\`, \`WebSearchService\`, \`ExaDriver\`,
\`web-search\` module
- Removing \`WEB_SEARCH_DRIVER\` config var
- Removing the current \`exa_web_search\` entry in
\`ActionToolProvider\`
- Chat preload list updated to \`app_exa_web_search\`
- Frontend \`getToolDisplayMessage\` branch for \`app_exa_web_search\`
- Setting \`PRE_INSTALLED_APPS\` default to include \`@twenty-apps/exa\`

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 21:59:00 +02:00
ec98130def fix(admin-panel): inline skeleton loaders for table sections (#20014)
## Summary
\`SettingsSkeletonLoader\` wraps its content in \`PageHeader\` +
\`PageBody\`, which is right for **full-page replacement** (user detail,
config variable detail, etc.) but renders as a large empty stub with one
tiny floating bar when placed **inline inside a section that's already
scaffolded**. That's what showed up in Recent Users, Top Workspaces, and
the Chats tab in the admin panel — a jarring white page flash where a
few row placeholders should be.

This PR adds \`SettingsAdminSectionSkeletonLoader\` — a small,
configurable-row-count skeleton that uses the project's standard
\`SkeletonTheme\` pattern (\`theme.background.tertiary\` /
\`theme.background.transparent.lighter\` + \`borderRadius: 4\`, matching
\`PageContentSkeletonLoader\` and \`SettingsAdminTabSkeletonLoader\`)
and renders row-height bars that match \`TableRow\` spacing. Swaps it
into the three inline call sites.

Full-page usages of \`SettingsSkeletonLoader\` are unchanged.

### Changed
- **New**:
\`packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminSectionSkeletonLoader.tsx\`
- **Swapped** (3 inline loading states): \`SettingsAdminGeneral.tsx\`
(Recent Users + Top Workspaces), \`SettingsAdminWorkspaceDetail.tsx\`
(Chats tab)

## Test plan
- [x] typecheck (\`npx nx typecheck twenty-front --skip-nx-cache\`) —
clean
- [x] oxlint — 0 warnings
- [x] prettier — clean
- [ ] Visual: navigate to admin panel, observe Recent Users / Top
Workspaces / Chats tab loading — should see a tight stack of row-height
placeholder bars instead of a big empty page stub

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 21:58:33 +02:00
Paul RastoinandGitHub b1838b3090 Retrieve ai catalog at bootstrap (#20005)
# Introduction
Migrating from build injection to a runtime bootstrap injection of the
ai catalog that are dynamic to the env we're deploying to
2026-04-23 17:00:35 +00:00
Thomas TrompetteandGitHub fa3d0cd4a6 Force uuids in AI workflow tools (#20010)
- Add .uuid() Zod validation to all AI workflow tool schemas
(workflowVersionId, workflowId, stepId, edge target) so the AI model is
constrained to use valid UUIDs instead of human-readable strings like
step-find-stale-leads
- Fixes Sentry noise from Invalid UUID errors triggered when workflows
created with non-UUID step IDs are later edited via GraphQL mutations
that enforce UUID scalars

Will fix
https://twenty-v7.sentry.io/issues/7240446584/events/05c7782655a34f4a8f5fc889164026ab/?project=4507072499810304&referrer=previous-event
2026-04-23 16:27:53 +00:00
652930e0ac i18n - docs translations (#20009)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-23 17:07:23 +02:00
Thomas TrompetteandGitHub d887fdc532 Stop throwing for event stream does not exists (#20008)
Fixes
https://twenty-v7.sentry.io/issues/7351816489/?environment=prod&environment=prod-eu&project=4507072499810304&query=is%3Aunresolved%20%21issue.type%3A%5Bperformance_consecutive_db_queries%2Cperformance_consecutive_http%2Cperformance_file_io_main_thread%2Cperformance_db_main_thread%2Cperformance_n_plus_one_db_queries%2Cperformance_n_plus_one_api_calls%2Cperformance_p95_endpoint_regression%2Cperformance_slow_db_query%2Cperformance_render_blocking_asset_span%2Cperformance_uncompressed_assets%2Cperformance_http_overhead%2Cperformance_large_http_payload%5D%20timesSeen%3A%3E10&referrer=issue-stream&sort=date

- When an SSE event stream already exists in Redis during a
reconnection, the server now checks if the caller is the rightful owner
(via isAuthorized) and destroys the stale stream before creating a fresh
one, instead of throwing an EVENT_STREAM_ALREADY_EXISTS error
- Unauthorized callers still receive the error, preserving the security
guard against hijacking
2026-04-23 13:22:09 +00:00
992a7ca12f i18n - docs translations (#20007)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-23 14:45:01 +02:00
Charles BochetandGitHub a445f4a6fa feat(sdk): add definePageLayoutTab for extending existing page layouts (#20004)
## Summary

Introduces `definePageLayoutTab` so apps can attach a single tab (with
optional widgets) to an **existing** `pageLayout` referenced by
`pageLayoutUniversalIdentifier`. The parent layout can be standard, from
the same app, or from another app — mirroring how `defineField`
references an object via `objectUniversalIdentifier`.

This complements `definePageLayout`: use `definePageLayout` when you own
the entire layout, use `definePageLayoutTab` when you only want to add
to one.

```ts
import { definePageLayoutTab, PageLayoutTabLayoutMode } from 'twenty-sdk/define';

export default definePageLayoutTab({
  universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
  pageLayoutUniversalIdentifier: 'STANDARD-OR-OTHER-APP-PAGE-LAYOUT-UUID',
  title: 'Hello World',
  position: 1000,
  icon: 'IconWorld',
  layoutMode: PageLayoutTabLayoutMode.CANVAS,
  widgets: [/* ... */],
});
```

## Changes

- **twenty-shared**: new top-level `pageLayoutTabs:
PageLayoutTabManifest[]` on `Manifest`, optional
`pageLayoutUniversalIdentifier` on `PageLayoutTabManifest`, new
`SyncableEntity.PageLayoutTab`.
- **twenty-sdk**:
  - new `definePageLayoutTab` + `PageLayoutTabConfig` exports;
- manifest extraction wiring (`TargetFunction.DefinePageLayoutTab`,
`ManifestEntityKey.PageLayoutTabs`);
  - dev-mode label/state for the new entity;
- CLI scaffold (`getPageLayoutTabBaseFile`) + unit tests for `npx
twenty-cli add`.
- **twenty-server**: convert top-level `pageLayoutTabs` (and their
widgets) into universal flat entities in
`computeApplicationManifestAllUniversalFlatEntityMaps`. Cross-app FK
validation on `pageLayoutUniversalIdentifier` is already handled by the
existing `FlatPageLayoutTab` validator.
- **docs**: new `definePageLayoutTab` accordion in `apps/layout.mdx`
with usage example and guidance vs `definePageLayout`.
- **CI / rich-app fixture**: `extra-tab.page-layout-tab.ts` exercises
the new flow with a front-component widget; `expected-manifest.ts` and
`manifest.tests.ts` updated.
2026-04-23 12:00:10 +00:00
neo773andGitHub 20f7ba82d7 optimize workspace export command (#20000)
- Use `COPY` instead of `INSERT` for workspace tables, 40x faster
imports
- Multi value statements, 2x smaller file size
- Bump Batch size to 10K , remove COUNT query
- Add `formatPgCopyField` utility with unit tests
2026-04-23 09:42:49 +00:00
WeikoandGitHub aa4aea0f9b Fix layout edition mode dark mode text color (#19992)
Summary

Fixed the dark-mode regression in the layout customization banner at
packages/twenty-front/src/modules/layout-customization/components/LayoutCustomizationBar.tsx:

- The banner's background is themeCssVariables.color.blue
(theme-independent), so its text color must be theme-independent too.
- Replaced color: ${themeCssVariables.font.color.inverted} (which
resolves to near-black in dark mode) with color:
${GRAY_SCALE_LIGHT.gray1} (always white), matching the convention used
 in AnimatedButton.tsx for text on colored backgrounds.

## Before
<img width="1508" height="185" alt="Screenshot 2026-04-22 at 19 50 03"
src="https://github.com/user-attachments/assets/b0a95fc2-05d5-4207-9d72-64a83daf94ae"
/>

## After
<img width="1511" height="190" alt="Screenshot 2026-04-22 at 19 49 39"
src="https://github.com/user-attachments/assets/9ce33353-412f-4db2-9322-a6f293f6f1b4"
/>
2026-04-23 08:36:12 +00:00
1375 changed files with 30784 additions and 53589 deletions
-22
View File
@@ -1,22 +0,0 @@
{
"mcpServers": {
"postgres": {
"type": "stdio",
"command": "bash",
"args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""],
"env": {}
},
"playwright": {
"type": "stdio",
"command": "npx",
"args": ["@playwright/mcp@latest", "--no-sandbox", "--headless"],
"env": {}
},
"context7": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"],
"env": {}
}
}
}
-223
View File
@@ -1,223 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Twenty is an open-source CRM built with modern technologies in a monorepo structure. The codebase is organized as an Nx workspace with multiple packages.
## Key Commands
### Development
```bash
# Start development environment (frontend + backend + worker)
yarn start
# Individual package development
npx nx start twenty-front # Start frontend dev server
npx nx start twenty-server # Start backend server
npx nx run twenty-server:worker # Start background worker
```
### Testing
```bash
# Preferred: run a single test file (fast)
npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs
# Run all tests for a package
npx nx test twenty-front # Frontend unit tests
npx nx test twenty-server # Backend unit tests
npx nx run twenty-server:test:integration:with-db-reset # Integration tests with DB reset
# To run an indivual test or a pattern of tests, use the following command:
cd packages/{workspace} && npx jest "pattern or filename"
# Storybook
npx nx storybook:build twenty-front
npx nx storybook:test twenty-front
# When testing the UI end to end, click on "Continue with Email" and use the prefilled credentials.
```
### Code Quality
```bash
# Linting (diff with main - fastest, always prefer this)
npx nx lint:diff-with-main twenty-front
npx nx lint:diff-with-main twenty-server
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix
# Linting (full project - slower, use only when needed)
npx nx lint twenty-front
npx nx lint twenty-server
# Type checking
npx nx typecheck twenty-front
npx nx typecheck twenty-server
# Format code
npx nx fmt twenty-front
npx nx fmt twenty-server
```
### Build
```bash
# Build packages (twenty-shared must be built first)
npx nx build twenty-shared
npx nx build twenty-front
npx nx build twenty-server
```
### Database Operations
```bash
# Database management
npx nx database:reset twenty-server # Reset database
npx nx run twenty-server:database:init:prod # Initialize database
npx nx run twenty-server:database:migrate:prod # Run instance commands (fast only)
# Generate an instance command (fast or slow)
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
```
### Database Inspection (Postgres MCP)
A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
- Inspect workspace data, metadata, and object definitions while developing
- Verify migration results (columns, types, constraints) after running migrations
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
- Inspect metadata tables to debug GraphQL schema generation issues
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
### GraphQL
```bash
# Generate GraphQL types (run after schema changes)
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
```
## Architecture Overview
### Tech Stack
- **Frontend**: React 18, TypeScript, Jotai (state management), Linaria (styling), Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL (with GraphQL Yoga)
- **Monorepo**: Nx workspace managed with Yarn 4
### Package Structure
```
packages/
├── twenty-front/ # React frontend application
├── twenty-server/ # NestJS backend API
├── twenty-ui/ # Shared UI components library
├── twenty-shared/ # Common types and utilities
├── twenty-emails/ # Email templates with React Email
├── twenty-website/ # Next.js documentation website
├── twenty-zapier/ # Zapier integration
└── twenty-e2e-testing/ # Playwright E2E tests
```
### Key Development Principles
- **Functional components only** (no class components)
- **Named exports only** (no default exports)
- **Types over interfaces** (except when extending third-party interfaces)
- **String literals over enums** (except for GraphQL enums)
- **No 'any' type allowed** — strict TypeScript enforced
- **Event handlers preferred over useEffect** for state updates
- **Props down, events up** — unidirectional data flow
- **Composition over inheritance**
- **No abbreviations** in variable names (`user` not `u`, `fieldMetadata` not `fm`)
### Naming Conventions
- **Variables/functions**: camelCase
- **Constants**: SCREAMING_SNAKE_CASE
- **Types/Classes**: PascalCase (suffix component props with `Props`, e.g. `ButtonProps`)
- **Files/directories**: kebab-case with descriptive suffixes (`.component.tsx`, `.service.ts`, `.entity.ts`, `.dto.ts`, `.module.ts`)
- **TypeScript generics**: descriptive names (`TData` not `T`)
### File Structure
- Components under 300 lines, services under 500 lines
- Components in their own directories with tests and stories
- Use `index.ts` barrel exports for clean imports
- Import order: external libraries first, then internal (`@/`), then relative
### Comments
- Use short-form comments (`//`), not JSDoc blocks
- Explain WHY (business logic), not WHAT
- Do not comment obvious code
- Multi-line comments use multiple `//` lines, not `/** */`
### State Management
- **Jotai** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
- Component-specific state with React hooks (`useState`, `useReducer` for complex logic)
- GraphQL cache managed by Apollo Client
- Use functional state updates: `setState(prev => prev + 1)`
### Backend Architecture
- **NestJS modules** for feature organization
- **TypeORM** for database ORM with PostgreSQL
- **GraphQL** API with code-first approach
- **Redis** for caching and session management
- **BullMQ** for background job processing
### Database & Upgrade Commands
- **PostgreSQL** as primary database
- **Redis** for caching and sessions
- **ClickHouse** for analytics (when enabled)
- When changing entity files, generate an **instance command** (`database:migrate:generate --name <name> --type <fast|slow>`)
- **Fast** instance commands handle schema changes; **slow** ones add a `runDataMigration` step for data backfills
- **Workspace commands** iterate over all active/suspended workspaces for per-workspace upgrades
- Commands use `@RegisteredInstanceCommand` and `@RegisteredWorkspaceCommand` decorators for automatic discovery
- Include both `up` and `down` logic in instance commands
- Never delete or rewrite committed instance command `up`/`down` logic
- See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for full documentation
### Utility Helpers
Use existing helpers from `twenty-shared` instead of manual type guards:
- `isDefined()`, `isNonEmptyString()`, `isNonEmptyArray()`
## Development Workflow
IMPORTANT: Use Context7 for code generation, setup or configuration steps, or library/API documentation. Automatically use the Context7 MCP tools to resolve library IDs and get library docs without waiting for explicit requests.
### Before Making Changes
1. Always run linting (`lint:diff-with-main`) and type checking after code changes
2. Test changes with relevant test suites (prefer single-file test runs)
3. Ensure instance commands are generated for entity changes (`database:migrate:generate`)
4. Check that GraphQL schema changes are backward compatible
5. Run `graphql:generate` after any GraphQL schema changes
### Code Style Notes
- Use **Linaria** for styling with zero-runtime CSS-in-JS (styled-components pattern)
- Follow **Nx** workspace conventions for imports
- Use **Lingui** for internationalization
- Apply security first, then formatting (sanitize before format)
### Testing Strategy
- **Test behavior, not implementation** — focus on user perspective
- **Test pyramid**: 70% unit, 20% integration, 10% E2E
- Query by user-visible elements (text, roles, labels) over test IDs
- Use `@testing-library/user-event` for realistic interactions
- Descriptive test names: "should [behavior] when [condition]"
- Clear mocks between tests with `jest.clearAllMocks()`
## Dev Environment Setup
All dev environments (Claude Code web, Cursor, local) use one script:
```bash
bash packages/twenty-utils/setup-dev-env.sh
```
This handles everything: starts Postgres + Redis (auto-detects local services vs Docker), creates databases, and copies `.env` files. Idempotent — safe to run multiple times.
- `--docker` — force Docker mode (uses `packages/twenty-docker/docker-compose.dev.yml`)
- `--down` — stop services
- `--reset` — wipe data and restart fresh
- **Skip the setup script** for tasks that only read code — architecture questions, code review, documentation, etc.
**Note:** CI workflows (GitHub Actions) manage services via Actions service containers and run setup steps individually — they don't use this script.
## Important Files
- `nx.json` - Nx workspace configuration with task definitions
- `tsconfig.base.json` - Base TypeScript configuration
- `package.json` - Root package with workspace definitions
- `.cursor/rules/` - Detailed development guidelines and best practices
+78
View File
@@ -78,6 +78,83 @@ jobs:
tag: scope:backend
tasks: lint,typecheck
server-previous-version-upgrade-mutation-guard:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Get changed upgrade-version-command files
id: changed-files
uses: tj-actions/changed-files@v45
with:
files: |
packages/twenty-server/src/database/commands/upgrade-version-command/**
- name: Check upgrade version commands are in current version only
if: >
steps.changed-files.outputs.any_changed == 'true' &&
!contains(github.event.pull_request.labels.*.name, 'ci:allow-previous-version-upgrade-mutation')
run: |
VERSION_CONSTANT_FILE="packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-current-version.constant.ts"
CURRENT_VERSION=$(sed -n "s/.*TWENTY_CURRENT_VERSION = '\([0-9.]*\)'.*/\1/p" "$VERSION_CONSTANT_FILE")
if [ -z "$CURRENT_VERSION" ]; then
echo "::error::Could not extract TWENTY_CURRENT_VERSION from $VERSION_CONSTANT_FILE"
exit 1
fi
CURRENT_DIR=$(echo "$CURRENT_VERSION" | sed -E 's/^([0-9]+)\.([0-9]+)\..*/\1-\2/')
echo "Current version: $CURRENT_VERSION (directory: $CURRENT_DIR)"
ADDED_OFFENDERS=""
MODIFIED_OFFENDERS=""
check_files() {
local category="$1"
shift
for file in "$@"; do
VERSION_DIR=$(echo "$file" | sed -n 's|.*upgrade-version-command/\([0-9]*-[0-9]*\)/.*|\1|p')
if [ -n "$VERSION_DIR" ] && [ "$VERSION_DIR" != "$CURRENT_DIR" ]; then
if [ "$category" = "added" ]; then
ADDED_OFFENDERS="$ADDED_OFFENDERS\n - $file (version directory: $VERSION_DIR)"
else
MODIFIED_OFFENDERS="$MODIFIED_OFFENDERS\n - $file (version directory: $VERSION_DIR)"
fi
fi
done
}
check_files "added" ${{ steps.changed-files.outputs.added_files }}
check_files "modified" ${{ steps.changed-files.outputs.modified_files }}
if [ -n "$ADDED_OFFENDERS" ] || [ -n "$MODIFIED_OFFENDERS" ]; then
echo "This PR touches upgrade command files outside the current version directory ($CURRENT_DIR / $CURRENT_VERSION)."
if [ -n "$ADDED_OFFENDERS" ]; then
echo ""
echo "New files added to non-current version directories:"
echo -e "$ADDED_OFFENDERS"
fi
if [ -n "$MODIFIED_OFFENDERS" ]; then
echo ""
echo "Existing files modified in non-current version directories:"
echo -e "$MODIFIED_OFFENDERS"
fi
echo ""
echo "If this is intentional, add the label 'ci:allow-previous-version-upgrade-mutation' to this PR and re-run CI."
echo "Otherwise, please move your changes to the current version directory ($CURRENT_DIR)."
echo "::error::Upgrade commands were added or modified in non-current version directories."
exit 1
fi
server-validation:
needs: server-build
timeout-minutes: 30
@@ -311,6 +388,7 @@ jobs:
changed-files-check,
server-build,
server-lint-typecheck,
server-previous-version-upgrade-mutation-guard,
server-validation,
server-test,
server-integration-test,
+2
View File
@@ -58,6 +58,7 @@ jobs:
npx nx run twenty-server:lingui:compile --strict
npx nx run twenty-emails:lingui:compile --strict
npx nx run twenty-front:lingui:compile --strict
npx nx run twenty-website-new:lingui:compile --strict
continue-on-error: true
- name: Stash any changes before pulling translations
@@ -115,6 +116,7 @@ jobs:
npx nx run twenty-server:lingui:compile
npx nx run twenty-emails:lingui:compile
npx nx run twenty-front:lingui:compile
npx nx run twenty-website-new:lingui:compile
git status
git add .
if ! git diff --staged --quiet --exit-code; then
+2
View File
@@ -41,6 +41,7 @@ jobs:
npx nx run twenty-server:lingui:extract
npx nx run twenty-emails:lingui:extract
npx nx run twenty-front:lingui:extract
npx nx run twenty-website-new:lingui:extract
- name: Check and commit extracted files
id: check_extract_changes
@@ -60,6 +61,7 @@ jobs:
npx nx run twenty-server:lingui:compile
npx nx run twenty-emails:lingui:compile
npx nx run twenty-front:lingui:compile
npx nx run twenty-website-new:lingui:compile
- name: Check and commit compiled files
id: check_compile_changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.0.0",
"version": "2.1.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -15,5 +15,23 @@
}
],
"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."
}
]
}
]
}
}
]
}
@@ -1,6 +0,0 @@
TWENTY_API_KEY=<SET_YOUR_TWENTY_API>
DAYS_AGO=<SET_YOUR_DAYS_AGO>
DISCORD_WEBHOOK_URL=<SET_YOUR_DISCORD_WEBHOOK_URL>
FB_GRAPH_TOKEN=<SET_YOUR_FB_GRAPH_TOKEN>
WHATSAPP_RECIPIENT_PHONE_NUMBER=<SET_YOUR_WHATSAPP_RECIPIENT_PHONE_NUMBER>
SLACK_HOOK_URL=<SET_YOUR_SLACK_HOOK_URL>
@@ -1,2 +0,0 @@
.yarn/install-state.gz
.env
@@ -1,108 +0,0 @@
# Twenty CRM Activity Reporter
A TypeScript-based reporting bot that summarizes activity from your Twenty CRM workspace and sends daily/periodic reports to Slack, Discord, and WhatsApp. Meet Kylian Mbaguette, your friendly CRM activity reporter!
## Features
- 🧑‍💻 **People & Company Tracking**: Summarizes newly created people and companies
- 🎯 **Opportunity Monitoring**: Reports on new opportunities created, broken down by stage
-**Task Analytics**:
- Tracks task creation
- Calculates on-time completion rates
- Identifies team members with the most overdue tasks (the "slackers")
- 🔔 **Multi-Platform Notifications**: Send reports to Slack, Discord, and/or WhatsApp
-**Configurable Time Range**: Look back any number of days
## Prerequisites
- Node.js (v14 or higher recommended)
- TypeScript
- A [Twenty CRM](https://twenty.com) account with API access
- Optional: Slack webhook, Discord webhook, and/or WhatsApp Business API access
## Installing dependencies
```bash
# Install dependencies
yarn install
```
### Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `TWENTY_API_KEY` | ✅ Yes | Your Twenty CRM API key |
| `DAYS_AGO` | ✅ Yes | Number of days to look back for the report |
| `SLACK_HOOK_URL` | ❌ No | Slack incoming webhook URL |
| `DISCORD_WEBHOOK_URL` | ❌ No | Discord webhook URL |
| `FB_GRAPH_TOKEN` | ❌ No | Facebook Graph API token for WhatsApp |
| `WHATSAPP_RECIPIENT_PHONE_NUMBER` | ❌ No | WhatsApp recipient phone number (with country code) |
## Project Structure
```
.
├── index.ts # Main entry point
├── people-creation-summariser.ts # Summarizes people/company creation
├── opportunity-creation-summariser.ts # Summarizes opportunity creation
├── task-creation-summariser.ts # Summarizes task creation & completion
├── senders.ts # Handles sending to Slack/Discord/WhatsApp
├── utils.ts # API request utility
└── README.md
```
## How It Works
1. **Data Collection**: The bot queries the Twenty CRM API for activities within the specified time range
2. **Analysis**:
- Counts new people and companies
- Categorizes opportunities by stage
- Calculates task completion rates and identifies overdue tasks
3. **Reporting**: Formats the data into friendly messages
4. **Distribution**: Sends reports to configured platforms (Slack, Discord, WhatsApp)
## Report Format
Each report includes:
```
Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last X days:
🧑‍💻 People & Companies
- X People and Y Companies were added
🎯 Opportunities
- X Opportunities were added: Y in NEW, Z in PROPOSAL
📋 Tasks
- X Tasks were created
- Y% Tasks were completed on time
- [Name] slacked the most with Z Tasks overdue
```
## API Integration
This bot uses the [Twenty CRM REST API](https://api.twenty.com/rest/). The following endpoints are used:
- `GET /people` - Fetch people data
- `GET /opportunities` - Fetch opportunity data
- `GET /tasks` - Fetch task data
- `GET /workspaceMembers/{id}` - Fetch workspace member details
## Notes
- The "slacker" detection is lighthearted and identifies team members with the most overdue tasks
- At least one messaging platform must be configured for the bot to send reports
- The bot uses ISO date format (YYYY-MM-DD) for date filtering
- Task completion percentage only considers incomplete tasks (excludes already completed tasks from the calculation)
## Troubleshooting
**Issue**: No messages being sent
- **Solution**: Ensure at least one messaging platform is configured with valid credentials
**Issue**: API authentication errors
- **Solution**: Verify your `TWENTY_API_KEY` is correct and has necessary permissions
**Issue**: WhatsApp messages not sending
- **Solution**: Ensure both `FB_GRAPH_TOKEN` and `WHATSAPP_RECIPIENT_PHONE_NUMBER` are set correctly
## Contributing
Built with ❤️ and 🥖 by Azmat, Ali and Mike from 9dots
@@ -1,45 +0,0 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: 'b53627f5-ca60-478c-bc43-c7ab4904e34a',
displayName: 'Activity Summary',
description:
'A TypeScript-based reporting bot that summarizes activity from your Twenty CRM workspace and sends daily/periodic reports to Slack, Discord, and WhatsApp. Meet Kylian Mbaguette, your friendly CRM activity reporter!',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: '304b7d5d-e2bb-4444-9b04-6b3ae8b73730',
description: 'Twenty API Key',
isSecret: true,
},
DAYS_AGO: {
universalIdentifier: '040a3097-9cee-4f74-b957-c2f9bf636c3f',
description:
'How far back into the past we want to summarise defaults to the past 7 days',
value: '7',
isSecret: false,
},
SLACK_HOOK_URL: {
universalIdentifier: 'fd16e370-934c-4267-83b4-7d88259bf7e1',
description: 'Slack hook URL for sending message to channel',
isSecret: true,
},
DISCORD_WEBHOOK_URL: {
universalIdentifier: 'f3741075-d525-4988-ba42-55d519c6fd76',
description:
'Discord webhook URL for sending message to channel of a server',
isSecret: true,
},
FB_GRAPH_TOKEN: {
universalIdentifier: 'fb907f49-74ac-4aa5-ba45-cfc9250ecc44',
description: 'For Facebook auth',
isSecret: true,
},
WHATSAPP_RECIPIENT_PHONE_NUMBER: {
universalIdentifier: 'c856ee5d-44bf-42f4-9a39-2553a94af518',
description: 'Phone number for receiving WhatsApp message',
isSecret: true,
},
},
};
export default config;
@@ -1,17 +0,0 @@
{
"name": "activity-summary",
"version": "0.0.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"dependencies": {
"twenty-sdk": "0.0.3"
},
"devDependencies": {
"@types/node": "^24.7.2"
}
}
@@ -1,80 +0,0 @@
import { summariseOpportunityCreation } from './opportunity-creation-summariser';
import { summarisePeopleCreation } from './people-creation-summariser';
import { sendToDiscord, sendToSlack, sendToWhatsApp } from './senders';
import { summariseTaskCreation } from './task-creation-summariser';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
export const main = async (): Promise<object> => {
let date: string | Date = new Date();
date.setDate(new Date().getDate() - Number(process.env.DAYS_AGO));
date = date.toISOString().substring(0, 10);
const peopleCreationSummary = await summarisePeopleCreation(date);
const opportunityCreationSummary = await summariseOpportunityCreation(date);
const taskCreationSummary = await summariseTaskCreation(date);
let body = {
daysAgo: Number(process.env.DAYS_AGO),
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
discord: {},
whatsapp: {},
slack: {},
};
if (process.env.SLACK_HOOK_URL) {
const slackBody = await sendToSlack({
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
});
body = {
...body,
slack: slackBody,
};
}
if (process.env.DISCORD_WEBHOOK_URL) {
const discordBody = await sendToDiscord({
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
});
body = {
...body,
discord: discordBody,
};
}
if (
process.env.FB_GRAPH_TOKEN &&
process.env.WHATSAPP_RECIPIENT_PHONE_NUMBER
) {
const whatsappBody = await sendToWhatsApp({
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
});
body = {
...body,
whatsapp: whatsappBody,
};
}
return body;
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: 'c5b0e3f7-cbbd-4bd6-b01c-150d52cf2ce9',
name: 'summarise-and-send',
triggers: [
{
universalIdentifier: '36e1c4c7-8664-4d6d-a88f-ac56f1bd0651',
type: 'cron',
pattern: '0 9 * * *',
},
],
};
@@ -1,28 +0,0 @@
import { request } from "./utils"
type Opportunity = {
stage: 'NEW' | 'PROPOSAL'
}
export const summariseOpportunityCreation = async (date: string) => {
const { opportunities }: { opportunities: Opportunity[] } = await request(
`opportunities?filter=createdAt[gte]:${date}`,
)
if (opportunities.length === 0) {
return `- No Opportunities were added`
}
const stageSummary = Object.entries(
opportunities.reduce((hash: Record<string,number>, opportunity) => {
if (!hash[opportunity.stage]) {
hash[opportunity.stage] = 0
}
hash[opportunity.stage] += 1
return hash
}, {})
).map(([stage, value]) => `${value} in ${stage}`).join(', ')
return `- ${opportunities.length} Opportunities were added: ${stageSummary}`
}
@@ -1,38 +0,0 @@
import { request } from "./utils"
type Person = {
companyId: string
company: Company
}
type Company = {
id: string
accountOwnerId: string
}
export const summarisePeopleCreation = async (date: string) => {
const { people }: { people: Person[] } = await request(
`people?depth=1&filter=createdAt[gte]:${date}`,
)
if (people.length === 0) {
return '- No People were added'
}
let createdForCompanies: Record<string, Company> = {}
let numberOfAccountOwnerlessCompanies = 0
for (const person of people) {
const isCompanyTracked = createdForCompanies[person.companyId]
if (person.companyId && !isCompanyTracked) {
createdForCompanies[person.company.id] = person.company
if (!person?.company?.accountOwnerId) {
numberOfAccountOwnerlessCompanies += 1
}
}
}
return `- ${people.length} People were added for ${Object.keys(createdForCompanies).length} Companies
- Out of those, ${numberOfAccountOwnerlessCompanies} Companies don't have account owners yet`
}
@@ -1,172 +0,0 @@
export const sendToSlack = async (params: {
peopleCreationSummary: string;
opportunityCreationSummary: string;
taskCreationSummary: string;
}) => {
const {
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
} = params;
const slackMessage = {
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: `Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last ${process.env.DAYS_AGO} days`,
emoji: true,
},
},
{
type: 'header',
text: {
type: 'plain_text',
text: '🧑‍💻 People & Companies',
emoji: true,
},
},
{
type: 'section',
text: {
type: 'plain_text',
text: peopleCreationSummary,
emoji: true,
},
},
{
type: 'header',
text: {
type: 'plain_text',
text: '🎯 Opportunities',
emoji: true,
},
},
{
type: 'section',
text: {
type: 'plain_text',
text: opportunityCreationSummary,
emoji: true,
},
},
{
type: 'header',
text: {
type: 'plain_text',
text: '📋 Tasks',
emoji: true,
},
},
{
type: 'section',
text: {
type: 'plain_text',
text: taskCreationSummary,
emoji: true,
},
},
],
};
const response = await fetch(process.env.SLACK_HOOK_URL ?? '', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(slackMessage),
});
return {
formattedMessage: slackMessage,
webhookStatus: response.status,
};
};
export const sendToDiscord = async (params: {
peopleCreationSummary: string;
opportunityCreationSummary: string;
taskCreationSummary: string;
}) => {
const {
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
} = params;
const formattedMessage = `Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last ${process.env.DAYS_AGO} days:
**🧑‍💻 People & Companies**
${peopleCreationSummary}
**🎯 Opportunities**
${opportunityCreationSummary}
**📋 Tasks**
${taskCreationSummary}`;
const body = {
username: 'Twenty Bot',
content: formattedMessage,
};
const response = await fetch(process.env.DISCORD_WEBHOOK_URL ?? '', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return {
formattedMessage,
webhookStatus: response.status,
};
};
export const sendToWhatsApp = async (params: {
peopleCreationSummary: string;
opportunityCreationSummary: string;
taskCreationSummary: string;
}): Promise<object> => {
const {
peopleCreationSummary,
opportunityCreationSummary,
taskCreationSummary,
} = params;
const formattedMessage = `Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last ${process.env.DAYS_AGO} days:
*🧑‍💻 People & Companies*
${peopleCreationSummary}
*🎯 Opportunities*
${opportunityCreationSummary}
*📋 Tasks*
${taskCreationSummary}`;
const response = await fetch(
'https://graph.facebook.com/v22.0/828771160324576/messages',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.FB_GRAPH_TOKEN}`,
},
body: JSON.stringify({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: process.env.WHATSAPP_RECIPIENT_PHONE_NUMBER,
type: 'text',
text: {
preview_url: true,
body: formattedMessage,
},
}),
},
);
const responseBody = await response.json();
return {
formattedMessage,
webhookStatus: response.status,
webhookResponse: responseBody,
};
};
@@ -1,89 +0,0 @@
import { request } from "./utils"
type Task = {
status: string
dueAt: string
createdBy: {
workspaceMemberId: string
}
}
export const summariseTaskCreation = async (date: string) => {
const { tasks }: { tasks: Task[] } = await request(
`tasks?filter=createdAt[gte]:${date}`,
)
if (tasks.length === 0) {
return '- No Tasks were added'
}
const presentDateISOString = new Date().toISOString()
const workspaceMemberIdsDueDateCounter: Record<string, number> = {}
let workspaceMembers = []
tasks.forEach((task) => {
if (task.status === 'DONE') {
return
}
if (presentDateISOString >= task.dueAt) {
if (!workspaceMemberIdsDueDateCounter[task.createdBy.workspaceMemberId]) {
workspaceMemberIdsDueDateCounter[task.createdBy.workspaceMemberId] = 0
}
workspaceMemberIdsDueDateCounter[task.createdBy.workspaceMemberId] += 1
}
})
for (const { userId, count } of findMaxIncompleteKeys(workspaceMemberIdsDueDateCounter)) {
const data = await request(`workspaceMembers/${userId}`)
if (data.workspaceMember) {
workspaceMembers.push({
...data.workspaceMember,
slackCount: count
})
}
}
let slackerMessage = workspaceMembers.length > 0 ? workspaceMembers.reduce((text, member, index) => {
if (index === 0 && workspaceMembers.length === 1) {
return `${member.name.firstName} slacked the most with ${member.slackCount} Tasks overdue`
}
if (index !== workspaceMembers.length - 1) {
return text.concat(`, ${member.name.firstName}`)
}
if (index === workspaceMembers.length - 1) {
return text.concat(`, and ${member.name.firstName} slacked the most with ${member.slackCount} Tasks overdue`)
}
}, '') : 'No one was caught slacking!'
const tasksCompletedOnTime = tasks.filter(
task => task.status === 'DONE' && task.dueAt >= presentDateISOString
)
const taskCompletionPercentage = tasksCompletedOnTime.length > 0
? (tasksCompletedOnTime.length / tasks.length) * 100
: NaN
const taskCompletionMessage = isNaN(taskCompletionPercentage)
? 'No completed Tasks yet'
: `${taskCompletionPercentage.toFixed(2)}% of Tasks were completed on time`
return `- ${tasks.length} Tasks were created
- ${taskCompletionMessage}
- ${slackerMessage}`
}
/**
* @description This is generated by AI
*/
const findMaxIncompleteKeys = (workspaceMemberIdsDueDateCounter: Record<string,number>) => {
// Get the maximum incomplete count
const max = Math.max(...Object.values(workspaceMemberIdsDueDateCounter));
// Filter keys that match the maximum value
return Object.entries(workspaceMemberIdsDueDateCounter)
.filter(([_, count]) => count === max)
.map(([userId, count]) => ({ userId, count }))
};
@@ -1,13 +0,0 @@
export const request = async (route: string, authToken?: string) => {
const response = await fetch(
`https://api.twenty.com/rest/${route}`,
{
headers: {
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`
},
}
)
const json = await response.json()
return json.data
}
@@ -1,26 +0,0 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
@@ -1,38 +0,0 @@
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__metadata:
version: 8
cacheKey: 10c0
"@types/node@npm:^24.7.2":
version: 24.9.2
resolution: "@types/node@npm:24.9.2"
dependencies:
undici-types: "npm:~7.16.0"
checksum: 10c0/7905d43f65cee72ef475fe76316e10bbf6ac5d08a7f0f6c38f2b6285d7ca3009e8fcafc8f8a1d2bf3f55889c9c278dbb203a9081fd0cf2d6d62161703924c6fa
languageName: node
linkType: hard
"activity-summary@workspace:.":
version: 0.0.0-use.local
resolution: "activity-summary@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
twenty-sdk: "npm:0.0.3"
languageName: unknown
linkType: soft
"twenty-sdk@npm:0.0.3":
version: 0.0.3
resolution: "twenty-sdk@npm:0.0.3"
checksum: 10c0/0a3c85c27edb22fb50f7eb0da4f9770e85729fce05e9e0118ad0cdfc36e42425c93340a6cd1c276daf30aeeaa612db0cd905831c0a8287a31bff3da5be9b0562
languageName: node
linkType: hard
"undici-types@npm:~7.16.0":
version: 7.16.0
resolution: "undici-types@npm:7.16.0"
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
languageName: node
linkType: hard
@@ -1,19 +0,0 @@
# Set environment values for your application here.
# Use the format: KEY=value
#
# These variables are automatically loaded when running your serverless functions.
# You can access them directly in your code using:
# const myValue = process.env.KEY;
#
# To make these variables available to your application,
# add them to package.json "env" key. This "env" key defines all
# environment variables that will be provided to your serverless
# functions at runtime.
#
# Example:
# API_TOKEN=your-api-token
# TIMEOUT_MS=3000
TWENTY_API_URL=
TWENTY_API_KEY=
OPENAI_API_KEY=
@@ -1,2 +0,0 @@
.yarn/install-state.gz
.env
@@ -1 +0,0 @@
nodeLinker: node-modules
@@ -1,159 +0,0 @@
# AI Meeting Transcript
Automatically process meeting transcripts to extract insights, action items, and follow-ups using AI.
## Features
- **Automatic Transcript Processing**: Receives meeting transcripts via webhook from Granola or similar transcription tools
- **AI-Powered Analysis**: Uses OpenAI to extract:
- Meeting summary
- Key discussion points
- Action items with assignees and due dates
- Commitments made by participants
- **Rich Note Creation**: Creates formatted notes in Twenty with summary and key points
- **Task Generation**: Automatically creates tasks for action items and commitments, linked to the meeting note
## Requirements
- `twenty-cli` - Install globally: `npm install -g twenty-cli`
- `apiKey` - Go to `https://twenty.com/settings/api-webhooks` to generate one
- `OpenAI API Key` - Get your API key from [OpenAI](https://platform.openai.com/api-keys)
## Installation
1. Copy the environment file:
```bash
cp .env.example .env
```
2. Edit `.env` and replace the placeholders:
- `<SET_YOUR_TWENTY_API>` with your Twenty API key
- `<SET_YOUR_OPENAI_API_KEY>` with your OpenAI API key
3. Install dependencies:
```bash
yarn install
```
4. Sync the app to your Twenty workspace:
```bash
twenty auth login
twenty app sync
```
## Configuration
After syncing, configure the environment variables in your Twenty workspace:
1. Go to Settings → Apps → AI Meeting Transcript
2. Set the following environment variables:
- `TWENTY_API_KEY` - Your Twenty API key
- `TWENTY_API_URL` - Your Twenty instance URL (e.g., `https://api.twenty.com` or `http://localhost:3000` for local development)
- `OPENAI_API_KEY` - Your OpenAI API key
**Important**: `TWENTY_API_URL` is required and must be set to your Twenty instance URL. For local development, use `http://localhost:3000`. For production, use your actual Twenty instance URL.
## Usage
### Webhook Endpoint
The app exposes a public serverless route trigger.
```
POST /s/webhook/transcript
```
Examples:
- Local: `POST http://localhost:3000/s/webhook/transcript`
- Hosted: `POST https://your-twenty-instance.com/s/webhook/transcript`
### Webhook Payload Format
Send a POST request with the following JSON structure:
```json
{
"transcript": "Full meeting transcript text here...",
"meetingTitle": "Q4 Planning Meeting",
"meetingDate": "2024-01-15",
"participants": ["John Doe", "Jane Smith"],
"metadata": {
"duration": "45 minutes",
"location": "Conference Room A"
}
}
```
**Required Fields:**
- `transcript` (string): The full meeting transcript text
**Optional Fields:**
- `meetingTitle` (string): Title of the meeting
- `meetingDate` (string): Date of the meeting (ISO format or readable date)
- `participants` (string[]): List of meeting participants
- `metadata` (object): Additional metadata about the meeting
### Example Webhook Call
```bash
curl -X POST https://your-twenty-instance.com/s/webhook/transcript \
-H "Content-Type: application/json" \
-d '{
"transcript": "John: Let'\''s start the meeting. Today we need to discuss Q4 goals. Jane: I agree. We should focus on customer retention. John: Great point. Can you prepare a report by Friday? Jane: Yes, I will have it ready.",
"meetingTitle": "Q4 Planning Meeting",
"meetingDate": "2024-01-15"
}'
```
### What Happens
1. **Transcript Analysis**: The transcript is sent to OpenAI for analysis
2. **Note Creation**: A formatted note is created in Twenty with:
- Meeting summary
- Key discussion points
- Reference to the transcript source
3. **Task Creation**: Tasks are automatically created for:
- Each action item identified
- Each commitment made by participants
- Tasks include a reference to the meeting note ID in their description
## Development
Run dev mode to see application updates on your workspace instantly:
```bash
twenty app dev
```
## Integration with Granola
To integrate with Granola or similar transcription tools:
1. Set up a webhook in your transcription service
2. Configure it to POST to: `https://your-twenty-instance.com/s/webhook/transcript`
3. Map the transcription service's payload format to the expected format above
### Granola Webhook Setup
If using Granola, configure the webhook to send:
- `transcript` field with the transcript text
- Optionally include meeting metadata fields
## API Response
The webhook returns a JSON response:
```json
{
"success": true,
"noteId": "uuid-of-created-note",
"taskIds": ["uuid-of-task-1", "uuid-of-task-2"],
"summary": {
"noteCreated": true,
"tasksCreated": 2,
"actionItemsProcessed": 1,
"commitmentsProcessed": 1
}
}
```
@@ -1,30 +0,0 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '028754f1-3235-43b9-9427-fa6a62dbd473',
displayName: 'AI Meeting Transcript',
description:
'Automatically process meeting transcripts to extract insights, action items, and follow-ups',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: '1359d05c-4947-4673-809f-abd55bede365',
isSecret: true,
value: '',
description: 'Twenty API key',
},
TWENTY_API_URL: {
universalIdentifier: 'dbe83355-b574-445c-92c0-5c2b94a61ddb',
isSecret: true,
value: '',
description: 'Twenty API URL',
},
OPENAI_API_KEY: {
universalIdentifier: '9559470d-15eb-4bc2-9cbc-3bc5c869d1fd',
isSecret: true,
value: '',
description: 'OpenAI API key for transcript analysis',
},
},
};
export default config;
@@ -1,19 +0,0 @@
{
"name": "ai-meeting-transcript",
"version": "0.0.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"dependencies": {
"axios": "^1.12.2",
"openai": "^4.28.0",
"twenty-sdk": "0.0.3"
},
"devDependencies": {
"@types/node": "^24.7.2"
}
}
@@ -1,306 +0,0 @@
import axios from 'axios';
import OpenAI from 'openai';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
type TranscriptWebhookPayload = {
transcript: string;
meetingTitle?: string;
meetingDate?: string;
participants?: string[];
metadata?: Record<string, unknown>;
};
type ActionItem = {
title: string;
description: string;
assignee?: string;
dueDate?: string;
};
type Commitment = {
person: string;
commitment: string;
dueDate?: string;
};
type AnalysisResult = {
summary: string;
keyPoints: string[];
actionItems: ActionItem[];
commitments: Commitment[];
};
type RichTextData = {
markdown: string;
blocknote: null;
};
type TwentyApiResponse = {
id: string;
};
const OPENAI_MODEL = 'gpt-4o-mini';
const OPENAI_TEMPERATURE = 0.3;
const analyzeTranscript = async (
transcript: string,
openaiApiKey: string,
): Promise<AnalysisResult> => {
const openai = new OpenAI({ apiKey: openaiApiKey });
const prompt = `Analyze the following meeting transcript and extract:
1. A concise summary (2-3 sentences)
2. Key discussion points (bullet list)
3. Action items with titles, descriptions, and any mentioned assignees or due dates
4. Commitments made by participants with names and any mentioned due dates
Return the response as a JSON object with this structure:
{
"summary": "string",
"keyPoints": ["string"],
"actionItems": [{"title": "string", "description": "string", "assignee": "string (optional)", "dueDate": "string (optional)"}],
"commitments": [{"person": "string", "commitment": "string", "dueDate": "string (optional)"}]
}
Transcript:
${transcript}`;
const completion = await openai.chat.completions.create({
model: OPENAI_MODEL,
messages: [
{
role: 'system',
content:
'You are a meeting analysis assistant. Extract key insights, action items, and commitments from meeting transcripts. Always return valid JSON.',
},
{
role: 'user',
content: prompt,
},
],
response_format: { type: 'json_object' },
temperature: OPENAI_TEMPERATURE,
});
const content = completion.choices[0]?.message?.content;
if (!content) {
throw new Error('No response from OpenAI');
}
return JSON.parse(content) as AnalysisResult;
};
const getTwentyApiConfig = () => {
const apiKey = process.env.TWENTY_API_KEY;
if (!apiKey) {
throw new Error('TWENTY_API_KEY environment variable is not set');
}
const baseUrl = process.env.TWENTY_API_URL;
return { apiKey, baseUrl };
};
const formatNoteBody = (summary: string, keyPoints: string[]): string => {
const keyPointsList = keyPoints.map((point) => `- ${point}`).join('\n');
return `## Summary\n\n${summary}\n\n## Key Points\n\n${keyPointsList}\n\n*Generated from meeting transcript*`;
};
const createNoteInTwenty = async (
summary: string,
keyPoints: string[],
meetingTitle?: string,
meetingDate?: string,
): Promise<TwentyApiResponse> => {
const { apiKey, baseUrl } = getTwentyApiConfig();
const noteTitle =
meetingTitle ||
`Meeting Notes - ${meetingDate || new Date().toLocaleDateString()}`;
const noteBodyMarkdown = formatNoteBody(summary, keyPoints);
const requestData = {
title: noteTitle,
bodyV2: {
markdown: noteBodyMarkdown,
blocknote: null,
} satisfies RichTextData,
};
try {
const { data } = await axios.post<TwentyApiResponse>(
`${baseUrl}/rest/notes`,
requestData,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
},
);
return data;
} catch (error) {
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data
? JSON.stringify(error.response.data, null, 2)
: error.message;
const status = error.response?.status;
throw new Error(
`Failed to create note: ${errorMessage}. Status: ${status}`,
);
}
throw error;
}
};
const createTaskInTwenty = async (
actionItem: ActionItem,
): Promise<TwentyApiResponse> => {
const { apiKey, baseUrl } = getTwentyApiConfig();
const taskData: {
title: string;
bodyV2: RichTextData;
dueAt?: string;
} = {
title: actionItem.title,
bodyV2: {
markdown: actionItem.description,
blocknote: null,
},
};
if (actionItem.dueDate) {
taskData.dueAt = new Date(actionItem.dueDate).toISOString();
}
try {
const { data } = await axios.post<TwentyApiResponse>(
`${baseUrl}/rest/tasks`,
taskData,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
},
);
return data;
} catch (error) {
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data
? JSON.stringify(error.response.data, null, 2)
: error.message;
const status = error.response?.status;
throw new Error(
`Failed to create task "${actionItem.title}": ${errorMessage}. Status: ${status}`,
);
}
throw error;
}
};
const createTasksFromActionItems = async (
actionItems: ActionItem[],
noteId: string,
): Promise<string[]> => {
const taskIds: string[] = [];
for (const actionItem of actionItems) {
try {
const taskDescription = `${actionItem.description}\n\n*Related to meeting note: ${noteId}*`;
const task = await createTaskInTwenty({
...actionItem,
description: taskDescription,
});
taskIds.push(task.id);
} catch (error) {
// Task creation failed, continue with next task
}
}
return taskIds;
};
const createTasksFromCommitments = async (
commitments: Commitment[],
noteId: string,
): Promise<string[]> => {
const taskIds: string[] = [];
for (const commitment of commitments) {
try {
const taskDescription = `Commitment from ${commitment.person}: ${commitment.commitment}\n\n*Related to meeting note: ${noteId}*`;
const task = await createTaskInTwenty({
title: `Follow up: ${commitment.commitment}`,
description: taskDescription,
dueDate: commitment.dueDate,
});
taskIds.push(task.id);
} catch (error) {
// Commitment task creation failed, continue with next commitment
}
}
return taskIds;
};
export const main = async (
params: TranscriptWebhookPayload,
): Promise<object> => {
const { transcript, meetingTitle, meetingDate } = params;
if (!transcript || typeof transcript !== 'string') {
throw new Error('Transcript is required and must be a string');
}
const openaiApiKey = process.env.OPENAI_API_KEY;
if (!openaiApiKey) {
throw new Error('OPENAI_API_KEY environment variable is not set');
}
const analysis = await analyzeTranscript(transcript, openaiApiKey);
const note = await createNoteInTwenty(
analysis.summary,
analysis.keyPoints,
meetingTitle,
meetingDate,
);
const actionItemTaskIds = await createTasksFromActionItems(
analysis.actionItems,
note.id,
);
const commitmentTaskIds = await createTasksFromCommitments(
analysis.commitments,
note.id,
);
const allTaskIds = [...actionItemTaskIds, ...commitmentTaskIds];
return {
success: true,
noteId: note.id,
taskIds: allTaskIds,
summary: {
noteCreated: true,
tasksCreated: allTaskIds.length,
actionItemsProcessed: analysis.actionItems.length,
commitmentsProcessed: analysis.commitments.length,
},
};
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: 'dae52ab2-174f-4f81-a031-604ee2e81eba',
name: 'ai-meeting-transcriptor',
triggers: [
{
universalIdentifier: 'b011303d-2c24-44d4-9923-55eb060a1ff6',
type: 'route',
path: '/webhook/transcript',
httpMethod: 'POST',
isAuthRequired: false,
},
],
};
@@ -1,26 +0,0 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
@@ -1,435 +0,0 @@
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__metadata:
version: 8
cacheKey: 10c0
"@types/node-fetch@npm:^2.6.4":
version: 2.6.13
resolution: "@types/node-fetch@npm:2.6.13"
dependencies:
"@types/node": "npm:*"
form-data: "npm:^4.0.4"
checksum: 10c0/6313c89f62c50bd0513a6839cdff0a06727ac5495ccbb2eeda51bb2bbbc4f3c0a76c0393a491b7610af703d3d2deb6cf60e37e59c81ceeca803ffde745dbf309
languageName: node
linkType: hard
"@types/node@npm:*, @types/node@npm:^24.7.2":
version: 24.9.2
resolution: "@types/node@npm:24.9.2"
dependencies:
undici-types: "npm:~7.16.0"
checksum: 10c0/7905d43f65cee72ef475fe76316e10bbf6ac5d08a7f0f6c38f2b6285d7ca3009e8fcafc8f8a1d2bf3f55889c9c278dbb203a9081fd0cf2d6d62161703924c6fa
languageName: node
linkType: hard
"@types/node@npm:^18.11.18":
version: 18.19.130
resolution: "@types/node@npm:18.19.130"
dependencies:
undici-types: "npm:~5.26.4"
checksum: 10c0/22ba2bc9f8863101a7e90a56aaeba1eb3ebdc51e847cef4a6d188967ab1acbce9b4f92251372fd0329ecb924bbf610509e122c3dfe346c04dbad04013d4ad7d0
languageName: node
linkType: hard
"abort-controller@npm:^3.0.0":
version: 3.0.0
resolution: "abort-controller@npm:3.0.0"
dependencies:
event-target-shim: "npm:^5.0.0"
checksum: 10c0/90ccc50f010250152509a344eb2e71977fbf8db0ab8f1061197e3275ddf6c61a41a6edfd7b9409c664513131dd96e962065415325ef23efa5db931b382d24ca5
languageName: node
linkType: hard
"agentkeepalive@npm:^4.2.1":
version: 4.6.0
resolution: "agentkeepalive@npm:4.6.0"
dependencies:
humanize-ms: "npm:^1.2.1"
checksum: 10c0/235c182432f75046835b05f239708107138a40103deee23b6a08caee5136873709155753b394ec212e49e60e94a378189562cb01347765515cff61b692c69187
languageName: node
linkType: hard
"ai-meeting-transcript@workspace:.":
version: 0.0.0-use.local
resolution: "ai-meeting-transcript@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
axios: "npm:^1.12.2"
openai: "npm:^4.28.0"
twenty-sdk: "npm:^0.0.2"
languageName: unknown
linkType: soft
"async-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-function@npm:1.0.0"
checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73
languageName: node
linkType: hard
"async-generator-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-generator-function@npm:1.0.0"
checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186
languageName: node
linkType: hard
"asynckit@npm:^0.4.0":
version: 0.4.0
resolution: "asynckit@npm:0.4.0"
checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d
languageName: node
linkType: hard
"axios@npm:^1.12.2":
version: 1.13.1
resolution: "axios@npm:1.13.1"
dependencies:
follow-redirects: "npm:^1.15.6"
form-data: "npm:^4.0.4"
proxy-from-env: "npm:^1.1.0"
checksum: 10c0/de9c3c6de43d3ee1146d3afe78645f19450cac6a5d7235bef8b8e8eeb705c2e47e2d231dea99cecaec4dae1897c521118ca9413b9d474063c719c4d94c5b9adc
languageName: node
linkType: hard
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
version: 1.0.2
resolution: "call-bind-apply-helpers@npm:1.0.2"
dependencies:
es-errors: "npm:^1.3.0"
function-bind: "npm:^1.1.2"
checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938
languageName: node
linkType: hard
"combined-stream@npm:^1.0.8":
version: 1.0.8
resolution: "combined-stream@npm:1.0.8"
dependencies:
delayed-stream: "npm:~1.0.0"
checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5
languageName: node
linkType: hard
"delayed-stream@npm:~1.0.0":
version: 1.0.0
resolution: "delayed-stream@npm:1.0.0"
checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19
languageName: node
linkType: hard
"dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
dependencies:
call-bind-apply-helpers: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
gopd: "npm:^1.2.0"
checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031
languageName: node
linkType: hard
"es-define-property@npm:^1.0.1":
version: 1.0.1
resolution: "es-define-property@npm:1.0.1"
checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c
languageName: node
linkType: hard
"es-errors@npm:^1.3.0":
version: 1.3.0
resolution: "es-errors@npm:1.3.0"
checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85
languageName: node
linkType: hard
"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1":
version: 1.1.1
resolution: "es-object-atoms@npm:1.1.1"
dependencies:
es-errors: "npm:^1.3.0"
checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c
languageName: node
linkType: hard
"es-set-tostringtag@npm:^2.1.0":
version: 2.1.0
resolution: "es-set-tostringtag@npm:2.1.0"
dependencies:
es-errors: "npm:^1.3.0"
get-intrinsic: "npm:^1.2.6"
has-tostringtag: "npm:^1.0.2"
hasown: "npm:^2.0.2"
checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af
languageName: node
linkType: hard
"event-target-shim@npm:^5.0.0":
version: 5.0.1
resolution: "event-target-shim@npm:5.0.1"
checksum: 10c0/0255d9f936215fd206156fd4caa9e8d35e62075d720dc7d847e89b417e5e62cf1ce6c9b4e0a1633a9256de0efefaf9f8d26924b1f3c8620cffb9db78e7d3076b
languageName: node
linkType: hard
"follow-redirects@npm:^1.15.6":
version: 1.15.11
resolution: "follow-redirects@npm:1.15.11"
peerDependenciesMeta:
debug:
optional: true
checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343
languageName: node
linkType: hard
"form-data-encoder@npm:1.7.2":
version: 1.7.2
resolution: "form-data-encoder@npm:1.7.2"
checksum: 10c0/56553768037b6d55d9de524f97fe70555f0e415e781cb56fc457a68263de3d40fadea2304d4beef2d40b1a851269bd7854e42c362107071892cb5238debe9464
languageName: node
linkType: hard
"form-data@npm:^4.0.4":
version: 4.0.4
resolution: "form-data@npm:4.0.4"
dependencies:
asynckit: "npm:^0.4.0"
combined-stream: "npm:^1.0.8"
es-set-tostringtag: "npm:^2.1.0"
hasown: "npm:^2.0.2"
mime-types: "npm:^2.1.12"
checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695
languageName: node
linkType: hard
"formdata-node@npm:^4.3.2":
version: 4.4.1
resolution: "formdata-node@npm:4.4.1"
dependencies:
node-domexception: "npm:1.0.0"
web-streams-polyfill: "npm:4.0.0-beta.3"
checksum: 10c0/74151e7b228ffb33b565cec69182694ad07cc3fdd9126a8240468bb70a8ba66e97e097072b60bcb08729b24c7ce3fd3e0bd7f1f80df6f9f662b9656786e76f6a
languageName: node
linkType: hard
"function-bind@npm:^1.1.2":
version: 1.1.2
resolution: "function-bind@npm:1.1.2"
checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5
languageName: node
linkType: hard
"generator-function@npm:^2.0.0":
version: 2.0.1
resolution: "generator-function@npm:2.0.1"
checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8
languageName: node
linkType: hard
"get-intrinsic@npm:^1.2.6":
version: 1.3.1
resolution: "get-intrinsic@npm:1.3.1"
dependencies:
async-function: "npm:^1.0.0"
async-generator-function: "npm:^1.0.0"
call-bind-apply-helpers: "npm:^1.0.2"
es-define-property: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
es-object-atoms: "npm:^1.1.1"
function-bind: "npm:^1.1.2"
generator-function: "npm:^2.0.0"
get-proto: "npm:^1.0.1"
gopd: "npm:^1.2.0"
has-symbols: "npm:^1.1.0"
hasown: "npm:^2.0.2"
math-intrinsics: "npm:^1.1.0"
checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d
languageName: node
linkType: hard
"get-proto@npm:^1.0.1":
version: 1.0.1
resolution: "get-proto@npm:1.0.1"
dependencies:
dunder-proto: "npm:^1.0.1"
es-object-atoms: "npm:^1.0.0"
checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c
languageName: node
linkType: hard
"gopd@npm:^1.2.0":
version: 1.2.0
resolution: "gopd@npm:1.2.0"
checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead
languageName: node
linkType: hard
"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0":
version: 1.1.0
resolution: "has-symbols@npm:1.1.0"
checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e
languageName: node
linkType: hard
"has-tostringtag@npm:^1.0.2":
version: 1.0.2
resolution: "has-tostringtag@npm:1.0.2"
dependencies:
has-symbols: "npm:^1.0.3"
checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c
languageName: node
linkType: hard
"hasown@npm:^2.0.2":
version: 2.0.2
resolution: "hasown@npm:2.0.2"
dependencies:
function-bind: "npm:^1.1.2"
checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9
languageName: node
linkType: hard
"humanize-ms@npm:^1.2.1":
version: 1.2.1
resolution: "humanize-ms@npm:1.2.1"
dependencies:
ms: "npm:^2.0.0"
checksum: 10c0/f34a2c20161d02303c2807badec2f3b49cbfbbb409abd4f95a07377ae01cfe6b59e3d15ac609cffcd8f2521f0eb37b7e1091acf65da99aa2a4f1ad63c21e7e7a
languageName: node
linkType: hard
"math-intrinsics@npm:^1.1.0":
version: 1.1.0
resolution: "math-intrinsics@npm:1.1.0"
checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f
languageName: node
linkType: hard
"mime-db@npm:1.52.0":
version: 1.52.0
resolution: "mime-db@npm:1.52.0"
checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa
languageName: node
linkType: hard
"mime-types@npm:^2.1.12":
version: 2.1.35
resolution: "mime-types@npm:2.1.35"
dependencies:
mime-db: "npm:1.52.0"
checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2
languageName: node
linkType: hard
"ms@npm:^2.0.0":
version: 2.1.3
resolution: "ms@npm:2.1.3"
checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48
languageName: node
linkType: hard
"node-domexception@npm:1.0.0":
version: 1.0.0
resolution: "node-domexception@npm:1.0.0"
checksum: 10c0/5e5d63cda29856402df9472335af4bb13875e1927ad3be861dc5ebde38917aecbf9ae337923777af52a48c426b70148815e890a5d72760f1b4d758cc671b1a2b
languageName: node
linkType: hard
"node-fetch@npm:^2.6.7":
version: 2.7.0
resolution: "node-fetch@npm:2.7.0"
dependencies:
whatwg-url: "npm:^5.0.0"
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8
languageName: node
linkType: hard
"openai@npm:^4.28.0":
version: 4.104.0
resolution: "openai@npm:4.104.0"
dependencies:
"@types/node": "npm:^18.11.18"
"@types/node-fetch": "npm:^2.6.4"
abort-controller: "npm:^3.0.0"
agentkeepalive: "npm:^4.2.1"
form-data-encoder: "npm:1.7.2"
formdata-node: "npm:^4.3.2"
node-fetch: "npm:^2.6.7"
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
bin:
openai: bin/cli
checksum: 10c0/c4f2e837684ed96b8cec58c65a584646d667c69918f29052775e2e8c05ff5c860d8b58214a7770bc6895ca8602480420c1db6a5392dd250179eb0b91c2b19a2f
languageName: node
linkType: hard
"proxy-from-env@npm:^1.1.0":
version: 1.1.0
resolution: "proxy-from-env@npm:1.1.0"
checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b
languageName: node
linkType: hard
"tr46@npm:~0.0.3":
version: 0.0.3
resolution: "tr46@npm:0.0.3"
checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11
languageName: node
linkType: hard
"twenty-sdk@npm:^0.0.2":
version: 0.0.2
resolution: "twenty-sdk@npm:0.0.2"
checksum: 10c0/99e6fe86059d847b548c1f03e0f0c59a4d540caf1d28dd4500f1f5f0094196985ded955801274de9e72ff03e3d1f41e9a509b4c2c5a02ffc8a027277b1e35d8e
languageName: node
linkType: hard
"undici-types@npm:~5.26.4":
version: 5.26.5
resolution: "undici-types@npm:5.26.5"
checksum: 10c0/bb673d7876c2d411b6eb6c560e0c571eef4a01c1c19925175d16e3a30c4c428181fb8d7ae802a261f283e4166a0ac435e2f505743aa9e45d893f9a3df017b501
languageName: node
linkType: hard
"undici-types@npm:~7.16.0":
version: 7.16.0
resolution: "undici-types@npm:7.16.0"
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
languageName: node
linkType: hard
"web-streams-polyfill@npm:4.0.0-beta.3":
version: 4.0.0-beta.3
resolution: "web-streams-polyfill@npm:4.0.0-beta.3"
checksum: 10c0/a9596779db2766990117ed3a158e0b0e9f69b887a6d6ba0779940259e95f99dc3922e534acc3e5a117b5f5905300f527d6fbf8a9f0957faf1d8e585ce3452e8e
languageName: node
linkType: hard
"webidl-conversions@npm:^3.0.0":
version: 3.0.1
resolution: "webidl-conversions@npm:3.0.1"
checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db
languageName: node
linkType: hard
"whatwg-url@npm:^5.0.0":
version: 5.0.0
resolution: "whatwg-url@npm:5.0.0"
dependencies:
tr46: "npm:~0.0.3"
webidl-conversions: "npm:^3.0.0"
checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5
languageName: node
linkType: hard
@@ -1,38 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty/*
!.twenty/output/
# 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
@@ -1,38 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}]
}
}
@@ -1 +0,0 @@
nodeLinker: node-modules
@@ -1,13 +0,0 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/fixtures/rich-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- 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.
@@ -1,51 +0,0 @@
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Getting Started
First, authenticate to your workspace:
```bash
yarn twenty remote add http://localhost:2020 --as local
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
## Available Commands
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote add http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Application
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
```
## LLMs instructions
Main docs and pitfalls are available in LLMS.md file.
## Learn More
To learn more about Twenty applications, take a look at the following resources:
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
@@ -1,26 +0,0 @@
{
"name": "apollo-enrich",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
"dependencies": {
"twenty-sdk": "latest"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"oxlint": "^0.16.0",
"react": "^18.2.0",
"typescript": "^5.9.3"
}
}
@@ -1,54 +0,0 @@
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: 'ac1d2ed1-8835-4bd4-9043-28b46fdda465',
displayName: 'Apollo enrichment',
description: 'Data enrichment with Apollo to keep your data accurate',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
settingsCustomTabFrontComponentUniversalIdentifier: '50d59f7c-eada-4731-aacd-8e45371e1040',
applicationVariables: {
APOLLO_CLIENT_ID: {
universalIdentifier: '5852219e-7757-463e-9e7c-80980203794c',
isSecret: true,
value: '',
description: 'Apollo Client ID',
},
APOLLO_CLIENT_SECRET: {
universalIdentifier: 'a032349d-9458-4381-8505-82547276434a',
isSecret: true,
value: '',
description: 'Apollo Client Secret',
},
APOLLO_OAUTH_URL: {
universalIdentifier: '1d42411c-5809-4093-873a-8121b1302475',
isSecret: false,
value: '',
description: 'Apollo OAuth URL',
},
APOLLO_REDIRECT_URI: {
universalIdentifier: 'c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f',
isSecret: false,
value: '',
description: 'Apollo OAuth redirect URI',
},
APOLLO_REGISTERED_URL: {
universalIdentifier: '672a6fce-5565-43bc-9a3b-7f2c33620770',
isSecret: false,
value: '',
description: 'Apollo registered URL',
},
APOLLO_ACCESS_TOKEN: {
universalIdentifier: '672a6fce-5565-43bc-9a3b-7f2c33620771',
isSecret: true,
value: '',
description: 'Apollo access token',
},
APOLLO_REFRESH_TOKEN: {
universalIdentifier: '672a6fce-5565-43bc-9a3b-7f2c33620772',
isSecret: true,
value: '',
description: 'Apollo refresh token',
},
},
});
@@ -1,12 +0,0 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'da15cfc6-3657-457d-8757-4ba11b5bb6e1',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.NUMBER,
name: 'apolloFoundedYear',
label: 'Founded Year',
description: 'Year the company was founded, from Apollo enrichment',
icon: 'IconCalendar',
});
@@ -1,12 +0,0 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '505532f5-1fc5-4a58-8074-ba9b48650dbc',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.TEXT,
name: 'apolloIndustry',
label: 'Apollo Industry',
description: 'Industry classification from Apollo enrichment',
icon: 'IconBuildingFactory',
});
@@ -1,12 +0,0 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'be15e062-b065-48b4-979c-65b9a50e0cb1',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.TEXT,
name: 'apolloShortDescription',
label: 'Apollo Description',
description: 'Short company description from Apollo enrichment',
icon: 'IconFileDescription',
});
@@ -1,12 +0,0 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'c90ae72d-4ddf-4f22-882f-eef98c91e40e',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.CURRENCY,
name: 'apolloTotalFunding',
label: 'Total Funding',
description: 'Total funding raised by the company, from Apollo enrichment',
icon: 'IconCash',
});
@@ -1,220 +0,0 @@
import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
import { OAuthApplicationVariables } from 'src/logic-functions/get-oauth-application-variables';
import { VERIFY_PAGE_PATH } from 'src/logic-functions/get-verify-page';
import { defineFrontComponent } from 'twenty-sdk/define';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
`;
const StyledSectionTitle = styled.h3`
color: #333;
font-family: 'Inter', sans-serif;
font-size: 15px;
font-weight: 600;
margin: 0 0 4px 0;
`;
const StyledSectionSubtitle = styled.p`
color: #818181;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 400;
margin: 0 0 12px 0;
`;
const StyledCard = styled.div`
align-items: center;
background: #fff;
border: 1px solid #ebebeb;
border-radius: 8px;
display: flex;
gap: 12px;
padding: 16px;
`;
const StyledIconContainer = styled.div`
align-items: center;
background: #f5f5f5;
border-radius: 8px;
color: #666;
display: flex;
flex-shrink: 0;
height: 40px;
justify-content: center;
width: 40px;
`;
const StyledTextContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
`;
const StyledTitle = styled.span`
color: #333;
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 500;
`;
const StyledDescription = styled.span`
color: #818181;
font-family: 'Inter', sans-serif;
font-size: 13px;
`;
const StyledLink = styled.a`
align-items: center;
background: #5e5adb;
border: 1px solid rgba(0, 0, 0, 0.04);
border-radius: 4px;
box-sizing: border-box;
color: #fafafa;
cursor: pointer;
display: inline-flex;
flex-shrink: 0;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 500;
gap: 4px;
height: 32px;
justify-content: center;
padding: 0 12px;
text-decoration: none;
transition: background 0.1s ease;
white-space: nowrap;
&:hover {
background: #4b47b8;
}
&:active {
background: #3c3996;
}
&:focus {
outline: none;
}
`;
const StyledConnectedStatus = styled.span`
align-items: center;
background: #10b981;
border-radius: 4px;
color: #fff;
display: inline-flex;
flex-shrink: 0;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 500;
gap: 6px;
height: 32px;
padding: 0 12px;
white-space: nowrap;
`;
const StyledIcon = styled.img`
height: 24px;
width: 24px;
`;
const APOLLO_ICON_URL = 'https://twenty-icons.com/apollo.io';
const fetchOAuthApplicationVariables = async (): Promise<OAuthApplicationVariables> => {
const backEndUrl = `${process.env.TWENTY_API_URL}/s/oauth/application-variables`;
const response = await fetch(backEndUrl, {
method: 'GET',
});
const data = await response.json();
return data;
};
const buildOAuthUrl = (oauthApplicationVariables: OAuthApplicationVariables): string => {
const { apolloOAuthUrl, apolloClientId, apolloRegisteredUrl } = oauthApplicationVariables;
const redirectUri = `${apolloRegisteredUrl}auth/oauth-propagator/callback`;
const state = encodeURIComponent(`${process.env.TWENTY_API_URL}/s${VERIFY_PAGE_PATH}`);
return `${apolloOAuthUrl}?client_id=${apolloClientId}&redirect_uri=${redirectUri}&state=${state}&response_type=code`;
};
const ApolloOAuthCta = () => {
const [oauthApplicationVariables, setOAuthApplicationVariables] =
useState<OAuthApplicationVariables | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchOAuthApplicationVariables()
.then(setOAuthApplicationVariables)
.catch(setError)
.finally(() => setIsLoading(false));
}, []);
if (isLoading) {
return (
<StyledContainer>
<StyledSectionTitle>Connect to Apollo</StyledSectionTitle>
<StyledSectionSubtitle>Enrich your contacts with Apollo data</StyledSectionSubtitle>
<StyledCard>
<StyledIconContainer>
<StyledIcon src={APOLLO_ICON_URL} alt="Apollo" />
</StyledIconContainer>
<StyledTextContainer>
<StyledTitle>Apollo OAuth</StyledTitle>
<StyledDescription>Loading...</StyledDescription>
</StyledTextContainer>
</StyledCard>
</StyledContainer>
);
}
if (error || !oauthApplicationVariables) {
return null;
}
const isConnected = Boolean(oauthApplicationVariables.apolloAccessToken);
const oauthUrl = buildOAuthUrl(oauthApplicationVariables);
return (
<StyledContainer>
<StyledSectionTitle>Connect to Apollo</StyledSectionTitle>
<StyledSectionSubtitle>Enrich your contacts with Apollo data</StyledSectionSubtitle>
<StyledCard>
<StyledIconContainer>
<StyledIcon src={APOLLO_ICON_URL} alt="Apollo" />
</StyledIconContainer>
<StyledTextContainer>
<StyledTitle>Apollo OAuth</StyledTitle>
<StyledDescription>
{isConnected
? 'Your Apollo account is connected'
: 'Connect your Apollo account to enrich contacts'}
</StyledDescription>
</StyledTextContainer>
{isConnected ? (
<StyledConnectedStatus>
Connected
</StyledConnectedStatus>
) : (
<StyledLink href={oauthUrl} rel="noopener noreferrer">
Connect
</StyledLink>
)}
</StyledCard>
</StyledContainer>
);
};
export default defineFrontComponent({
universalIdentifier: '50d59f7c-eada-4731-aacd-8e45371e1040',
name: 'apollo-oauth-cta',
description: 'CTA button to connect to Apollo Enrichment via OAuth',
component: ApolloOAuthCta,
});
@@ -1,96 +0,0 @@
import { defineLogicFunction, RoutePayload } from "twenty-sdk/define";
import { MetadataApiClient } from 'twenty-sdk/clients';
export const OAUTH_TOKEN_PAIRS_PATH = '/oauth/token-pairs';
type ApolloTokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
created_at: number;
};
const getAuthenticationTokenPairs = async (
code: string,
clientId: string,
clientSecret: string,
): Promise<ApolloTokenResponse> => {
const formData = new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
code: code,
redirect_uri: 'https://hjsm0q38-3000.uks1.devtunnels.ms/auth/oauth-propagator/callback',
});
const response = await fetch('https://app.apollo.io/api/v1/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to exchange code for tokens: ${response.status} - ${errorText}`);
}
return response.json();
};
const handler = async (event: RoutePayload): Promise<any> => {
const { queryStringParameters: { code } } = event;
if (!code) {
throw new Error('Code is required');
}
const apolloClientId = process.env.APOLLO_CLIENT_ID ?? '';
const apolloClientSecret = process.env.APOLLO_CLIENT_SECRET ?? '';
const applicationId = process.env.APPLICATION_ID ?? '';
const metadataClient = new MetadataApiClient({});
const tokenPairs = await getAuthenticationTokenPairs(
code,
apolloClientId,
apolloClientSecret,
);
await metadataClient.mutation({
updateOneApplicationVariable: {
__args: {
key: 'APOLLO_ACCESS_TOKEN',
value: tokenPairs.access_token,
applicationId,
},
},
});
await metadataClient.mutation({
updateOneApplicationVariable: {
__args: {
key: 'APOLLO_REFRESH_TOKEN',
value: tokenPairs.refresh_token,
applicationId,
},
},
});
return {tokenPairs};
};
export default defineLogicFunction({
universalIdentifier: '7ccc63a7-ece1-44c0-adbe-805a1baea03a',
name: 'get-authentication-token-pairs',
description: 'Returns the Apollo authentication token pairs',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: OAUTH_TOKEN_PAIRS_PATH,
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -1,32 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
export type OAuthApplicationVariables = {
apolloClientId: string;
apolloRegisteredUrl: string;
apolloOAuthUrl: string;
apolloAccessToken: string;
apolloRefreshToken: string;
};
const handler = async (): Promise<OAuthApplicationVariables> => {
const apolloClientId = process.env.APOLLO_CLIENT_ID ?? '';
const apolloRegisteredUrl = process.env.APOLLO_REGISTERED_URL ?? '';
const apolloOAuthUrl = process.env.APOLLO_OAUTH_URL ?? '';
const apolloAccessToken = process.env.APOLLO_ACCESS_TOKEN ?? '';
const apolloRefreshToken = process.env.APOLLO_REFRESH_TOKEN ?? '';
return { apolloClientId, apolloRegisteredUrl, apolloOAuthUrl, apolloAccessToken, apolloRefreshToken };
};
export default defineLogicFunction({
universalIdentifier: 'b7c3e8f1-9d4a-4e2b-8f6c-1a5d3e7b9c2f',
name: 'get-oauth-application-variables',
description: 'Returns the Apollo OAuth authorization URL',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: '/oauth/application-variables',
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -1,90 +0,0 @@
import { defineLogicFunction } from "twenty-sdk/define";
export const VERIFY_PAGE_PATH = '/oauth/verify';
const buildVerifyPageHtml = (applicationId: string): string => `<!DOCTYPE html>
<html>
<head>
<title>Apollo OAuth - Verifying...</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; text-align: center; }
.loading { color: #6b7280; }
.success { color: #10b981; }
.error { color: #ef4444; }
.spinner { border: 3px solid #f3f4f6; border-top: 3px solid #3b82f6; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
<script>
(async function() {
const applicationId = ${JSON.stringify(applicationId)};
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const baseUrl = window.location.origin;
function showError(message) {
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('spinner').style.display = 'none';
document.getElementById('title').textContent = '✗ Connection Failed';
document.getElementById('title').className = 'error';
document.getElementById('status').textContent = message;
});
if (window.opener) {
window.opener.postMessage({ type: 'APOLLO_OAUTH_ERROR', error: message }, '*');
}
}
if (!code) {
showError('Authorization code is missing. Please try connecting again.');
return;
}
try {
const response = await fetch(
baseUrl + '/s/oauth/token-pairs?code=' + encodeURIComponent(code),
{
method: 'GET',
headers: { 'Content-Type': 'application/json' }
}
);
if (!response.ok) {
const errorText = await response.text();
throw new Error('Failed to get tokens: ' + response.status + ' - ' + errorText);
}
const tokens = await response.json();
window.location.href = 'http://apple.localhost:3001/settings/applications/' + applicationId + '#custom';
} catch (error) {
showError(error.message);
}
})();
</script>
</head>
<body>
<div class="spinner" id="spinner"></div>
<h1 class="loading" id="title">Connecting to Apollo...</h1>
<p id="status">Please wait while we complete the connection.</p>
</body>
</html>`;
const handler = async (): Promise<string> => {
const applicationId = process.env.APPLICATION_ID ?? '';
return buildVerifyPageHtml(applicationId);
};
export default defineLogicFunction({
universalIdentifier: '4d74950a-d9c1-4c66-a799-89c1aea4e6b0',
name: 'get-verify-page',
description: 'Returns the Apollo OAuth verify page',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: VERIFY_PAGE_PATH,
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -1,223 +0,0 @@
import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordUpdateEvent } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-sdk/clients';
type CompanyRecord = {
id: string;
name?: string;
domainName?: {
primaryLinkUrl?: string;
primaryLinkLabel?: string;
};
};
type ApolloOrganization = {
name?: string;
website_url?: string;
linkedin_url?: string;
twitter_url?: string;
estimated_num_employees?: number;
annual_revenue?: number;
total_funding?: number;
street_address?: string;
city?: string;
state?: string;
postal_code?: string;
country?: string;
short_description?: string;
industry?: string;
founded_year?: number;
};
type ApolloEnrichResponse = {
organization?: ApolloOrganization;
};
const extractDomain = (
domainName?: CompanyRecord['domainName'],
): string | undefined => {
const url = domainName?.primaryLinkUrl;
if (!url) {
return undefined;
}
try {
const hostname = new URL(
url.startsWith('http') ? url : `https://${url}`,
).hostname;
return hostname.replace(/^www\./, '');
} catch {
return url.replace(/^(https?:\/\/)?(www\.)?/, '').split('/')[0];
}
};
const fetchApolloEnrichment = async (
domain: string,
): Promise<ApolloOrganization | undefined> => {
const response = await fetch(
`https://api.apollo.io/api/v1/organizations/enrich?domain=${encodeURIComponent(domain)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.APOLLO_ACCESS_TOKEN ?? ''}`,
},
},
);
const data: ApolloEnrichResponse = await response.json();
return data.organization;
};
const buildCompanyUpdateData = (
apolloOrganization: ApolloOrganization,
): Record<string, unknown> => {
const updateData: Record<string, unknown> = {};
if (apolloOrganization.name) {
updateData.name = apolloOrganization.name;
}
if (apolloOrganization.estimated_num_employees) {
updateData.employees = apolloOrganization.estimated_num_employees;
}
if (apolloOrganization.linkedin_url) {
updateData.linkedinLink = {
primaryLinkUrl: apolloOrganization.linkedin_url,
primaryLinkLabel: 'LinkedIn',
};
}
if (apolloOrganization.twitter_url) {
updateData.xLink = {
primaryLinkUrl: apolloOrganization.twitter_url,
primaryLinkLabel: 'X',
};
}
if (apolloOrganization.annual_revenue) {
updateData.annualRecurringRevenue = {
amountMicros: apolloOrganization.annual_revenue * 1_000_000,
currencyCode: 'USD',
};
}
const hasAddress =
apolloOrganization.street_address ||
apolloOrganization.city ||
apolloOrganization.state ||
apolloOrganization.country;
if (hasAddress) {
updateData.address = {
addressStreet1: apolloOrganization.street_address ?? '',
addressCity: apolloOrganization.city ?? '',
addressState: apolloOrganization.state ?? '',
addressPostcode: apolloOrganization.postal_code ?? '',
addressCountry: apolloOrganization.country ?? '',
};
}
if (apolloOrganization.industry) {
updateData.apolloIndustry = apolloOrganization.industry;
}
if (apolloOrganization.short_description) {
updateData.apolloShortDescription = apolloOrganization.short_description;
}
if (apolloOrganization.founded_year) {
updateData.apolloFoundedYear = apolloOrganization.founded_year;
}
if (apolloOrganization.total_funding) {
updateData.apolloTotalFunding = {
amountMicros: apolloOrganization.total_funding * 1_000_000,
currencyCode: 'USD',
};
}
return updateData;
};
const updateCompanyInTwenty = async (
companyId: string,
updateData: Record<string, unknown>,
): Promise<void> => {
const client = new CoreApiClient();
const result = await client.mutation({
updateCompany: {
__args: {
id: companyId,
data: updateData,
},
id: true,
},
});
if (!result.updateCompany) {
throw new Error(`Failed to update company ${companyId}: no result`);
}
};
type CompanyUpdateEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<CompanyRecord>
>;
const handler = async (
event: CompanyUpdateEvent,
): Promise<object | undefined> => {
const { recordId, properties } = event;
const { after: companyAfter } = properties;
const domain = extractDomain(companyAfter?.domainName);
if (!domain) {
return { skipped: true, reason: 'no domain found on company' };
}
const apolloOrganization = await fetchApolloEnrichment(domain);
if (!apolloOrganization) {
return {
skipped: true,
reason: `no Apollo data found for ${domain}`,
};
}
const updateData = buildCompanyUpdateData(apolloOrganization);
if (Object.keys(updateData).length === 0) {
return { skipped: true, reason: 'no enrichment data to apply' };
}
await updateCompanyInTwenty(recordId, updateData);
const result = {
enriched: true,
companyId: recordId,
domain,
updatedFields: Object.keys(updateData),
};
return result;
};
export default defineLogicFunction({
universalIdentifier: '6248b3fe-a8af-404a-8e38-19df98f73d81',
name: 'on-company-updated',
description:
'Enriches company data from Apollo when the company domain is updated',
timeoutSeconds: 30,
handler,
databaseEventTriggerSettings: {
eventName: 'company.updated',
updatedFields: ['domainName'],
},
});
@@ -1,13 +0,0 @@
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk/define';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
};
export default definePostInstallLogicFunction({
universalIdentifier: '08292efc-d7ba-4ec3-ab95-e7c33bd3a3bc',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
@@ -1,13 +0,0 @@
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk/define';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
};
export default definePreInstallLogicFunction({
universalIdentifier: 'af7cd86e-149e-466a-8d60-312b6e46d604',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
handler,
});
@@ -1,15 +0,0 @@
import { defineRole } from 'twenty-sdk/define';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b8faae3f-e174-43fa-ab94-715712ae26cb';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Apollo enrich default function role',
description: 'Apollo enrich default function role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: true,
});
File diff suppressed because it is too large Load Diff
@@ -1,109 +0,0 @@
# Fireflies Integration Environment Variables
# Copy this file to .env and fill in your actual values
# =============================================================================
# REQUIRED: Authentication & API Keys
# =============================================================================
# Secret key for HMAC signature verification of Fireflies webhooks
# This must match the secret configured in your Fireflies webhook settings
# Get this from: https://app.fireflies.ai/settings#DeveloperSettings
FIREFLIES_WEBHOOK_SECRET=your_webhook_secret_here
# Fireflies API key for fetching meeting data from GraphQL API
# Get this from: https://app.fireflies.ai/settings#DeveloperSettings
FIREFLIES_API_KEY=your_fireflies_api_key_here
# Fireflies plan level - affects which fields are available
# Options: free, pro, business, enterprise
# This controls which GraphQL fields are requested to avoid 403 errors
FIREFLIES_PLAN=free
# Twenty CRM API key for authentication
# Generate this from your Twenty instance: Settings > Developers > API Keys
TWENTY_API_KEY=your_twenty_api_key_here
# =============================================================================
# Server Configuration
# =============================================================================
# Twenty CRM server URL
# Use http://localhost:3000 for local development
# Use your production URL for deployed instances
SERVER_URL=http://localhost:3000
# =============================================================================
# Contact Management
# =============================================================================
# Automatically create contacts for unknown participants (true/false)
# If true, new Person records will be created for meeting participants not found in CRM
# If false, only meetings with existing contacts will be fully processed
AUTO_CREATE_CONTACTS=true
# =============================================================================
# Summary Processing Configuration
# =============================================================================
# Strategy for handling async summary generation
# Options:
# - immediate_with_retry: Attempts immediate fetch with retry logic (RECOMMENDED)
# - delayed_polling: Schedules background polling for summaries
# - basic_only: Creates records without waiting for summaries
FIREFLIES_SUMMARY_STRATEGY=immediate_with_retry
# Number of retry attempts when fetching summary data
# Used with immediate_with_retry strategy
# Recommended: 3-5 attempts (summaries can take up to 10 minutes but rate limit is low)
FIREFLIES_RETRY_ATTEMPTS=5
# Delay in milliseconds between retry attempts (with exponential backoff)
# Each retry will wait: RETRY_DELAY * attempt_number
# Example: 120000ms means 2min, 4min, 6min... for extended backoff
# Total max time with 5 attempts: ~15 minutes
FIREFLIES_RETRY_DELAY=120000
# Polling interval in milliseconds for delayed_polling strategy
# How often to check if summary is ready
# Recommended: 60000 (60 seconds) for extended processing
FIREFLIES_POLL_INTERVAL=120000
# Maximum number of polling attempts for delayed_polling strategy
# Total max time = POLL_INTERVAL * MAX_POLLS
# Example: 2min * 5 = 10 minutes maximum
FIREFLIES_MAX_POLLS=5
# =============================================================================
# Debugging & Logging
# =============================================================================
# Log level: silent, error, warn, info, debug (default: error)
# Controls verbosity of console output
# - silent: No console output
# - error: Only errors (production default)
# - warn: Warnings and errors
# - info: Info, warnings, and errors
# - debug: All logs including detailed debugging
LOG_LEVEL=error
# =============================================================================
# Configuration Notes
# =============================================================================
#
# Webhook Setup:
# 1. Configure your Fireflies webhook at: https://app.fireflies.ai/settings#DeveloperSettings
# 2. Webhook URL: https://your-twenty-instance.com/s/webhook/fireflies
# 3. Event Type: "Transcription completed"
# 4. Secret: Same value as FIREFLIES_WEBHOOK_SECRET above, genereate it there
#
# Summary Strategy Guide:
# - immediate_with_retry: Best for most use cases - fast with reliability
# - delayed_polling: Use if your server is heavily loaded
# - basic_only: Use if you only need transcript links without AI summaries
#
# Performance Tuning:
# - Fireflies summaries can take 5-15 minutes to generate after transcription
# - Use 30+ retry attempts with 30s delay for 15-minute coverage
# - Consider delayed_polling strategy for heavily loaded servers
# - Monitor DEBUG_LOGS to tune timing for your Fireflies account
#
@@ -1 +0,0 @@
.yarn
@@ -1,40 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}]
}
}
@@ -1 +0,0 @@
nodeLinker: node-modules
@@ -1,167 +0,0 @@
# Changelog
## [0.3.1] - 2025-12-08
Import all
### Added
- Historical import CLI: `yarn meeting:all` to fetch and insert historical Fireflies meetings with filters (date range, organizers, participants, channel, mine) and dry-run support.
- Fireflies transcripts listing with pagination and date filtering to support bulk imports.
### Changed
- Deduplication now checks `firefliesMeetingId` before creating meetings (webhook + bulk).
- Shared historical importer pipeline reusing existing note/meeting formatting.
## [0.3.0] - 2025-12-08
Subscription-based query / Full transcript and AI notes for Pro+ / More
### Added
- **Full transcript capture**: Meeting object now stores the complete meeting transcript with speaker names and timestamps (`transcript` field)
- **Rich AI meeting notes**: Captures detailed AI-generated meeting notes from Fireflies (`notes` field with 7,000+ char summaries)
- **Expanded summary fields**: Now fetches all available Fireflies summary data:
- `notes` - Detailed AI-generated meeting notes with timestamps and section headers
- `bullet_gist` - Emoji-enhanced bullet point summaries
- `outline` / `shorthand_bullet` - Timestamped meeting outline
- `gist` - One-sentence meeting summary
- `short_summary` - Single paragraph summary
- `short_overview` - Brief overview
- **New Meeting fields**:
- `transcript` - Full meeting transcript with speaker attribution
- `notes` - AI-generated detailed notes
- `audioUrl` - Link to audio recording (Pro+)
- `videoUrl` - Link to video recording (Business+)
- `meetingLink` - Original meeting link
- `neutralPercent` - Neutral sentiment percentage
- **Meeting delete utility**: New `yarn meeting:delete <meetingId>` script for cleanup and re-import
- **Debug meeting utility**: New `scripts/debug-meeting.ts` to inspect raw Fireflies API responses
### Changed
- **Plan-based GraphQL queries**: Completely redesigned query system with three tiers:
- **Free**: Basic fields only (title, date, duration, participants, transcript_url, meeting_link)
- **Pro**: Adds full transcript (`sentences`), summary fields, speakers, audio_url
- **Business+**: Adds analytics, video_url, speaker stats, meeting metrics
- **Action items parsing**: Fixed parsing of `action_items` which Fireflies returns as newline-separated string, not array
- **Note body format**: Enhanced with Meeting Notes, Outline, Key Points sections from rich Fireflies data
- **Import status**: Added `PARTIAL` status for imports missing summary/analytics data
### Fixed
- Missing `notes` and `bullet_gist` fields in data transform (were fetched but not passed through)
- Proper fallback: Uses `shorthand_bullet` when `outline` is empty (Fireflies stores outline content there)
- Summary readiness detection now checks `notes` field in addition to `overview` and `action_items`
### Documentation
- Updated README with complete API access comparison table by subscription plan
- Documented all available Fireflies summary fields and their plan requirements
## [0.2.3] - 2025-12-06
### Added
- **Meeting ingest utility**: New `yarn meeting:ingest <meetingId>` script to manually fetch and import specific Fireflies meetings into Twenty
- **Plan-based field selection**: Added `FIREFLIES_PLAN` configuration to control which GraphQL fields are requested based on subscription level (free, pro, business, enterprise)
- **Main entry point**: New `src/index.ts` centralizing all exports for cleaner imports
### Changed
- **Auth configuration**: Disabled authentication requirement for webhook route (`isAuthRequired: false`) to support serverless deployments
- **Signature verification fallback**: Webhook handler now supports signature in payload body as fallback when HTTP headers aren't forwarded to serverless functions (production doesn't work for Fireflies webhook)
- **Improved type safety**: Replaced `any` types with proper TypeScript types throughout codebase
### Enhanced
- **Webhook debugging**: Added detailed debug output including param keys, header info, and signature comparison details
- **Test webhook script**: Includes signature in both header and payload, with diagnostic output for header forwarding status
- **Documentation**: Added README sections on current twenty headers forward limitations and utility scripts
## [0.2.2] - 2025-11-04
### Added
- **Enhanced logging system**: Introduced configurable `AppLogger` class with log level support (debug, info, warn, error, silent)
- Environment-based log level configuration via `LOG_LEVEL` environment variable
- Test environment detection to prevent log noise during testing
- Context-aware logging with proper prefixes for better debugging
- **Improved error handling**: Enhanced webhook signature verification with detailed debug logging
- **Better debugging capabilities**: Added comprehensive logging throughout webhook processing pipeline
### Enhanced
- **Webhook signature verification**: Improved signature validation with detailed logging for troubleshooting
- **Error messages**: More descriptive error logging for failed operations and security violations
- **Development experience**: Better debugging information for webhook processing and API interactions
## [0.2.1] - 2025-11-03
### Added
- **Import status tracking**: Added four new meeting fields to track import status and failure handling:
- `importStatus` (SELECT) - Tracks SUCCESS, FAILED, PENDING, RETRYING states
- `importError` (TEXT) - Stores error messages when imports fail
- `lastImportAttempt` (DATE_TIME) - Timestamp of the last import attempt
- `importAttempts` (NUMBER) - Counter for number of import attempts
- **Automatic failure tracking**: Enhanced webhook handler to automatically create failed meeting records when processing fails
- **Failed meeting formatter**: Added `toFailedMeetingCreateInput()` method to create standardized failed meeting records
### Enhanced
- **Meeting type definition**: Extended `MeetingCreateInput` type with import tracking fields
- **Success status tracking**: Successful meeting imports now automatically set `importStatus: 'SUCCESS'` and track timestamps
- **Error handling**: Webhook processing failures are now captured and stored as meeting records for visibility and potential retry
## [0.2.0] - 2025-11-03
### Changed
- **Major refactoring**: Split monolithic `receive-fireflies-notes.ts` into modular architecture:
- `fireflies-api-client.ts` - Fireflies GraphQL API integration with retry logic
- `twenty-crm-service.ts` - Twenty CRM operations (contacts, notes, meetings)
- `formatters.ts` - Meeting and note body formatting
- `webhook-handler.ts` - Main webhook orchestration
- `webhook-validator.ts` - HMAC signature verification
- `utils.ts` - Shared utility functions
- `types.ts` - Centralized type definitions
- **Schema update**: Changed Meeting `notes` field from `RICH_TEXT` to `RELATION` type linking to Note object
- Enhanced participant extraction from multiple Fireflies API data sources (participants, meeting_attendees, speakers, meeting_attendance)
- Improved organizer email matching with name-based heuristics
- Updated note creation to use `bodyV2.markdown` format instead of legacy `body` field
- Modernized Meeting object schema with proper link field types for transcriptUrl and recordingUrl
- Enhanced test suite with improved mocking for new modular structure
- **Configuration optimization**: Reduced default retry attempts from 30 to 5 with increased delay (120s) to better respect Fireflies API rate limits (50 requests/day for free/pro plans)
- Updated field setup script to support relation field creation with Note object
- Restructured exports: types now exported from `types.ts`, runtime functions from `index.ts`
- Updated import paths in action handlers to use centralized index exports
- Added TypeScript path mappings for `twenty-sdk` in workspace configuration
### Added
- `createNoteTarget` method for linking notes to multiple participants
- Support for extracting participants from extended Fireflies API response formats
- Better organizer identification logic matching email usernames to speaker names
- `axios` dependency for improved HTTP client capabilities
- API subscription plan documentation highlighting rate limit differences (50/day vs 60/minute)
- Enhanced README with rate limiting guidance and configuration documentation
- Relation field creation support in field provisioning script
### Fixed
- Note linking now properly associates a single note with multiple participants in 1:1 meetings
- Participant extraction handles missing email addresses gracefully
- Improved handling of various Fireflies participant data structures
- Test mocks updated to use string format for participants (`"Name <email>"`) matching Fireflies API response format
- Test assertions updated to validate `bodyV2.markdown` instead of deprecated `body` field
## [0.1.0] - 2025-11-02
### Added
- HMAC SHA-256 signature verification for incoming Fireflies webhooks
- Fireflies GraphQL client with retry logic, timeout handling, and summary readiness detection
- Summary-focused meeting processing that extracts action items, sentiment, keywords, and transcript/recording links
- Scripted custom field provisioning via `yarn setup:fields`
- Local webhook testing workflow via `yarn test:webhook`
- Comprehensive Jest suite (15 tests) covering authentication, API integration, summary strategies, and error handling
### Changed
- Replaced legacy JSON manifests with TypeScript configuration:
- `application.config.ts` now declares app metadata and configuration variables
- `src/objects/meeting.ts` defines the Meeting object via `@ObjectMetadata`
- `src/actions/receive-fireflies-notes.ts` exports the Fireflies webhook action plus its runtime config
- Updated documentation (README, Deployment Guide, Testing) to reflect the new project layout and workflows
- Switched utility scripts to `tsx` and aligned package management with the hello-world example
### Fixed
- Resolved real-world Fireflies payload mismatch by adopting the minimal webhook schema
- Replaced body-based secrets with header-driven HMAC verification
- Ensured graceful degradation when summaries are pending or Fireflies is temporarily unavailable
@@ -1,331 +0,0 @@
# Fireflies
Automatically captures meeting notes with AI-generated summaries and insights from Fireflies.ai into your Twenty CRM.
### Current Status
- Doesn't work with Fireflies webhook yet due to missing headers forwarding in twenty serverless func
- Meeting ingestion utility scripts are available for individual meeting insertion and historical meetings with filters with yarn meeting:all
## Integration Overview
**Fireflies webhook → Fireflies API → Twenty CRM with summary-focused insights**
- **Summary-first approach** - Prioritizes action items, keywords, and sentiment over raw transcripts
- **HMAC signature verification** - Secure webhook authentication
- **Two-phase architecture** - Webhook notification → API data fetch → CRM record creation
- **Contact identification** - Matches participants to existing contacts or creates new ones
- **One-on-one meetings** (2 people) → Individual notes linked to each contact
- **Multi-party meetings** (3+ people) → Meeting records with all attendees
- **Business intelligence extraction** - Action items, sentiment scores, topics, meeting types
- **Smart retry logic** - Handles async summary generation with exponential backoff
- **Links transcripts and recordings** - Easy access to full Fireflies content
- **Duplicate prevention** - Checks for existing meetings by title
## API Access by Subscription Plan
Fireflies API access varies by subscription tier. This integration automatically adapts queries based on your plan and falls back gracefully if restrictions are encountered.
### Plan Comparison
| Feature | Free | Pro | Business | Enterprise |
|---------|:----:|:---:|:--------:|:----------:|
| **API Rate Limit** | 50/day | 50/day | 60/min | 60/min |
| **Basic Data** (title, date, duration) | ✅ | ✅ | ✅ | ✅ |
| **Participants List** | ✅ | ✅ | ✅ | ✅ |
| **Transcript URL** | ✅ | ✅ | ✅ | ✅ |
| **Speakers** | ❌ | ✅ | ✅ | ✅ |
| **Summary** (overview, keywords) | ❌ | ✅ | ✅ | ✅ |
| **Audio URL** | ❌ | ✅ | ✅ | ✅ |
| **Action Items** | ❌ | ❌ | ✅ | ✅ |
| **Topics Discussed** | ❌ | ❌ | ✅ | ✅ |
| **Video URL** | ❌ | ❌ | ✅ | ✅ |
| **Sentiment Analytics** | ❌ | ❌ | ✅ | ✅ |
| **Meeting Attendees (detailed)** | ❌ | ❌ | ✅ | ✅ |
### What You'll Get Per Plan
**Free Plan:**
- Meeting title, date, duration
- Participant names/emails (basic)
- Link to transcript
**Pro Plan:**
- Everything in Free, plus:
- Speaker identification
- AI summary (overview + keywords)
- Audio recording URL
**Business Plan:**
- Everything in Pro, plus:
- Action items extraction
- Topics discussed
- Sentiment analysis (positive/negative/neutral %)
- Video recording URL
- Detailed meeting attendee info
### Configuration
Set your plan in `.env`:
```bash
FIREFLIES_PLAN=free # Options: free, pro, business, enterprise
```
**Rate Limiting:** Free/Pro plans are limited to 50 API calls/day. The integration uses conservative retry settings by default to stay within limits.
## What Gets Captured
### Summary & Insights
- **Action Items** - Concrete next steps and commitments
- **Keywords** - Key topics and themes discussed
- **Overview** - Executive summary of the meeting
- **Topics Discussed** - Main discussion points
- **Meeting Type** - Context (sales call, standup, demo, etc.)
### Analytics
- **Sentiment Analysis** - Positive/negative/neutral percentages for deal health
- **Engagement Metrics** - Participation levels (future)
### Resources
- **Transcript Link** - Quick access to full Fireflies transcript
- **Recording Link** - Video/audio recording when available
## Quick Start
### Installation
```bash
# Step 1: Authenticate with Twenty
npx twenty-cli auth login
# Step 2: Sync the app to create Meeting object
npx twenty-cli app sync packages/twenty-apps/fireflies
# Step 3: Install dependencies
yarn install
# Step 4: Add custom fields
yarn setup:fields
```
(TODO: change when fields setup internal support)
### Configuration
⚠️ **Important**: The integration uses **conservative retry settings** to respect Fireflies' 50 requests/day API limit with free/pro plans. You may increase for more reactivity with higher plans.
**Required Environment Variables:**
```bash
FIREFLIES_API_KEY=your_api_key # From Fireflies settings
TWENTY_API_KEY=your_api_key # From Twenty CRM settings
SERVER_URL=https://your-domain.twenty.com
```
**Optional (Recommended):**
```bash
FIREFLIES_WEBHOOK_SECRET=your_secret # For webhook security
```
📖 **For detailed configuration, troubleshooting, and rate limit management**, see [WEBHOOK_CONFIGURATION.md](./WEBHOOK_CONFIGURATION.md)
### What Gets Created
#### Basic Installation (Step 2)
The `app sync` command creates:
- ✅ Meeting object with basic `name` field
- ✅ Webhook endpoint at `/s/webhook/fireflies`
#### After Custom Fields Setup (Step 4)
The `setup:fields` script adds 13 custom fields to store rich Fireflies data:
| Field Name | Type | Label | Description |
|------------|------|-------|-------------|
| `notes` | RICH_TEXT | Meeting Notes | AI-generated summary with overview, topics, action items, and insights |
| `meetingDate` | DATE_TIME | Meeting Date | Date and time when the meeting occurred |
| `duration` | NUMBER | Duration (minutes) | Meeting duration in minutes |
| `meetingType` | TEXT | Meeting Type | Type of meeting (e.g., Sales Call, Sprint Planning, 1:1) |
| `keywords` | TEXT | Keywords | Key topics and themes discussed (comma-separated) |
| `sentimentScore` | NUMBER | Sentiment Score | Overall meeting sentiment (0-1 scale, 1 = most positive) |
| `positivePercent` | NUMBER | Positive % | Percentage of positive sentiment in conversation |
| `negativePercent` | NUMBER | Negative % | Percentage of negative sentiment in conversation |
| `actionItemsCount` | NUMBER | Action Items | Number of action items identified |
| `transcriptUrl` | LINKS | Transcript URL | Link to full transcript in Fireflies |
| `recordingUrl` | LINKS | Recording URL | Link to video/audio recording in Fireflies |
| `firefliesMeetingId` | TEXT | Fireflies Meeting ID | Unique identifier from Fireflies |
| `organizerEmail` | TEXT | Organizer Email | Email address of the meeting organizer |
**Note:** Without custom fields, meetings will be created with just the title. The rich summary data will only be stored in Notes for 1-on-1 meetings.
## Configuration
### Required Environment Variables
Check [.env.example](./.env.example)
### Summary Processing Strategies
| Strategy | Description | Use Case |
|----------|-------------|----------|
| `immediate_only` | Single fetch attempt, no retries | Fast processing, accept missing summaries if not ready |
| `immediate_with_retry` | Attempts immediate fetch, retries with backoff | **Recommended** - Balances speed and reliability |
| `delayed_polling` | Schedules background polling | For heavily loaded systems |
| `basic_only` | Creates records without waiting for summaries | For basic transcript archival only |
## Webhook Setup
### Step 1: Get Your Webhook URL
Your webhook endpoint will be:
```
https://your-twenty-instance.com/s/webhook/fireflies
```
### Step 2: Configure Fireflies Webhook
1. Log into Fireflies.ai
2. https://app.fireflies.ai/settings#DeveloperSettings
4. Enter your webhook URL
5. Set **Secret**: Generate from there and set value of `FIREFLIES_WEBHOOK_SECRET`
6. Save configuration
### Step 3: Verify Webhook
The integration uses **HMAC SHA-256 signature verification**:
- Fireflies sends `x-hub-signature` header
- Twenty verifies signature using your webhook secret
- Invalid signatures are rejected immediately
### Current Platform Limitation (Headers)
- Twenty serverless route triggers currently do **not forward HTTP headers** to functions. Fireflies signatures sent in headers are stripped, so header-based verification does not work in production.
- Workaround: the provided test script also includes the signature inside the payload; the handler falls back to that payload signature. Use this only for testing until header forwarding is supported.
## Utilities for meeting insertion (workarounds)
- Ingest a specific Fireflies meeting into Twenty:
`yarn meeting:ingest <meetingId>` or `MEETING_ID=... yarn meeting:ingest`
- Fetch all/historical Fireflies meetings into Twenty:
`yarn meeting:all [--from 2024-01-01] [--to 2024-02-01] [--organizer a@x.com] [--participant b@x.com] [--channel <channelId>] [--mine] [--dry-run]`
- Filters (combine as needed):
- `--from` / `--to`: ISO or date string range filter
- `--organizer` / `--participant`: comma-separated emails
- `--channel`: Fireflies channel id
- `--mine`: only meetings for the current Fireflies user
- Controls:
- `--dry-run`: list and transform without writing to Twenty
- `--page-size`: pagination size (default 50)
- `--max-records`: stop after N transcripts (default 500)
## Development
```bash
# Run tests
npm test
# Run tests in watch mode
npm run test -- --watch
# Development mode with live sync
npx twenty-cli app dev
# Type checking
npx tsgo --noEmit
```
## Testing
The integration includes comprehensive test coverage:
```bash
# Run all tests
npm test
# Run specific test suite
npm test -- fireflies-webhook.spec.ts
# Run with coverage
npm test -- --coverage
```
### Test Coverage
- HMAC signature verification
- Fireflies GraphQL API integration
- Summary processing with retry logic
- Summary-focused CRM record creation
- One-on-one vs multi-party meeting detection
- Contact matching and creation
- Duplicate prevention
- Error handling and resilience
## CRM Record Structure
### One-on-One Meeting Note Example
```markdown
# Meeting: Product Demo with Client (Sales Call)
**Date:** Monday, November 2, 2024, 02:00 PM
**Duration:** 30 minutes
**Participants:** Sarah Sales, John Client
## Overview
Successful product demonstration with positive client feedback.
Client expressed strong interest in the enterprise plan.
## Key Topics
- product features
- pricing discussion
- integration capabilities
- support options
## Action Items
- Follow up with pricing proposal by Friday
- Schedule technical deep-dive next week
- Share case studies from similar clients
## Insights
**Keywords:** product demo, pricing, technical requirements, integration
**Sentiment:** 75% positive, 10% negative, 15% neutral
**Meeting Type:** Sales Call
## Resources
[View Full Transcript](https://app.fireflies.ai/transcript/xxx)
[Watch Recording](https://app.fireflies.ai/recording/xxx)
```
### Multi-Party Meeting Record
- Meeting object with title, date, and all attendees
- Summary stored as meeting notes (structure same as above)
- Action items potentially converted to separate tasks (future)
- Keywords as tags/categories (future)
## Future Implementation Opportunities
Next iterations would enhance the **intelligence layer** to:
### AI-Powered Insights
- **Extract pain points, objections & buying signals** automatically from transcripts
- **Calculate deal health scores** based on conversation sentiment trends
- **Auto-create contextualized tasks** with AI-suggested next steps and priorities
- **Proactively flag at-risk deals** when negative signals appear
- **Track conversation patterns** that correlate with deal success
### Enhanced Analytics
- **Action item completion tracking** across deals
- **Sentiment trend analysis** over time for account health
- **Speaking time analysis** for meeting engagement insights
- **Topic clustering** for product/feature interest patterns
### Workflow Automation
- **Auto-assign follow-up tasks** based on action items
- **Smart notifications** for urgent follow-ups
- **Deal stage progression** based on meeting outcomes
- **Competitive intelligence** extraction from conversations
**Integration**: Fireflies webhook → AI processing layer → Enhanced Twenty records
*This would require the current MVP to be stabilized and discussions about intelligence layer architecture and data privacy considerations.*
@@ -1,81 +0,0 @@
import { type ApplicationConfig } from 'twenty-sdk/define';
const config: ApplicationConfig = {
universalIdentifier: 'a4df0c0f-c65e-44e5-8436-24814182d4ac',
displayName: 'Fireflies',
description: 'Sync Fireflies meeting summaries, sentiment, and action items into Twenty.',
icon: 'IconMicrophone',
applicationVariables: {
FIREFLIES_WEBHOOK_SECRET: {
universalIdentifier: 'f51f7646-be9f-4ba9-9b75-160dd288cd0c',
description: 'Secret key for verifying Fireflies webhook signatures',
//isSecret: true,
value: '',
},
FIREFLIES_API_KEY: {
universalIdentifier: 'faa41f07-b28e-4500-b1c0-ce4b3d27924c',
description: 'Fireflies GraphQL API key used to fetch meeting summaries',
//isSecret: true,
value: '',
},
FIREFLIES_PLAN: {
universalIdentifier: '57dbb73c-aac5-4247-9fcc-a070bb669f16',
description: 'Fireflies plan: free, pro, business, enterprise',
value: 'free',
},
TWENTY_API_KEY: {
universalIdentifier: '02756551-5bf7-4fb2-8e08-1f622008d305',
description: 'Twenty API key used when running scripts locally',
//isSecret: true,
value: '',
},
SERVER_URL: {
universalIdentifier: '9b3a5e8e-5973-4e6b-a059-2966075652aa',
description: 'Base URL for the Twenty workspace (default: http://localhost:3000)',
value: 'http://localhost:3000',
},
AUTO_CREATE_CONTACTS: {
universalIdentifier: 'c4fa946e-e06b-4d54-afb6-288b0ac75bdf',
description: 'Whether to auto-create contacts for unknown participants',
value: 'true',
},
LOG_LEVEL: {
universalIdentifier: '2b019cf1-d198-48dd-943e-110571aa541e',
description: 'Log level: silent, error, warn, info, debug (default: error)',
value: 'error',
},
CAPTURE_LOGS: {
universalIdentifier: 'adbcc267-309d-49b2-af71-76f1299d863e',
description: 'Capture logs in webhook response for debugging (true/false)',
value: 'true',
},
FIREFLIES_SUMMARY_STRATEGY: {
universalIdentifier: '562b43d9-cd47-4ec1-ae16-5cc7ebc9729b',
description: 'Summary fetch strategy: immediate_only, immediate_with_retry, delayed_polling, or basic_only',
value: 'immediate_with_retry',
},
FIREFLIES_RETRY_ATTEMPTS: {
universalIdentifier: '670ca203-01ce-4ae8-8294-eb38b29434f2',
description: 'Number of retry attempts when fetching summaries',
value: '3',
},
FIREFLIES_RETRY_DELAY: {
universalIdentifier: '2e8ccb82-9390-47ba-b628-ca2726931bce',
description: 'Delay in milliseconds between retry attempts',
value: '5000',
},
FIREFLIES_POLL_INTERVAL: {
universalIdentifier: '904538f7-7bec-4ee6-9bac-5d43c619b667',
description: 'Polling interval (ms) when using delayed polling strategy',
value: '30000',
},
FIREFLIES_MAX_POLLS: {
universalIdentifier: '84d54c97-5572-4c01-9039-764ab3aa87b8',
description: 'Maximum number of polling attempts when waiting for summaries',
value: '10',
},
},
};
export default config;
@@ -1,23 +0,0 @@
const jestConfig = {
displayName: 'fireflies',
preset: 'ts-jest',
testEnvironment: 'node',
moduleFileExtensions: ['ts', 'js'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
testMatch: [
'<rootDir>/src/**/__tests__/**/*.(test|spec).{js,ts}',
'<rootDir>/src/**/?(*.)(test|spec).{js,ts}',
],
setupFilesAfterEnv: [
'<rootDir>/src/__tests__/setup.ts'
],
collectCoverageFrom: [
'src/**/*.{ts,js}',
'!src/**/*.d.ts',
],
coverageDirectory: './coverage',
};
export default jestConfig;
@@ -1,32 +0,0 @@
{
"name": "fireflies",
"version": "0.3.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"scripts": {
"test": "jest",
"setup:fields": "tsx scripts/add-meeting-fields.ts",
"test:webhook": "tsx scripts/test-webhook.ts",
"meeting:ingest": "tsx scripts/ingest-meeting.ts",
"meeting:delete": "tsx scripts/delete-meeting.ts",
"meeting:all": "tsx scripts/fetch-all-meetings.ts"
},
"dependencies": {
"axios": "^1.13.1",
"dotenv": "^17.2.3",
"twenty-sdk": "0.1.3"
},
"devDependencies": {
"@types/jest": "^29.5.5",
"@types/node": "^24.9.2",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"tsx": "^4.19.3",
"typescript": "^5.9.3"
}
}
@@ -1,31 +0,0 @@
{
"name": "fireflies",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-apps/community/fireflies/src",
"projectType": "application",
"tags": [
"scope:apps"
],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": [
"{workspaceRoot}/coverage/{projectRoot}"
],
"options": {
"jestConfig": "packages/twenty-apps/community/fireflies/jest.config.mjs",
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"coverageReporters": ["text"]
}
}
},
"typecheck": {
"dependsOn": ["^build"]
},
"lint": {}
}
}
@@ -1,494 +0,0 @@
/**
* Migration script to add custom fields to the Meeting object
* Run this after: npx twenty-cli app sync packages/twenty-apps/fireflies
*
* Usage: yarn setup:fields
*/
/* oxlint-disable no-console */
import * as dotenv from 'dotenv';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load environment variables
dotenv.config({ path: path.join(__dirname, '../.env') });
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000';
const API_KEY = process.env.TWENTY_API_KEY;
if (!API_KEY) {
console.error('❌ Error: TWENTY_API_KEY not found in .env file');
process.exit(1);
}
interface RelationCreationPayload {
targetObjectMetadataId: string;
targetFieldLabel: string;
targetFieldIcon: string;
type: 'ONE_TO_MANY' | 'MANY_TO_ONE';
}
interface FieldOption {
value: string;
label: string;
position: number;
color: string;
}
interface FieldDefinition {
type: string;
name: string;
label: string;
description: string;
icon?: string;
isNullable?: boolean;
relationCreationPayload?: RelationCreationPayload;
options?: FieldOption[];
}
// Meeting fields based on Fireflies GraphQL API transcript schema
// See: https://docs.fireflies.ai/graphql-api/query/transcript
// Note: Some fields require higher plans (Pro, Business, Enterprise)
const MEETING_FIELDS: FieldDefinition[] = [
// === Internal Twenty Relations ===
{
type: 'RELATION',
name: 'note',
label: 'Meeting Note',
description: 'Related note with detailed meeting content',
icon: 'IconNotes',
isNullable: true,
},
// === Basic Fields (All Plans) ===
{
type: 'TEXT',
name: 'firefliesMeetingId',
label: 'Fireflies ID',
description: 'Unique transcript ID from Fireflies (maps to: id)',
icon: 'IconKey',
isNullable: true,
},
{
type: 'DATE_TIME',
name: 'meetingDate',
label: 'Meeting Date',
description: 'When the meeting occurred (maps to: date)',
icon: 'IconCalendar',
isNullable: true,
},
{
type: 'NUMBER',
name: 'duration',
label: 'Duration (minutes)',
description: 'Meeting duration in minutes (maps to: duration)',
icon: 'IconClock',
isNullable: true,
},
{
type: 'TEXT',
name: 'organizerEmail',
label: 'Organizer Email',
description: 'Meeting organizer email (maps to: organizer_email)',
icon: 'IconMail',
isNullable: true,
},
{
type: 'LINKS',
name: 'transcriptUrl',
label: 'Transcript URL',
description: 'Link to full transcript (maps to: transcript_url)',
icon: 'IconFileText',
isNullable: true,
},
{
type: 'LINKS',
name: 'meetingLink',
label: 'Meeting Link',
description: 'Original meeting link (maps to: meeting_link)',
icon: 'IconLink',
isNullable: true,
},
// === Pro+ Fields (summary, speakers, audio_url, transcript) ===
{
type: 'TEXT',
name: 'transcript',
label: 'Full Transcript',
description: 'Full meeting transcript with speaker names and timestamps [Pro+]',
icon: 'IconFileText',
isNullable: true,
},
{
type: 'TEXT',
name: 'overview',
label: 'Overview',
description: 'AI-generated meeting summary (maps to: summary.overview) [Pro+]',
icon: 'IconFileDescription',
isNullable: true,
},
{
type: 'TEXT',
name: 'notes',
label: 'AI Notes',
description: 'Detailed AI-generated meeting notes (maps to: summary.notes) [Pro+]',
icon: 'IconNotes',
isNullable: true,
},
{
type: 'TEXT',
name: 'keywords',
label: 'Keywords',
description: 'Key topics extracted (maps to: summary.keywords) [Pro+]',
icon: 'IconTags',
isNullable: true,
},
{
type: 'LINKS',
name: 'audioUrl',
label: 'Audio URL',
description: 'Link to audio recording (maps to: audio_url) [Pro+]',
icon: 'IconHeadphones',
isNullable: true,
},
// === Business+ Fields (analytics, video_url, full summary) ===
{
type: 'TEXT',
name: 'meetingType',
label: 'Meeting Type',
description: 'AI-detected meeting type (maps to: summary.meeting_type) [Business+]',
icon: 'IconTag',
isNullable: true,
},
{
type: 'TEXT',
name: 'topics',
label: 'Topics Discussed',
description: 'Topics covered in meeting (maps to: summary.topics_discussed) [Business+]',
icon: 'IconListDetails',
isNullable: true,
},
{
type: 'NUMBER',
name: 'actionItemsCount',
label: 'Action Items',
description: 'Number of action items (count of: summary.action_items) [Business+]',
icon: 'IconCheckbox',
isNullable: true,
},
{
type: 'NUMBER',
name: 'positivePercent',
label: 'Positive %',
description: 'Positive sentiment % (maps to: analytics.sentiments.positive_pct) [Business+]',
icon: 'IconThumbUp',
isNullable: true,
},
{
type: 'NUMBER',
name: 'negativePercent',
label: 'Negative %',
description: 'Negative sentiment % (maps to: analytics.sentiments.negative_pct) [Business+]',
icon: 'IconThumbDown',
isNullable: true,
},
{
type: 'NUMBER',
name: 'neutralPercent',
label: 'Neutral %',
description: 'Neutral sentiment % (maps to: analytics.sentiments.neutral_pct) [Business+]',
icon: 'IconMoodNeutral',
isNullable: true,
},
{
type: 'LINKS',
name: 'videoUrl',
label: 'Video URL',
description: 'Link to video recording (maps to: video_url) [Business+]',
icon: 'IconVideo',
isNullable: true,
},
// === Import Tracking Fields (Internal) ===
{
type: 'SELECT',
name: 'importStatus',
label: 'Import Status',
description: 'Status of the Fireflies import',
icon: 'IconCheck',
isNullable: true,
options: [
{ value: 'SUCCESS', label: 'Success', position: 0, color: 'green' },
{ value: 'PARTIAL', label: 'Partial', position: 1, color: 'blue' },
{ value: 'FAILED', label: 'Failed', position: 2, color: 'red' },
{ value: 'PENDING', label: 'Pending', position: 3, color: 'yellow' },
{ value: 'RETRYING', label: 'Retrying', position: 4, color: 'orange' },
],
},
{
type: 'TEXT',
name: 'importError',
label: 'Import Error',
description: 'Error message if import failed',
icon: 'IconAlertTriangle',
isNullable: true,
},
{
type: 'DATE_TIME',
name: 'lastImportAttempt',
label: 'Last Import Attempt',
description: 'When import was last attempted',
icon: 'IconClock',
isNullable: true,
},
{
type: 'NUMBER',
name: 'importAttempts',
label: 'Import Attempts',
description: 'Number of import attempts',
icon: 'IconRepeat',
isNullable: true,
},
];
const graphqlRequest = async (query: string, variables: Record<string, unknown> = {}) => {
const response = await fetch(`${SERVER_URL}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`GraphQL request failed (${response.status}): ${errorText}`);
}
const json = await response.json();
if (json.errors) {
throw new Error(`GraphQL errors: ${JSON.stringify(json.errors, null, 2)}`);
}
return json.data;
};
const findMeetingObject = async () => {
const query = `
query FindMeetingObject {
objects(paging: { first: 200 }) {
edges {
node {
id
nameSingular
labelSingular
labelPlural
fields {
edges {
node {
id
name
label
type
}
}
}
}
}
}
}
`;
const data = await graphqlRequest(query);
const edges = data.objects?.edges || [];
const meetingEdge = edges.find(
(edge: any) => edge?.node?.nameSingular === 'meeting',
);
if (!meetingEdge) {
throw new Error('Meeting object not found. Please run "npx twenty-cli app sync" first.');
}
return meetingEdge.node;
};
const findNoteObject = async () => {
const query = `
query FindObjects {
objects(paging: { first: 100 }) {
edges {
node {
id
nameSingular
labelSingular
}
}
}
}
`;
const data = await graphqlRequest(query);
const edges = data.objects?.edges || [];
const noteEdge = edges.find(
(edge: any) => edge?.node?.nameSingular === 'note',
);
if (!noteEdge) {
throw new Error('Note object not found.');
}
return noteEdge.node;
};
const createField = async (objectId: string, field: FieldDefinition) => {
const mutation = `
mutation CreateField($input: CreateOneFieldMetadataInput!) {
createOneField(input: $input) {
id
name
label
type
description
}
}
`;
const input = {
field: {
type: field.type,
name: field.name,
label: field.label,
description: field.description,
icon: field.icon || 'IconAbc',
isNullable: field.isNullable !== false,
isActive: true,
isCustom: true,
objectMetadataId: objectId,
...(field.relationCreationPayload && {
relationCreationPayload: field.relationCreationPayload,
}),
...(field.options && {
options: field.options,
}),
},
};
try {
const data = await graphqlRequest(mutation, { input });
return data.createOneField;
} catch (error) {
if (error instanceof Error) {
const message = error.message;
if (
message.includes('already exists') ||
message.includes('not available') ||
message.includes('Duplicating')
) {
return null;
}
}
throw error;
}
};
const main = async () => {
console.log('🚀 Adding custom fields to Meeting object...\n');
try {
// Step 1: Find Meeting and Note objects
console.log('📋 Finding Meeting object...');
const meetingObject = await findMeetingObject();
console.log(`✅ Found Meeting object: ${meetingObject.labelSingular ?? meetingObject.nameSingular ?? 'Meeting'} (ID: ${meetingObject.id})\n`);
console.log('📋 Finding Note object...');
const noteObject = await findNoteObject();
console.log(`✅ Found Note object: ${noteObject.labelSingular ?? noteObject.nameSingular ?? 'Note'} (ID: ${noteObject.id})\n`);
// Step 2: Update note field with relationCreationPayload
const fieldsToCreate = MEETING_FIELDS.map(field => {
if (field.name === 'note' && field.type === 'RELATION') {
return {
...field,
relationCreationPayload: {
targetObjectMetadataId: noteObject.id,
targetFieldLabel: 'Meeting',
targetFieldIcon: 'IconCalendarEvent',
type: 'MANY_TO_ONE' as const,
},
};
}
return field;
});
// Step 3: Check existing fields
const existingFields = meetingObject.fields?.edges?.map((edge: any) => edge.node.name) || [];
console.log(`📌 Existing fields: ${existingFields.join(', ')}\n`);
// Step 4: Create custom fields
console.log(' Creating custom fields...\n');
let createdCount = 0;
let failedCount = 0;
let skippedCount = 0;
for (const field of fieldsToCreate) {
try {
if (existingFields.includes(field.name)) {
console.log(` ⏭️ ${field.name} - already exists`);
skippedCount++;
continue;
}
const result = await createField(meetingObject.id, field);
if (result) {
console.log(`${field.name} - created successfully`);
createdCount++;
} else {
console.log(` ⏭️ ${field.name} - skipped (already exists)`);
skippedCount++;
}
} catch (error) {
console.error(`${field.name} - failed: ${error instanceof Error ? error.message : String(error)}`);
failedCount++;
}
}
// Step 4: Summary
console.log('\n' + '='.repeat(60));
console.log('📊 Summary:');
console.log(` ✅ Created: ${createdCount} fields`);
console.log(` ⏭️ Skipped: ${skippedCount} fields`);
console.log(` ❌ Failed: ${failedCount} fields`);
console.log('='.repeat(60));
if (failedCount > 0) {
console.log('\n⚠️ Some fields failed to create. Please check the errors above.');
process.exit(1);
}
if (createdCount === 0 && skippedCount === MEETING_FIELDS.length) {
console.log('\n✨ All fields already exist. Nothing to do!\n');
} else if (createdCount > 0) {
console.log('\n✨ Custom fields added successfully!\n');
}
} catch (error) {
console.error('\n❌ Error:', error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
// Run the script
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
@@ -1,81 +0,0 @@
/* oxlint-disable no-console */
import * as dotenv from 'dotenv';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
dotenv.config({ path: path.join(__dirname, '../.env') });
const FIREFLIES_API_KEY = process.env.FIREFLIES_API_KEY;
const meetingId = process.argv[2] || '01KBMR1ZYQ34YP8D2KB4B16QPH';
const main = async (): Promise<void> => {
if (!FIREFLIES_API_KEY) {
console.error('❌ FIREFLIES_API_KEY is required');
process.exit(1);
}
const query = `
query GetTranscript($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
summary {
overview
notes
gist
bullet_gist
short_summary
short_overview
outline
shorthand_bullet
action_items
keywords
topics_discussed
meeting_type
transcript_chapters
}
}
}
`;
const response = await fetch('https://api.fireflies.ai/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${FIREFLIES_API_KEY}`,
},
body: JSON.stringify({ query, variables: { transcriptId: meetingId } }),
});
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ API request failed with status ${response.status}`);
console.error(errorText);
process.exit(1);
}
const json = await response.json();
console.log('=== Fireflies API Response ===\n');
console.log(JSON.stringify(json, null, 2));
if (json.data?.transcript?.summary) {
const s = json.data.transcript.summary;
console.log('\n=== Summary Fields Status ===');
console.log('overview:', s.overview ? `✓ (${s.overview.length} chars)` : '✗ empty');
console.log('notes:', s.notes ? `✓ (${s.notes.length} chars)` : '✗ empty');
console.log('gist:', s.gist ? `✓ (${s.gist.length} chars)` : '✗ empty');
console.log('bullet_gist:', s.bullet_gist ? `✓ (${s.bullet_gist.length} chars)` : '✗ empty');
console.log('outline:', s.outline ? `✓ (${s.outline.length} chars)` : '✗ empty');
console.log('action_items:', s.action_items?.length || 0, 'items');
console.log('topics_discussed:', s.topics_discussed?.length || 0, 'topics');
console.log('keywords:', s.keywords?.length || 0, 'keywords');
}
};
main().catch((error) => {
console.error('❌ Failed to fetch meeting');
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
@@ -1,59 +0,0 @@
/* oxlint-disable no-console */
import * as dotenv from 'dotenv';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
dotenv.config({ path: path.join(__dirname, '../.env') });
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000';
const API_KEY = process.env.TWENTY_API_KEY;
const meetingId = process.argv[2];
const main = async (): Promise<void> => {
if (!API_KEY) {
console.error('❌ TWENTY_API_KEY is required');
process.exit(1);
}
if (!meetingId) {
console.error('Usage: yarn delete:meeting <meetingId>');
process.exit(1);
}
const response = await fetch(`${SERVER_URL}/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({
query: `mutation DeleteMeeting($id: UUID!) { deleteMeeting(id: $id) { id } }`,
variables: { id: meetingId },
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ Delete failed (status ${response.status})`);
console.error(errorText);
process.exit(1);
}
const result = await response.json();
const deletedId = result.data?.deleteMeeting?.id;
if (result.errors || !deletedId) {
const message = result.errors?.[0]?.message || 'deleteMeeting returned null';
console.error('❌ Error:', message);
process.exit(1);
}
console.log('✅ Deleted meeting:', deletedId);
};
main().catch((error) => {
console.error('❌ Failed to delete meeting');
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
@@ -1,221 +0,0 @@
/* oxlint-disable no-console */
/**
* Fetch historical Fireflies meetings and insert into Twenty.
*
* Usage:
* yarn meeting:all [--from 2024-01-01] [--to 2024-02-01] [--organizer alice@x.com] [--participant bob@x.com] [--channel <channelId>] [--mine] [--dry-run] [--page-size 50] [--max-records 200]
*
* Required env:
* FIREFLIES_API_KEY
* TWENTY_API_KEY
*
* Optional env:
* SERVER_URL (defaults to http://localhost:3000)
* FIREFLIES_PLAN (free|pro|business|enterprise)
* AUTO_CREATE_CONTACTS (true|false)
* FIREFLIES_* retry settings (see README)
*/
import * as dotenv from 'dotenv';
import { existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { FirefliesApiClient } from '../src/fireflies-api-client';
import { type HistoricalImportFilters, HistoricalImporter } from '../src/historical-importer';
import { createLogger } from '../src/logger';
import { TwentyCrmService } from '../src/twenty-crm-service';
import {
getApiUrl,
getFirefliesPlan,
getSummaryFetchConfig,
shouldAutoCreateContacts,
} from '../src/utils';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const envPath = join(__dirname, '..', '.env');
if (existsSync(envPath)) {
dotenv.config({ path: envPath });
}
const logger = createLogger('cli:meeting:all');
type CliArgs = {
from?: string;
to?: string;
organizer?: string[];
participant?: string[];
channel?: string;
host?: string;
mine?: boolean;
dryRun?: boolean;
pageSize?: number;
maxRecords?: number;
limit?: number;
};
const parseArgs = (argv: string[]): CliArgs => {
const args: CliArgs = {};
const parseNumberArg = (value?: string): number | undefined => {
if (!value) return undefined;
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? undefined : parsed;
};
for (let i = 0; i < argv.length; i += 1) {
const current = argv[i];
const next = argv[i + 1];
switch (current) {
case '--from':
args.from = next;
i += 1;
break;
case '--to':
args.to = next;
i += 1;
break;
case '--organizer':
args.organizer = next ? next.split(',') : [];
i += 1;
break;
case '--participant':
args.participant = next ? next.split(',') : [];
i += 1;
break;
case '--channel':
args.channel = next;
i += 1;
break;
case '--host':
args.host = next;
i += 1;
break;
case '--mine':
args.mine = true;
break;
case '--dry-run':
args.dryRun = true;
break;
case '--page-size':
args.pageSize = parseNumberArg(next);
i += 1;
break;
case '--max-records':
args.maxRecords = parseNumberArg(next);
i += 1;
break;
case '--limit':
args.limit = parseNumberArg(next);
i += 1;
break;
default:
break;
}
}
return args;
};
const parseDate = (value?: string): number | undefined => {
if (!value) {
return undefined;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? undefined : parsed;
};
const main = async (): Promise<void> => {
const args = parseArgs(process.argv.slice(2));
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
const twentyApiKey = process.env.TWENTY_API_KEY || '';
if (!firefliesApiKey) {
console.error('❌ FIREFLIES_API_KEY is required');
process.exit(1);
}
if (!twentyApiKey) {
console.error('❌ TWENTY_API_KEY is required');
process.exit(1);
}
const fromDate = parseDate(args.from);
const toDate = parseDate(args.to);
const filters: HistoricalImportFilters = {
fromDate,
toDate,
organizers: args.organizer,
participants: args.participant,
channelId: args.channel,
hostEmail: args.host,
mine: args.mine,
limit: args.limit,
pageSize: args.pageSize,
maxRecords: args.maxRecords,
};
const summaryConfig = getSummaryFetchConfig();
const plan = getFirefliesPlan();
const autoCreateContacts = shouldAutoCreateContacts();
logger.info(
`Starting historical import (dryRun=${Boolean(args.dryRun)}, plan=${plan}, pageSize=${filters.pageSize ?? 50})`,
);
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
const twentyService = new TwentyCrmService(twentyApiKey, getApiUrl());
const importer = new HistoricalImporter(firefliesClient, twentyService);
const result = await importer.run(filters, {
dryRun: args.dryRun,
autoCreateContacts,
summaryConfig,
plan,
});
console.log('✅ Historical import summary:');
const summary = {
dryRun: result.dryRun,
totalListed: result.totalListed,
imported: result.imported,
skippedExisting: result.skippedExisting,
summaryPending: result.summaryPending,
failed: result.failed,
};
console.log(JSON.stringify(summary, null, 2));
if (result.statuses.length > 0) {
console.log('Status by meeting:');
console.table(
result.statuses.map((s) => ({
meetingId: s.meetingId,
title: s.title ?? '',
status: s.status,
reason: s.reason ?? '',
})),
);
}
if (result.failed.length > 0) {
process.exitCode = 1;
}
};
main().catch((error) => {
console.error('❌ Failed to import historical meetings');
if (error instanceof Error) {
console.error(error.message);
if (error.stack) {
console.error(error.stack);
}
} else {
console.error(String(error));
}
process.exit(1);
});
@@ -1,93 +0,0 @@
/* oxlint-disable no-console */
/**
* Fetch a Fireflies meeting by ID and insert it into Twenty using the same path
* as the webhook handler.
*
* Usage:
* yarn meeting:ingest <meetingId>
* Or
* MEETING_ID=... yarn meeting:ingest
*
* Required env:
* FIREFLIES_API_KEY
* FIREFLIES_WEBHOOK_SECRET
* TWENTY_API_KEY
*
* Optional env:
* SERVER_URL (defaults to http://localhost:3000)
* FIREFLIES_PLAN (free|pro|business|enterprise)
*/
import { createHmac } from 'crypto';
import * as dotenv from 'dotenv';
import { existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { WebhookHandler } from '../src/webhook-handler';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const envPath = join(__dirname, '..', '.env');
if (existsSync(envPath)) {
dotenv.config({ path: envPath });
}
const args = process.argv.slice(2);
const meetingId = args[0] || process.env.MEETING_ID;
if (!meetingId) {
console.error('❌ meetingId is required (arg or MEETING_ID env)');
process.exit(1);
}
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
const twentyApiKey = process.env.TWENTY_API_KEY || '';
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
if (!firefliesApiKey) {
console.error('❌ FIREFLIES_API_KEY is required');
process.exit(1);
}
if (!twentyApiKey) {
console.error('❌ TWENTY_API_KEY is required');
process.exit(1);
}
if (!webhookSecret) {
console.error('❌ FIREFLIES_WEBHOOK_SECRET is required to generate signature');
process.exit(1);
}
const payload = {
meetingId,
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = `sha256=${createHmac('sha256', webhookSecret)
.update(body, 'utf8')
.digest('hex')}`;
const main = async (): Promise<void> => {
console.log(`🚀 Ingesting meeting ${meetingId} via webhook handler`);
const handler = new WebhookHandler();
const result = await handler.handle(payload, {
'x-hub-signature': signature,
body,
});
console.log('✅ Result:');
console.log(JSON.stringify(result, null, 2));
if (result.errors && result.errors.length > 0) {
process.exitCode = 1;
}
};
main().catch((error) => {
console.error('❌ Failed to ingest meeting');
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
@@ -1,241 +0,0 @@
/* oxlint-disable no-console */
/**
* Test script for Fireflies webhook against local Twenty instance
*
* Usage:
* yarn test:webhook
* # or
* npx tsx scripts/test-webhook.ts
*
* Prerequisites:
* 1. Twenty server running on http://localhost:3000
* 2. Fireflies app synced: npx twenty-cli app sync
* 3. Custom fields created: yarn setup:fields
* 4. API key configured (get from Settings > Developers > API Keys)
* 5. Environment variables set (copy .env.example to .env and fill values)
*/
import * as crypto from 'crypto';
import * as dotenv from 'dotenv';
import { existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
// Get __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load environment variables
const envPath = join(__dirname, '..', '.env');
if (existsSync(envPath)) {
dotenv.config({ path: envPath });
} else {
console.warn('⚠️ .env file not found, using environment variables');
}
// Configuration
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000';
const TWENTY_API_KEY = process.env.TWENTY_API_KEY;
const FIREFLIES_WEBHOOK_SECRET = process.env.FIREFLIES_WEBHOOK_SECRET || 'test_secret';
const _FIREFLIES_API_KEY = process.env.FIREFLIES_API_KEY || 'test_api_key';
// Test meeting data (simulating Fireflies API response)
const TEST_MEETING_ID = process.env.MEETING_ID || 'test-meeting-local-' + Date.now();
const CLIENT_REFERENCE_ID = process.env.CLIENT_REFERENCE_ID;
const TEST_WEBHOOK_PAYLOAD = {
meetingId: TEST_MEETING_ID,
eventType: 'Transcription completed',
...(CLIENT_REFERENCE_ID ? { clientReferenceId: CLIENT_REFERENCE_ID } : {}),
};
// Mock Fireflies GraphQL API response
const MOCK_FIREFLIES_RESPONSE = {
data: {
meeting: {
id: TEST_MEETING_ID,
title: 'Local Test Meeting',
date: new Date().toISOString(),
duration: 1800, // 30 minutes
participants: [
{ email: 'test1@example.com', name: 'Test User One' },
{ email: 'test2@example.com', name: 'Test User Two' },
],
organizer_email: 'organizer@example.com',
summary: {
action_items: ['Complete integration testing', 'Review webhook logs'],
keywords: ['testing', 'integration', 'webhook'],
overview: 'This is a test meeting to verify the Fireflies webhook integration.',
gist: 'Quick test summary',
topics_discussed: ['Webhook testing', 'Integration verification'],
meeting_type: 'Test',
},
analytics: {
sentiments: {
positive_pct: 75,
negative_pct: 5,
neutral_pct: 20,
},
},
transcript_url: 'https://app.fireflies.ai/transcript/' + TEST_MEETING_ID,
recording_url: 'https://app.fireflies.ai/recording/' + TEST_MEETING_ID,
summary_status: 'ready',
},
},
};
// Generate HMAC signature
const generateHMACSignature = (body: string, secret: string): string => {
const signature = crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
return `sha256=${signature}`;
};
// Mock Fireflies API fetch (currently unused but kept for reference)
// In production, you'd need to mock this at the network level
const _mockFirefliesFetch = async (url: string, options?: RequestInit) => {
if (url.includes('graphql.fireflies.ai')) {
// Return mock Fireflies API response
return new Response(JSON.stringify(MOCK_FIREFLIES_RESPONSE), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// For Twenty API calls, use real fetch
return fetch(url, options);
};
const main = async () => {
console.log('🧪 Testing Fireflies Webhook Against Local Twenty Instance\n');
console.log(`📍 Server URL: ${SERVER_URL}`);
console.log(`🔑 API Key: ${TWENTY_API_KEY ? '✅ Configured' : '❌ Missing'}`);
console.log(`🔐 Webhook Secret: ${FIREFLIES_WEBHOOK_SECRET ? '✅ Configured' : '⚠️ Using test secret'}\n`);
// Validation
if (!TWENTY_API_KEY) {
console.error('❌ Error: TWENTY_API_KEY is required');
console.error(' Get your API key from: Settings > Developers > API Keys');
process.exit(1);
}
// Prepare webhook payload
const unsignedBody = JSON.stringify(TEST_WEBHOOK_PAYLOAD);
const signature = generateHMACSignature(unsignedBody, FIREFLIES_WEBHOOK_SECRET);
const payloadWithSignature = {
...TEST_WEBHOOK_PAYLOAD,
'x-hub-signature': signature,
};
const body = JSON.stringify(payloadWithSignature);
console.log('📤 Sending webhook payload:');
console.log(JSON.stringify(payloadWithSignature, null, 2));
console.log('\n️ Signature is sent both as header (preferred) and in payload as fallback (headers are not passed to serverless functions)\n');
console.log(`\n🔐 HMAC Signature: ${signature}\n`);
// Check if server is reachable
try {
const healthCheck = await fetch(`${SERVER_URL}/api/health`);
if (!healthCheck.ok) {
throw new Error(`Server health check failed: ${healthCheck.status}`);
}
console.log('✅ Server is reachable\n');
} catch {
console.error(`❌ Cannot reach server at ${SERVER_URL}`);
console.error(' Make sure Twenty is running: cd twenty && yarn dev');
process.exit(1);
}
// Note: In a real test, we'd intercept fetch calls
// For now, we'll make a direct request to the webhook endpoint
// The actual serverless function will call Fireflies API
// This test validates the endpoint is accessible
// Webhook endpoint: The route path from manifest is /webhook/fireflies
// Routes are matched after removing /s/ prefix
// So /s/webhook/fireflies should match the route /webhook/fireflies
const webhookUrl = `${SERVER_URL}/s/webhook/fireflies`;
console.log(`📡 Calling webhook endpoint: ${webhookUrl}\n`);
try {
// Note: This will fail because the serverless function needs to call
// Fireflies API, which we can't easily mock at the endpoint level.
// In development, you might want to set FIREFLIES_API_KEY to a test value
// and mock the Fireflies API endpoint separately.
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TWENTY_API_KEY}`,
'x-hub-signature': signature,
},
body: body,
});
const responseText = await response.text();
let responseData;
try {
responseData = JSON.parse(responseText);
} catch {
responseData = responseText;
}
console.log(`📥 Response Status: ${response.status} ${response.statusText}`);
console.log('📥 Response Body:');
console.log(JSON.stringify(responseData, null, 2));
// Report whether the server appears to have received the header signature
const debugMessages = Array.isArray((responseData as any)?.debug)
? ((responseData as any).debug as string[])
: [];
const headerMissing =
debugMessages.some((msg) => msg.includes('headerKeys=none')) ||
debugMessages.some((msg) => msg.includes('providedSignature=undefined'));
const signatureErrors =
Array.isArray((responseData as any)?.errors) &&
((responseData as any).errors as unknown[]).some(
(err) => typeof err === 'string' && err.toLowerCase().includes('signature'),
);
if (headerMissing) {
console.log(
'\n⚠️ Server did not report any received headers; it may be using payload fallback for signature verification.',
);
} else {
console.log('\n✅ Server reported headers present (header-based signature should be used).');
}
if (signatureErrors) {
console.log('⚠️ Signature was rejected by the server (check webhook secret / payload).');
}
if (response.ok) {
console.log('\n✅ Webhook test completed successfully!');
console.log('\n📋 Next steps:');
console.log(' 1. Check Twenty CRM for new Meeting/Note records');
console.log(' 2. Verify custom fields are populated');
console.log(' 3. Check server logs for any errors');
} else {
console.log('\n⚠️ Webhook returned an error status');
console.log(' This might be expected if Fireflies API key is not configured');
console.log(' or if the meeting data fetch fails.');
}
} catch (error) {
console.error('\n❌ Error calling webhook:');
console.error(error instanceof Error ? error.message : String(error));
console.error('\n💡 Troubleshooting:');
console.error(' 1. Ensure Twenty server is running');
console.error(' 2. Ensure app is synced: npx twenty-cli app sync');
console.error(' 3. Check API key is valid');
console.error(' 4. Verify webhook endpoint exists');
process.exit(1);
}
};
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
@@ -1,11 +0,0 @@
// Serverless function entry point - re-exports from src/lib
export { config, main } from '../../../src';
export type {
FirefliesMeetingData,
FirefliesParticipant,
FirefliesWebhookPayload,
ProcessResult,
SummaryFetchConfig,
SummaryStrategy
} from '../../../src';
@@ -1,718 +0,0 @@
import * as crypto from 'crypto';
import {
main,
type FirefliesMeetingData,
type FirefliesWebhookPayload,
} from '../';
// Helper to generate HMAC signature
const generateHMACSignature = (body: string, secret: string): string => {
const signature = crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
return `sha256=${signature}`;
};
// Mock raw Fireflies API response with full summary (before transformation)
const mockFirefliesApiResponseWithSummary = {
id: 'test-meeting-001',
title: 'Product Demo with Client',
date: '2024-11-02T14:00:00Z',
duration: 1800,
participants: [
'Sarah Sales <sales@company.com>',
'John Client <client@customer.com>',
],
organizer_email: 'sales@company.com',
summary: {
action_items: [
'Follow up with pricing proposal by Friday',
'Schedule technical deep-dive next week',
'Share case studies from similar clients',
],
keywords: ['product demo', 'pricing', 'technical requirements', 'integration'],
overview: 'Successful product demonstration with positive client feedback. Client expressed strong interest in the enterprise plan and requested technical documentation for their IT team.',
gist: 'Product demo went well, client interested in enterprise plan, next steps identified',
topics_discussed: ['product features', 'pricing discussion', 'integration capabilities', 'support options'],
meeting_type: 'Sales Call',
bullet_gist: '• Demonstrated core product features\n• Discussed enterprise pricing\n• Addressed integration questions',
},
analytics: {
sentiments: {
positive_pct: 75,
negative_pct: 10,
neutral_pct: 15,
},
},
transcript_url: 'https://app.fireflies.ai/transcript/test-001',
video_url: 'https://app.fireflies.ai/recording/test-001',
summary_status: 'completed',
};
// Transformed meeting data (after fetchFirefliesMeetingData processes it)
const mockMeetingWithFullSummary: FirefliesMeetingData = {
id: 'test-meeting-001',
title: 'Product Demo with Client',
date: '2024-11-02T14:00:00Z',
duration: 1800,
participants: [
{ email: 'sales@company.com', name: 'Sarah Sales' },
{ email: 'client@customer.com', name: 'John Client' },
],
organizer_email: 'sales@company.com',
summary: {
action_items: [
'Follow up with pricing proposal by Friday',
'Schedule technical deep-dive next week',
'Share case studies from similar clients',
],
keywords: ['product demo', 'pricing', 'technical requirements', 'integration'],
overview: 'Successful product demonstration with positive client feedback. Client expressed strong interest in the enterprise plan and requested technical documentation for their IT team.',
gist: 'Product demo went well, client interested in enterprise plan, next steps identified',
topics_discussed: ['product features', 'pricing discussion', 'integration capabilities', 'support options'],
meeting_type: 'Sales Call',
bullet_gist: '• Demonstrated core product features\n• Discussed enterprise pricing\n• Addressed integration questions',
},
analytics: {
sentiments: {
positive_pct: 75,
negative_pct: 10,
neutral_pct: 15,
},
},
transcript_url: 'https://app.fireflies.ai/transcript/test-001',
video_url: 'https://app.fireflies.ai/recording/test-001',
summary_status: 'completed',
};
// Mock raw API response without summary (processing)
const mockFirefliesApiResponseWithoutSummary = {
id: 'test-meeting-002',
title: 'Team Standup',
date: '2024-11-02T15:00:00Z',
duration: 900,
participants: [
'Alice Developer <dev1@company.com>',
'Bob Developer <dev2@company.com>',
],
organizer_email: 'dev1@company.com',
summary: {
action_items: [],
keywords: [],
overview: '',
gist: '',
topics_discussed: [],
},
transcript_url: 'https://app.fireflies.ai/transcript/test-002',
summary_status: 'processing',
};
// Mock meeting data without summary (processing) - currently unused but kept for reference
const _mockMeetingWithoutSummary = {
id: 'test-meeting-002',
title: 'Team Standup',
date: '2024-11-02T15:00:00Z',
duration: 900,
participants: [
{ email: 'dev1@company.com', name: 'Alice Developer' },
{ email: 'dev2@company.com', name: 'Bob Developer' },
],
organizer_email: 'dev1@company.com',
summary: {
action_items: [],
keywords: [],
overview: '',
gist: '',
topics_discussed: [],
},
transcript_url: 'https://app.fireflies.ai/transcript/test-002',
summary_status: 'processing',
};
// Mock raw API response for team meeting
const mockFirefliesApiResponseTeamMeeting = {
...mockFirefliesApiResponseWithSummary,
id: 'test-team-003',
title: 'Sprint Planning',
participants: [
'Alice Scrum <scrum@company.com>',
'Bob Developer <dev1@company.com>',
'Carol Coder <dev2@company.com>',
'David QA <qa@company.com>',
],
summary: {
...mockFirefliesApiResponseWithSummary.summary,
meeting_type: 'Sprint Planning',
},
};
// Mock team meeting with multiple participants (transformed) - currently unused but kept for reference
const _mockTeamMeeting = {
...mockMeetingWithFullSummary,
id: 'test-team-003',
title: 'Sprint Planning',
participants: [
{ email: 'scrum@company.com', name: 'Alice Scrum' },
{ email: 'dev1@company.com', name: 'Bob Developer' },
{ email: 'dev2@company.com', name: 'Carol Coder' },
{ email: 'qa@company.com', name: 'David QA' },
],
summary: {
...mockMeetingWithFullSummary.summary,
meeting_type: 'Sprint Planning',
},
};
// Mock environment variables
const originalEnv = process.env;
beforeEach(() => {
process.env = {
...originalEnv,
FIREFLIES_WEBHOOK_SECRET: 'test_webhook_secret',
FIREFLIES_API_KEY: 'test_fireflies_api_key',
TWENTY_API_KEY: 'test_twenty_api_key',
SERVER_URL: 'http://localhost:3000',
AUTO_CREATE_CONTACTS: 'true',
DEBUG_LOGS: 'false',
FIREFLIES_SUMMARY_STRATEGY: 'immediate_with_retry',
FIREFLIES_RETRY_ATTEMPTS: '3',
FIREFLIES_RETRY_DELAY: '1000',
};
});
afterEach(() => {
process.env = originalEnv;
jest.clearAllMocks();
});
describe('Fireflies Webhook Integration v2', () => {
describe('Webhook Authentication', () => {
it('should verify HMAC SHA-256 signature from x-hub-signature header', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
// Mock Fireflies API
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
transcript: mockFirefliesApiResponseWithSummary, // Use raw API format
},
}),
});
}
// Twenty API mocks
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
createNote: { id: 'new-note-id' },
createMeeting: { id: 'new-meeting-id' },
},
}),
});
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(result.success).toBe(true);
});
it('should reject requests with invalid signature', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const invalidSignature = 'sha256=invalid_signature_here';
const result = await main(payload, { 'x-hub-signature': invalidSignature, body });
expect(result.success).toBe(false);
expect(result.errors).toContain('Invalid webhook signature');
});
it('should reject requests without signature header', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const result = await main(payload, {});
expect(result.success).toBe(false);
expect(result.errors).toContain('Invalid webhook signature');
});
it('should reject requests with missing webhook secret env var', async () => {
delete process.env.FIREFLIES_WEBHOOK_SECRET;
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const result = await main(payload, {});
expect(result.success).toBe(false);
});
});
describe('Fireflies GraphQL Integration', () => {
it('should fetch meeting data from Fireflies API', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
const firefliesApiMock = jest.fn();
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
firefliesApiMock();
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
transcript: mockFirefliesApiResponseWithSummary, // Use raw API format
},
}),
});
}
// Twenty API mocks
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
createNote: { id: 'new-note-id' },
createMeeting: { id: 'new-meeting-id' },
},
}),
});
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(firefliesApiMock).toHaveBeenCalled();
expect(result.success).toBe(true);
});
it('should handle Fireflies API fetch failures gracefully', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.reject(new Error('Fireflies API unavailable'));
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({ data: {} }) });
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(result.success).toBe(false);
expect(result.errors?.[0]).toContain('Fireflies API');
});
it('should handle malformed GraphQL responses', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { malformed: 'response' },
}),
});
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({ data: {} }) });
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(result.success).toBe(false);
expect(result.errors?.[0]).toContain('Invalid response from Fireflies API');
});
});
describe('Summary Processing', () => {
it('should create complete records when summary is ready', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { transcript: mockFirefliesApiResponseWithSummary }, // Use raw API format
}),
});
}
// Twenty API mocks
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
createNote: { id: 'new-note-id' },
createMeeting: { id: 'new-meeting-id' },
},
}),
});
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(result.success).toBe(true);
expect(result.summaryReady).toBe(true);
expect(result.actionItemsCount).toBe(3);
expect(result.sentimentAnalysis).toEqual({
positive_pct: 75,
negative_pct: 10,
neutral_pct: 15,
});
expect(result.meetingType).toBe('Sales Call');
expect(result.keyTopics).toEqual(['product features', 'pricing discussion', 'integration capabilities', 'support options']);
});
it('should create basic records when summary is pending', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-002',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { transcript: mockFirefliesApiResponseWithoutSummary }, // Use raw API format
}),
});
}
// Twenty API mocks
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
createNote: { id: 'new-note-id' },
createMeeting: { id: 'new-meeting-id' },
},
}),
});
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(result.success).toBe(true);
expect(result.summaryPending).toBe(true);
expect(result.noteIds || result.meetingId).toBeDefined();
});
it('should retry summary fetch with exponential backoff', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-003',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
let attemptCount = 0;
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
attemptCount++;
// First two attempts return no summary, third returns full summary
if (attemptCount < 3) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { transcript: mockFirefliesApiResponseWithoutSummary }, // Use raw API format
}),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { transcript: mockFirefliesApiResponseWithSummary }, // Use raw API format
}),
});
}
// Twenty API mocks
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
createNote: { id: 'new-note-id' },
createMeeting: { id: 'new-meeting-id' },
},
}),
});
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(attemptCount).toBe(3);
expect(result.success).toBe(true);
expect(result.summaryReady).toBe(true);
});
it('should handle immediate_only strategy with single fetch attempt', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-004',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
let fetchCount = 0;
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://api.fireflies.ai/graphql') {
fetchCount++;
// Return summary not ready
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { transcript: mockFirefliesApiResponseWithoutSummary },
}),
});
}
// Twenty API mocks
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
createNote: { id: 'new-note-id' },
createMeeting: { id: 'new-meeting-id' },
},
}),
});
});
// Override strategy for this test
process.env.FIREFLIES_SUMMARY_STRATEGY = 'immediate_only';
const result = await main(payload, { 'x-hub-signature': signature, body });
// Should only fetch once with immediate_only strategy
expect(fetchCount).toBe(1);
expect(result.success).toBe(true);
expect(result.summaryPending).toBe(true);
// Reset to default
process.env.FIREFLIES_SUMMARY_STRATEGY = 'immediate_with_retry';
});
});
describe('CRM Record Creation', () => {
it('should create summary-focused notes for 1-on-1 meetings', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-meeting-001',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
const createNoteMock = jest.fn();
global.fetch = jest.fn().mockImplementation((url: string, options?: RequestInit) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { transcript: mockFirefliesApiResponseWithSummary }, // Use raw API format
}),
});
}
// Twenty API
const requestBody = options?.body ? JSON.parse(options.body as string) : {};
if (requestBody.query?.includes('createNote')) {
createNoteMock(requestBody.variables);
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { createNote: { id: 'new-note-id' } },
}),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
createMeeting: { id: 'new-meeting-id' },
},
}),
});
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(result.success).toBe(true);
expect(createNoteMock).toHaveBeenCalled();
const noteData = createNoteMock.mock.calls[0][0];
expect(noteData.data.title).toContain('Meeting:');
expect(noteData.data.bodyV2.markdown).toContain('## Overview'); // Markdown header, not bold
expect(noteData.data.bodyV2.markdown).toContain('## Action Items'); // Markdown header, not bold
expect(noteData.data.bodyV2.markdown).toContain('**Sentiment:**'); // This is bold
expect(noteData.data.bodyV2.markdown).toContain('View Full Transcript on Fireflies');
});
it('should create meeting records for multi-party meetings', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-team-003',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
const createMeetingMock = jest.fn();
global.fetch = jest.fn().mockImplementation((url: string, options?: RequestInit) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { transcript: mockFirefliesApiResponseTeamMeeting }, // Use raw API format
}),
});
}
// Twenty API
const requestBody = options?.body ? JSON.parse(options.body as string) : {};
if (requestBody.query?.includes('createMeeting')) {
createMeetingMock(requestBody.variables);
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { createMeeting: { id: 'new-meeting-id' } },
}),
});
}
if (requestBody.query?.includes('createNote')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: { createNote: { id: 'new-note-id' } },
}),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
data: {
meetings: { edges: [] },
people: { edges: [] },
createPerson: { id: 'new-person-id' },
},
}),
});
});
const result = await main(payload, { 'x-hub-signature': signature, body });
expect(result.success).toBe(true);
expect(result.meetingId).toBeDefined();
expect(createMeetingMock).toHaveBeenCalled();
});
});
describe('Error Handling & Resilience', () => {
it('should never throw uncaught exceptions', async () => {
const payload: FirefliesWebhookPayload = {
meetingId: 'test-critical-error',
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = generateHMACSignature(body, 'test_webhook_secret');
global.fetch = jest.fn().mockImplementation(() => {
throw new Error('Critical failure');
});
await expect(main(payload, { 'x-hub-signature': signature, body })).resolves.toEqual(
expect.objectContaining({ success: false, errors: expect.any(Array) })
);
});
it('should handle missing payload gracefully', async () => {
const result = await main(null as unknown, {});
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
});
it('should handle invalid payload structure', async () => {
const invalidPayload = { invalid: 'data' };
const result = await main(invalidPayload as unknown, {});
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
});
});
});
@@ -1,83 +0,0 @@
import { HistoricalImporter } from '../historical-importer';
import type { FirefliesMeetingData, SummaryFetchConfig } from '../types';
const summaryConfig: SummaryFetchConfig = {
strategy: 'immediate_with_retry',
retryAttempts: 1,
retryDelay: 0,
pollInterval: 0,
maxPolls: 0,
};
const sampleMeeting: FirefliesMeetingData = {
id: 'm-1',
title: 'Sample',
date: new Date().toISOString(),
duration: 30,
participants: [],
summary: { action_items: [], overview: '' },
transcript_url: 'https://example.com',
};
describe('HistoricalImporter', () => {
const buildImporter = () => {
const firefliesClient = {
listTranscripts: jest.fn(),
fetchMeetingDataWithRetry: jest.fn(),
} as unknown as jest.Mocked<any>;
const twentyService = {
findMeetingByFirefliesId: jest.fn(),
matchParticipantsToContacts: jest.fn(),
createContactsForUnmatched: jest.fn(),
createNoteOnly: jest.fn(),
createMeeting: jest.fn(),
createNoteTarget: jest.fn(),
} as unknown as jest.Mocked<any>;
return { firefliesClient, twentyService };
};
it('skips meetings that already exist by firefliesMeetingId', async () => {
const { firefliesClient, twentyService } = buildImporter();
firefliesClient.listTranscripts.mockResolvedValue([{ id: 'existing' }]);
twentyService.findMeetingByFirefliesId.mockResolvedValue({ id: 'twenty-id' });
const importer = new HistoricalImporter(firefliesClient, twentyService);
const result = await importer.run(
{},
{ dryRun: false, autoCreateContacts: true, summaryConfig, plan: 'free' },
);
expect(result.skippedExisting).toBe(1);
expect(result.imported).toBe(0);
expect(twentyService.createMeeting).not.toHaveBeenCalled();
expect(result.statuses[0].status).toBe('skipped_existing');
});
it('supports dry-run without writing to Twenty', async () => {
const { firefliesClient, twentyService } = buildImporter();
firefliesClient.listTranscripts.mockResolvedValue([{ id: 'm-2' }]);
firefliesClient.fetchMeetingDataWithRetry.mockResolvedValue({
data: sampleMeeting,
summaryReady: false,
});
twentyService.findMeetingByFirefliesId.mockResolvedValue(undefined);
const importer = new HistoricalImporter(firefliesClient, twentyService);
const result = await importer.run(
{},
{ dryRun: true, autoCreateContacts: false, summaryConfig, plan: 'free' },
);
expect(result.imported).toBe(1);
expect(result.summaryPending).toBe(1);
expect(twentyService.createMeeting).not.toHaveBeenCalled();
expect(twentyService.createNoteOnly).not.toHaveBeenCalled();
expect(result.statuses).toHaveLength(1);
expect(result.statuses[0].status).toBe('pending_summary');
});
});
@@ -1,17 +0,0 @@
// Test setup for Fireflies app
// Mock global fetch for all tests
global.fetch = jest.fn();
// Setup test environment variables
process.env.FIREFLIES_WEBHOOK_SECRET = 'testsecret';
process.env.AUTO_CREATE_CONTACTS = 'true';
process.env.SERVER_URL = 'http://localhost:3000';
process.env.TWENTY_API_KEY = 'test-api-key';
process.env.LOG_LEVEL = 'silent';
process.env.CAPTURE_LOGS = 'false';
// Reset mocks before each test
beforeEach(() => {
jest.clearAllMocks();
});
@@ -1,40 +0,0 @@
import { WebhookHandler } from '../webhook-handler';
describe('WebhookHandler log capture', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = {
...originalEnv,
FIREFLIES_WEBHOOK_SECRET: 'testsecret',
FIREFLIES_API_KEY: '',
TWENTY_API_KEY: '',
CAPTURE_LOGS: 'false',
LOG_LEVEL: 'silent',
};
});
afterEach(() => {
process.env = originalEnv;
});
it('includes debug logs in response when CAPTURE_LOGS is true', async () => {
process.env.CAPTURE_LOGS = 'true';
const handler = new WebhookHandler();
const result = await handler.handle(null);
expect(result.success).toBe(false);
expect(Array.isArray(result.debug)).toBe(true);
});
it('omits debug logs when CAPTURE_LOGS is false', async () => {
process.env.CAPTURE_LOGS = 'false';
const handler = new WebhookHandler();
const result = await handler.handle(null);
expect(result.debug).toBeUndefined();
});
});
@@ -1,823 +0,0 @@
import { createLogger } from './logger';
import {
FIREFLIES_PLANS,
type FirefliesMeetingData,
type FirefliesParticipant,
type FirefliesPlan,
type FirefliesTranscriptListItem,
type FirefliesTranscriptListOptions,
type SummaryFetchConfig
} from './types';
const logger = createLogger('fireflies-api');
export class FirefliesApiClient {
private apiKey: string;
constructor(apiKey: string) {
if (!apiKey) {
logger.critical('FIREFLIES_API_KEY is required but not provided - this is a critical configuration error');
throw new Error('FIREFLIES_API_KEY is required');
}
this.apiKey = apiKey;
}
async listTranscripts(options: FirefliesTranscriptListOptions = {}): Promise<FirefliesTranscriptListItem[]> {
const {
organizers,
participants,
hostEmail,
participantEmail,
userId,
channelId,
mine,
fromDate,
toDate,
pageSize = 50,
maxRecords = 500,
} = options;
const sanitizedOrganizers = organizers?.filter(Boolean);
const sanitizedParticipants = participants?.filter(Boolean);
const transcripts: FirefliesTranscriptListItem[] = [];
let skip = options.skip ?? 0;
const limit = options.limit ?? pageSize;
const baseQuery = `
query Transcripts(
$limit: Int
$skip: Int
$hostEmail: String
$participantEmail: String
$organizers: [String!]
$participants: [String!]
$userId: String
$channelId: String
$mine: Boolean
$date: Float
) {
transcripts(
limit: $limit
skip: $skip
host_email: $hostEmail
participant_email: $participantEmail
organizers: $organizers
participants: $participants
user_id: $userId
channel_id: $channelId
mine: $mine
date: $date
) {
id
title
date
duration
organizer_email
participants
transcript_url
meeting_link
meeting_info { summary_status }
}
}
`;
while (transcripts.length < maxRecords) {
const pageVariables = {
limit,
skip,
hostEmail,
participantEmail,
organizers: sanitizedOrganizers,
participants: sanitizedParticipants,
userId,
channelId,
mine,
date: fromDate,
};
const page = await this.executeTranscriptListQuery(baseQuery, pageVariables);
const normalized = page
.map((item) => {
const normalizedDate = this.normalizeDate(item.date);
return {
id: (item.id as string) || '',
title: (item.title as string) || 'Untitled Meeting',
date: normalizedDate,
duration: (item.duration as number) || 0,
organizer_email: item.organizer_email as string | undefined,
participants: Array.isArray(item.participants)
? (item.participants as string[])
: undefined,
transcript_url: item.transcript_url as string | undefined,
meeting_link: item.meeting_link as string | undefined,
summary_status: (item.meeting_info as { summary_status?: string } | undefined)?.summary_status,
};
})
.filter((item) => {
if (toDate && item.date) {
const itemTime = Date.parse(item.date);
if (!Number.isNaN(itemTime) && itemTime > toDate) {
return false;
}
}
return true;
});
transcripts.push(...normalized);
if (page.length < limit) {
break;
}
skip += limit;
}
if (transcripts.length > maxRecords) {
return transcripts.slice(0, maxRecords);
}
return transcripts;
}
async fetchMeetingData(
meetingId: string,
options?: { timeout?: number; plan?: FirefliesPlan }
): Promise<FirefliesMeetingData> {
const plan = options?.plan ?? FIREFLIES_PLANS.FREE;
const isPremiumPlan =
plan === FIREFLIES_PLANS.BUSINESS || plan === FIREFLIES_PLANS.ENTERPRISE;
// Minimal query for free plans - only basic fields available on all plans
// Note: audio_url requires Pro+, video_url requires Business+
const freeQuery = `
query GetTranscriptMinimal($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
date
duration
participants
organizer_email
transcript_url
meeting_link
}
}
`;
// Standard query for pro plans - adds speakers, summary, sentences, and audio_url (Pro+)
// Note: video_url requires Business+
const proQuery = `
query GetTranscriptBasic($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
date
duration
participants
organizer_email
speakers {
name
}
sentences {
index
speaker_name
text
start_time
end_time
}
summary {
overview
keywords
action_items
notes
gist
bullet_gist
short_summary
short_overview
outline
shorthand_bullet
}
meeting_info {
summary_status
}
transcript_url
audio_url
meeting_link
}
}
`;
// Full query for business/enterprise - includes all fields
const businessQuery = `
query GetTranscriptFull($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
date
duration
participants
organizer_email
analytics {
sentiments {
positive_pct
negative_pct
neutral_pct
}
categories {
questions
tasks
metrics
date_times
}
speakers {
speaker_id
name
duration
word_count
longest_monologue
filler_words
questions
words_per_minute
}
}
meeting_attendees {
displayName
email
phoneNumber
name
location
}
meeting_attendance {
name
join_time
leave_time
}
speakers {
name
}
sentences {
index
speaker_name
text
start_time
end_time
ai_filters {
task
question
sentiment
}
}
summary {
action_items
overview
keywords
notes
gist
bullet_gist
short_summary
short_overview
outline
shorthand_bullet
topics_discussed
meeting_type
transcript_chapters
}
meeting_info {
summary_status
}
transcript_url
audio_url
video_url
meeting_link
}
}
`;
// Select query based on plan
const queryToUse = isPremiumPlan ? businessQuery :
(plan === FIREFLIES_PLANS.PRO ? proQuery : freeQuery);
const planFeatures = {
[FIREFLIES_PLANS.FREE]: 'basic fields only (no summary, no audio/video)',
[FIREFLIES_PLANS.PRO]: 'summary, speakers, audio_url',
[FIREFLIES_PLANS.BUSINESS]: 'full access including analytics, video_url',
[FIREFLIES_PLANS.ENTERPRISE]: 'full access including analytics, video_url',
};
logger.debug(`using ${plan} plan query (${planFeatures[plan]})`);
try {
return await this.executeTranscriptQuery({
meetingId,
query: queryToUse,
timeout: options?.timeout,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// Detect plan-specific errors
const requiresBusiness = message.toLowerCase().includes('business or higher');
const requiresPro = message.toLowerCase().includes('pro or higher');
const planError = requiresBusiness || requiresPro ||
message.toLowerCase().includes('higher plan') ||
message.includes('Cannot query field');
// Fallback cascade: business -> pro -> free
if (planError) {
if (isPremiumPlan) {
logger.warn(`Plan limitation detected (configured: ${plan}), falling back to pro query`);
try {
return await this.executeTranscriptQuery({
meetingId,
query: proQuery,
timeout: options?.timeout,
});
} catch (proError) {
const proMessage = proError instanceof Error ? proError.message : String(proError);
if (proMessage.toLowerCase().includes('plan') || proMessage.includes('Cannot query field')) {
logger.warn('Pro query also failed, falling back to minimal free query');
return this.executeTranscriptQuery({
meetingId,
query: freeQuery,
timeout: options?.timeout,
});
}
throw proError;
}
} else if (plan === FIREFLIES_PLANS.PRO) {
logger.warn(`Pro plan query failed (${requiresBusiness ? 'requires Business+' : 'unknown restriction'}), falling back to free query`);
return this.executeTranscriptQuery({
meetingId,
query: freeQuery,
timeout: options?.timeout,
});
} else {
// Already using free query - some field might still be restricted
logger.error(
'Fireflies API rejected the minimal free query. This may indicate: ' +
'1) The transcript ID is invalid, or ' +
'2) Your API key does not have access to this transcript, or ' +
'3) An unexpected API restriction : open an issue'
);
}
}
throw error;
}
}
async fetchMeetingDataWithRetry(
meetingId: string,
config: SummaryFetchConfig,
plan: FirefliesPlan = FIREFLIES_PLANS.FREE
): Promise<{ data: FirefliesMeetingData; summaryReady: boolean }> {
// immediate_only: single attempt, no retries
if (config.strategy === 'immediate_only') {
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_only)`);
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000, plan });
const ready = this.isSummaryReady(meetingData);
logger.debug(`summary ready: ${ready}`);
return { data: meetingData, summaryReady: ready };
}
// immediate_with_retry: retry with linear backoff
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_with_retry, maxAttempts: ${config.retryAttempts})`);
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
try {
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000, plan });
const ready = this.isSummaryReady(meetingData);
logger.debug(`attempt ${attempt}/${config.retryAttempts}: summary ready=${ready}`);
if (ready) {
return { data: meetingData, summaryReady: true };
}
if (attempt < config.retryAttempts) {
const delayMs = config.retryDelay * attempt;
logger.debug(`summary not ready, waiting ${delayMs}ms before retry ${attempt + 1}`);
await new Promise(resolve => setTimeout(resolve, delayMs));
} else {
logger.debug(`max retries reached, returning partial data`);
return { data: meetingData, summaryReady: false };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(`attempt ${attempt}/${config.retryAttempts} failed: ${errorMsg}`);
if (attempt === config.retryAttempts) {
throw error;
}
const delayMs = config.retryDelay * attempt;
logger.debug(`retrying in ${delayMs}ms...`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
throw new Error('Failed to fetch meeting data after retries');
}
private async executeTranscriptQuery({
meetingId,
query,
timeout,
}: {
meetingId: string;
query: string;
timeout?: number;
}): Promise<FirefliesMeetingData> {
const controller = new AbortController();
const timeoutId = timeout ? setTimeout(() => controller.abort(), timeout) : null;
try {
const response = await fetch('https://api.fireflies.ai/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
query,
variables: { transcriptId: meetingId },
}),
signal: controller.signal,
});
if (timeoutId) clearTimeout(timeoutId);
if (!response.ok) {
let errorDetails = `Fireflies API request failed with status ${response.status}`;
try {
const errorBody = await response.text();
if (errorBody) {
errorDetails += `: ${errorBody}`;
}
} catch {
// Ignore if we can't read the response body
}
throw new Error(errorDetails);
}
const json = await response.json() as {
data?: { transcript?: Record<string, unknown> };
errors?: Array<{ message?: string }>;
};
if (json.errors && json.errors.length > 0) {
throw new Error(`Fireflies API error: ${json.errors[0]?.message || 'Unknown error'}`);
}
const transcript = json.data?.transcript;
if (!transcript) {
throw new Error('Invalid response from Fireflies API: missing transcript data');
}
return this.transformMeetingData(transcript, meetingId);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
private isSummaryReady(meetingData: FirefliesMeetingData): boolean {
return (
(meetingData.summary?.action_items?.length > 0) ||
(meetingData.summary?.overview?.length > 0) ||
meetingData.summary_status === 'completed'
);
}
private extractAllParticipants(transcript: Record<string, unknown>): FirefliesParticipant[] {
const participantsWithEmails: FirefliesParticipant[] = [];
const participantsNameOnly: FirefliesParticipant[] = [];
logger.debug('=== PARTICIPANT EXTRACTION DEBUG ===');
logger.debug('participants field:', JSON.stringify(transcript.participants));
logger.debug('meeting_attendees field:', JSON.stringify(transcript.meeting_attendees));
logger.debug('speakers field:', (transcript.speakers as Array<{ name: string }>)?.map((s) => s.name));
logger.debug('meeting_attendance field:', (transcript.meeting_attendance as Array<{ name: string }>)?.map((a) => a.name));
logger.debug('organizer_email:', transcript.organizer_email);
const isEmail = (str: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str.trim());
};
const isDuplicate = (name: string, email: string): boolean => {
const nameLower = name.toLowerCase().trim();
const emailLower = email.toLowerCase().trim();
return participantsWithEmails.some(p =>
p.name.toLowerCase().trim() === nameLower ||
(email && p.email.toLowerCase() === emailLower)
) || participantsNameOnly.some(p =>
p.name.toLowerCase().trim() === nameLower
);
};
// 1. Extract from legacy participants field (with emails)
if (transcript.participants && Array.isArray(transcript.participants)) {
transcript.participants.forEach((participant: string) => {
const parts = participant.split(',').map(p => p.trim());
parts.forEach(part => {
const emailMatch = part.match(/<([^>]+)>/);
const email = emailMatch ? emailMatch[1] : '';
const name = emailMatch
? part.substring(0, part.indexOf('<')).trim()
: part.trim();
if (isEmail(name)) {
logger.debug(`Skipping participant with email as name: "${name}"`);
return;
}
if (!name) {
return;
}
if (isDuplicate(name, email)) {
logger.debug(`Skipping duplicate participant: "${name}" <${email}>`);
return;
}
if (name && email) {
participantsWithEmails.push({ name, email });
} else if (name) {
participantsNameOnly.push({ name, email: '' });
}
});
});
}
// 2. Extract from meeting_attendees field (structured)
if (transcript.meeting_attendees && Array.isArray(transcript.meeting_attendees)) {
transcript.meeting_attendees.forEach((attendee: Record<string, unknown>) => {
const name = (attendee.displayName || attendee.name || '') as string;
const email = (attendee.email || '') as string;
if (isEmail(name)) {
logger.debug(`Skipping attendee with email as name: "${name}"`);
return;
}
if (name && !isDuplicate(name, email)) {
if (email) {
participantsWithEmails.push({ name, email });
} else {
participantsNameOnly.push({ name, email: '' });
}
}
});
}
// 3. Extract from speakers field (name only)
if (transcript.speakers && Array.isArray(transcript.speakers)) {
transcript.speakers.forEach((speaker: Record<string, unknown>) => {
const name = (speaker.name || '') as string;
if (isEmail(name)) {
logger.debug(`Skipping speaker with email as name: "${name}"`);
return;
}
if (name && !isDuplicate(name, '')) {
participantsNameOnly.push({ name, email: '' });
}
});
}
// 4. Extract from meeting_attendance field (name only)
if (transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
transcript.meeting_attendance.forEach((attendance: Record<string, unknown>) => {
const name = (attendance.name || '') as string;
if (isEmail(name) || name.includes(',')) {
logger.debug(`Skipping attendance with email/list as name: "${name}"`);
return;
}
if (name && !isDuplicate(name, '')) {
participantsNameOnly.push({ name, email: '' });
}
});
}
// 5. Add organizer email if available and not already included
const organizerEmail = transcript.organizer_email as string | undefined;
if (organizerEmail) {
const existsWithEmail = participantsWithEmails.some(p =>
p.email.toLowerCase() === organizerEmail.toLowerCase()
);
if (!existsWithEmail) {
let organizerName = '';
const emailUsername = organizerEmail.split('@')[0].toLowerCase();
const emailNameVariations = [emailUsername];
if (transcript.speakers && Array.isArray(transcript.speakers)) {
const potentialOrganizerSpeaker = transcript.speakers.find((speaker: Record<string, unknown>) => {
const name = ((speaker.name || '') as string).toLowerCase();
return emailNameVariations.some(variation =>
name.includes(variation) || variation.includes(name)
);
}) as Record<string, unknown> | undefined;
if (potentialOrganizerSpeaker) {
organizerName = potentialOrganizerSpeaker.name as string;
}
}
if (!organizerName && transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
const potentialOrganizerAttendance = transcript.meeting_attendance.find((attendance: Record<string, unknown>) => {
const name = ((attendance.name || '') as string).toLowerCase();
return emailNameVariations.some(variation =>
name.includes(variation) || variation.includes(name)
);
}) as Record<string, unknown> | undefined;
if (potentialOrganizerAttendance) {
organizerName = potentialOrganizerAttendance.name as string;
}
}
if (organizerName) {
participantsWithEmails.push({ name: organizerName, email: organizerEmail });
const nameIndex = participantsNameOnly.findIndex(p =>
p.name.toLowerCase().includes(organizerName.toLowerCase()) ||
organizerName.toLowerCase().includes(p.name.toLowerCase())
);
if (nameIndex !== -1) {
participantsNameOnly.splice(nameIndex, 1);
}
} else {
participantsWithEmails.push({ name: 'Meeting Organizer', email: organizerEmail });
}
}
}
const allParticipants = [...participantsWithEmails, ...participantsNameOnly];
logger.debug('=== EXTRACTED PARTICIPANTS ===');
logger.debug('With emails:', participantsWithEmails.length, JSON.stringify(participantsWithEmails));
logger.debug('Name only:', participantsNameOnly.length, JSON.stringify(participantsNameOnly));
logger.debug('Total:', allParticipants.length);
return allParticipants;
}
private transformMeetingData(transcript: Record<string, unknown>, meetingId: string): FirefliesMeetingData {
let dateString: string;
if (transcript.date) {
if (typeof transcript.date === 'number') {
dateString = new Date(transcript.date).toISOString();
} else if (typeof transcript.date === 'string') {
const parsed = Number(transcript.date);
if (!isNaN(parsed)) {
dateString = new Date(parsed).toISOString();
} else {
dateString = transcript.date;
}
} else {
dateString = new Date().toISOString();
}
} else {
dateString = new Date().toISOString();
}
const summary = transcript.summary as Record<string, unknown> | undefined;
const analytics = transcript.analytics as Record<string, unknown> | undefined;
const sentiments = analytics?.sentiments as Record<string, number> | undefined;
const categories = analytics?.categories as Record<string, number> | undefined;
const speakersAnalytics = analytics?.speakers as Array<Record<string, unknown>> | undefined;
const meetingInfo = transcript.meeting_info as Record<string, unknown> | undefined;
// Transform sentences array
const rawSentences = transcript.sentences as Array<Record<string, unknown>> | undefined;
const sentences = rawSentences?.map(s => ({
index: (s.index as number) || 0,
speaker_name: (s.speaker_name as string) || 'Unknown',
text: (s.text as string) || '',
start_time: (s.start_time as string) || '0',
end_time: (s.end_time as string) || '0',
ai_filters: s.ai_filters as { task?: boolean; question?: boolean; sentiment?: string } | undefined,
}));
// Transform speaker analytics
const speakers = speakersAnalytics?.map(sp => ({
speaker_id: (sp.speaker_id as string) || '',
name: (sp.name as string) || 'Unknown',
duration: (sp.duration as number) || 0,
word_count: (sp.word_count as number) || 0,
longest_monologue: (sp.longest_monologue as number) || 0,
filler_words: (sp.filler_words as number) || 0,
questions: (sp.questions as number) || 0,
words_per_minute: (sp.words_per_minute as number) || 0,
}));
return {
id: (transcript.id as string) || meetingId,
title: (transcript.title as string) || 'Untitled Meeting',
date: dateString,
duration: (transcript.duration as number) || 0,
participants: this.extractAllParticipants(transcript),
organizer_email: transcript.organizer_email as string | undefined,
sentences,
summary: {
// action_items can be string or array - normalize to array
action_items: Array.isArray(summary?.action_items)
? summary.action_items as string[]
: (typeof summary?.action_items === 'string' && summary.action_items.trim()
? summary.action_items.split('\n').filter((item: string) => item.trim())
: []),
overview: (summary?.overview as string) || '',
notes: summary?.notes as string | undefined,
gist: summary?.gist as string | undefined,
bullet_gist: summary?.bullet_gist as string | undefined,
short_summary: summary?.short_summary as string | undefined,
short_overview: summary?.short_overview as string | undefined,
outline: summary?.outline as string | undefined,
shorthand_bullet: summary?.shorthand_bullet as string | undefined,
keywords: summary?.keywords as string[] | undefined,
topics_discussed: summary?.topics_discussed as string[] | undefined,
meeting_type: summary?.meeting_type as string | undefined,
transcript_chapters: summary?.transcript_chapters as string[] | undefined,
},
analytics: (sentiments || categories || speakers) ? {
sentiments: sentiments ? {
positive_pct: sentiments.positive_pct || 0,
negative_pct: sentiments.negative_pct || 0,
neutral_pct: sentiments.neutral_pct || 0,
} : undefined,
categories: categories ? {
questions: categories.questions || 0,
tasks: categories.tasks || 0,
metrics: categories.metrics || 0,
date_times: categories.date_times || 0,
} : undefined,
speakers,
} : undefined,
meeting_info: meetingInfo ? {
summary_status: meetingInfo.summary_status as string | undefined,
} : undefined,
// URLs by plan availability:
transcript_url: (transcript.transcript_url as string) || `https://app.fireflies.ai/view/${meetingId}`,
audio_url: transcript.audio_url as string | undefined, // Pro+
video_url: transcript.video_url as string | undefined, // Business+
meeting_link: transcript.meeting_link as string | undefined, // All plans
summary_status: (meetingInfo?.summary_status as string) || (transcript.summary_status as string) || undefined,
};
}
private async executeTranscriptListQuery(
query: string,
variables: Record<string, unknown>,
): Promise<Array<Record<string, unknown>>> {
const response = await fetch('https://api.fireflies.ai/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Fireflies transcripts request failed: ${response.status} ${errorBody}`);
}
const json = await response.json() as {
data?: { transcripts?: Array<Record<string, unknown>> };
errors?: Array<{ message?: string }>;
};
if (json.errors && json.errors.length > 0) {
const message = json.errors[0]?.message || 'Unknown error';
throw new Error(`Fireflies API error: ${message}`);
}
return json.data?.transcripts ?? [];
}
private normalizeDate(dateValue: unknown): string | undefined {
if (!dateValue) {
return undefined;
}
if (typeof dateValue === 'number') {
return new Date(dateValue).toISOString();
}
if (typeof dateValue === 'string') {
const parsed = Number(dateValue);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toISOString();
}
return dateValue;
}
return undefined;
}
}
@@ -1,259 +0,0 @@
import type { FirefliesMeetingData, FirefliesSentence, MeetingCreateInput } from './types';
export class MeetingFormatter {
// Format timestamp from seconds to MM:SS
private static formatTimestamp(timeStr: string): string {
const seconds = parseFloat(timeStr);
if (isNaN(seconds)) return '00:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// Format full transcript from sentences
private static formatTranscript(sentences: FirefliesSentence[]): string {
if (!sentences || sentences.length === 0) return '';
let transcript = '';
let currentSpeaker = '';
for (const sentence of sentences) {
const timestamp = this.formatTimestamp(sentence.start_time);
const speaker = sentence.speaker_name || 'Unknown';
// Add speaker header when speaker changes
if (speaker !== currentSpeaker) {
currentSpeaker = speaker;
transcript += `\n**${speaker}** [${timestamp}]\n`;
}
transcript += `${sentence.text} `;
}
return transcript.trim();
}
static formatNoteBody(meetingData: FirefliesMeetingData): string {
const meetingDate = meetingData.date ? new Date(meetingData.date) : null;
const hasValidDate = meetingDate instanceof Date && !Number.isNaN(meetingDate.getTime());
const formattedDate = hasValidDate
? meetingDate.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: 'Unknown date';
const durationMinutes = Math.round(meetingData.duration);
let noteBody = `**Date:** ${formattedDate}\n`;
noteBody += `**Duration:** ${durationMinutes} minutes\n`;
if (meetingData.participants.length > 0) {
const participantNames = meetingData.participants.map(p => p.name).join(', ');
noteBody += `**Participants:** ${participantNames}\n`;
}
// Overview section
if (meetingData.summary?.overview) {
noteBody += `\n## Overview\n${meetingData.summary.overview}\n`;
}
// Detailed AI Notes (the rich content from Fireflies)
if (meetingData.summary?.notes) {
noteBody += `\n## Meeting Notes\n${meetingData.summary.notes}\n`;
}
// Bullet gist with emojis (if available and different from notes)
if (meetingData.summary?.bullet_gist && !meetingData.summary?.notes) {
noteBody += `\n## Key Points\n${meetingData.summary.bullet_gist}\n`;
}
// Meeting outline with timestamps (shorthand_bullet contains the timestamped outline)
const outline = meetingData.summary?.outline || meetingData.summary?.shorthand_bullet;
if (outline) {
noteBody += `\n## Outline\n${outline}\n`;
}
// Key topics
if (meetingData.summary?.topics_discussed?.length) {
noteBody += `\n## Key Topics\n`;
meetingData.summary.topics_discussed.forEach(topic => {
noteBody += `- ${topic}\n`;
});
}
// Action items
if (meetingData.summary?.action_items?.length) {
noteBody += `\n## Action Items\n`;
meetingData.summary.action_items.forEach(item => {
noteBody += `- [ ] ${item}\n`;
});
}
// Insights section
noteBody += `\n## Insights\n`;
if (meetingData.summary?.keywords?.length) {
noteBody += `**Keywords:** ${meetingData.summary.keywords.join(', ')}\n`;
}
if (meetingData.analytics?.sentiments) {
const sentiments = meetingData.analytics.sentiments;
noteBody += `**Sentiment:** ${sentiments.positive_pct}% positive, ${sentiments.negative_pct}% negative, ${sentiments.neutral_pct}% neutral\n`;
}
if (meetingData.summary?.meeting_type) {
noteBody += `**Meeting Type:** ${meetingData.summary.meeting_type}\n`;
}
// Speaker analytics (Business+)
if (meetingData.analytics?.speakers?.length) {
noteBody += `\n### Speaker Stats\n`;
for (const speaker of meetingData.analytics.speakers) {
const talkTime = Math.round(speaker.duration / 60);
noteBody += `- **${speaker.name}**: ${talkTime} min talk time, ${speaker.word_count} words, ${speaker.questions} questions\n`;
}
}
// Meeting metrics (Business+)
if (meetingData.analytics?.categories) {
const cats = meetingData.analytics.categories;
noteBody += `\n### Meeting Metrics\n`;
noteBody += `- Questions asked: ${cats.questions}\n`;
noteBody += `- Tasks identified: ${cats.tasks}\n`;
if (cats.metrics > 0) noteBody += `- Metrics mentioned: ${cats.metrics}\n`;
if (cats.date_times > 0) noteBody += `- Dates/times discussed: ${cats.date_times}\n`;
}
// Resources section
noteBody += `\n## Resources\n`;
noteBody += `[View Full Transcript on Fireflies](${meetingData.transcript_url})\n`;
if (meetingData.video_url) {
noteBody += `[Watch Video Recording](${meetingData.video_url})\n`;
}
if (meetingData.audio_url) {
noteBody += `[Listen to Audio](${meetingData.audio_url})\n`;
}
if (meetingData.meeting_link) {
noteBody += `[Original Meeting Link](${meetingData.meeting_link})\n`;
}
return noteBody;
}
static toMeetingCreateInput(
meetingData: FirefliesMeetingData,
noteId?: string
): MeetingCreateInput {
const durationMinutes = Math.round(meetingData.duration);
const hasSummary = Boolean(meetingData.summary?.overview || meetingData.summary?.action_items?.length);
const hasAnalytics = Boolean(meetingData.analytics?.sentiments);
// Build input object with only defined values (omit null fields)
const input: MeetingCreateInput = {
name: meetingData.title,
meetingDate: meetingData.date,
duration: durationMinutes,
actionItemsCount: meetingData.summary?.action_items?.length || 0,
firefliesMeetingId: meetingData.id,
};
// Add direct relationship to note if noteId is provided
if (noteId) {
input.noteId = noteId;
}
// Basic fields (All plans)
if (meetingData.organizer_email) {
input.organizerEmail = meetingData.organizer_email;
}
if (meetingData.transcript_url?.trim()) {
input.transcriptUrl = {
primaryLinkUrl: meetingData.transcript_url,
primaryLinkLabel: 'View Transcript'
};
}
if (meetingData.meeting_link?.trim()) {
input.meetingLink = {
primaryLinkUrl: meetingData.meeting_link,
primaryLinkLabel: 'Join Meeting'
};
}
// Pro+ fields (transcript, summary, notes, keywords, audio)
if (meetingData.sentences?.length) {
input.transcript = this.formatTranscript(meetingData.sentences);
}
if (meetingData.summary?.overview) {
input.overview = meetingData.summary.overview;
}
if (meetingData.summary?.notes) {
input.notes = meetingData.summary.notes;
}
if (meetingData.summary?.keywords?.length) {
input.keywords = meetingData.summary.keywords.join(', ');
}
if (meetingData.audio_url?.trim()) {
input.audioUrl = {
primaryLinkUrl: meetingData.audio_url,
primaryLinkLabel: 'Listen to Audio'
};
}
// Business+ fields (analytics, video, detailed summary)
if (meetingData.summary?.meeting_type) {
input.meetingType = meetingData.summary.meeting_type;
}
if (meetingData.summary?.topics_discussed?.length) {
input.topics = meetingData.summary.topics_discussed.join(', ');
}
if (meetingData.analytics?.sentiments) {
const sentiments = meetingData.analytics.sentiments;
input.positivePercent = sentiments.positive_pct;
input.negativePercent = sentiments.negative_pct;
input.neutralPercent = sentiments.neutral_pct;
}
if (meetingData.video_url?.trim()) {
input.videoUrl = {
primaryLinkUrl: meetingData.video_url,
primaryLinkLabel: 'Watch Video'
};
}
// Import status based on data completeness
const isPartial = !hasSummary && !hasAnalytics;
input.importStatus = isPartial ? 'PARTIAL' : 'SUCCESS';
input.lastImportAttempt = new Date().toISOString();
input.importAttempts = 1;
return input;
}
static toFailedMeetingCreateInput(
meetingId: string,
title: string,
error: string,
attempts: number = 1
): MeetingCreateInput {
const currentDate = new Date().toISOString();
return {
name: title || `Failed Meeting Import - ${meetingId}`,
meetingDate: currentDate,
duration: 0,
actionItemsCount: 0,
firefliesMeetingId: meetingId,
importStatus: 'FAILED',
importError: error,
lastImportAttempt: currentDate,
importAttempts: attempts,
};
}
}
@@ -1,160 +0,0 @@
import { type FirefliesApiClient } from './fireflies-api-client';
import { MeetingFormatter } from './formatters';
import { createLogger } from './logger';
import { type TwentyCrmService } from './twenty-crm-service';
import type {
FirefliesPlan,
FirefliesTranscriptListOptions,
SummaryFetchConfig,
} from './types';
const logger = createLogger('historical-importer');
export type HistoricalImportFilters = FirefliesTranscriptListOptions;
export type HistoricalImportOptions = {
dryRun?: boolean;
autoCreateContacts: boolean;
summaryConfig: SummaryFetchConfig;
plan: FirefliesPlan;
};
export type HistoricalImportResult = {
dryRun: boolean;
totalListed: number;
imported: number;
skippedExisting: number;
failed: Array<{ meetingId: string; reason: string }>;
summaryPending: number;
statuses: Array<{
meetingId: string;
title?: string;
status: 'imported' | 'skipped_existing' | 'failed' | 'dry_run' | 'pending_summary';
reason?: string;
}>;
};
export class HistoricalImporter {
private firefliesClient: FirefliesApiClient;
private twentyService: TwentyCrmService;
constructor(firefliesClient: FirefliesApiClient, twentyService: TwentyCrmService) {
this.firefliesClient = firefliesClient;
this.twentyService = twentyService;
}
async run(
filters: HistoricalImportFilters,
options: HistoricalImportOptions,
): Promise<HistoricalImportResult> {
const { dryRun = false, autoCreateContacts, summaryConfig, plan } = options;
logger.info('Listing Fireflies transcripts for historical import...');
const transcripts = await this.firefliesClient.listTranscripts(filters);
logger.info(`Found ${transcripts.length} transcript(s) to process`);
let imported = 0;
let skippedExisting = 0;
let summaryPending = 0;
const failed: Array<{ meetingId: string; reason: string }> = [];
const statuses: HistoricalImportResult['statuses'] = [];
for (const transcript of transcripts) {
const meetingId = transcript.id;
try {
const existing = await this.twentyService.findMeetingByFirefliesId(meetingId);
if (existing) {
logger.debug(`Skipping ${meetingId}: already exists in Twenty (${existing.id})`);
skippedExisting += 1;
statuses.push({
meetingId,
title: transcript.title,
status: 'skipped_existing',
});
continue;
}
logger.info(`Fetching meeting ${meetingId} details`);
const { data: meetingData, summaryReady } =
await this.firefliesClient.fetchMeetingDataWithRetry(
meetingId,
summaryConfig,
plan,
);
const isPendingSummary = summaryReady === false;
if (isPendingSummary) {
summaryPending += 1;
}
if (dryRun) {
logger.info(`[dry-run] Would import meeting "${meetingData.title}" (${meetingId})`);
imported += 1;
statuses.push({
meetingId,
title: meetingData.title,
status: isPendingSummary ? 'pending_summary' : 'dry_run',
reason: isPendingSummary ? 'summary not ready' : undefined,
});
continue;
}
const { matchedContacts, unmatchedParticipants } =
await this.twentyService.matchParticipantsToContacts(
meetingData.participants,
);
const newContactIds = autoCreateContacts
? await this.twentyService.createContactsForUnmatched(unmatchedParticipants)
: [];
const allContactIds = [...matchedContacts.map(({ id }) => id), ...newContactIds];
const noteBody = MeetingFormatter.formatNoteBody(meetingData);
const noteId = await this.twentyService.createNoteOnly(
`Meeting: ${meetingData.title}`,
noteBody,
);
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
const createdMeetingId = await this.twentyService.createMeeting(meetingInput);
for (const contactId of allContactIds) {
await this.twentyService.createNoteTarget(noteId, contactId);
}
logger.info(
`Imported meeting "${meetingData.title}" (${meetingId}) as ${createdMeetingId}`,
);
imported += 1;
statuses.push({
meetingId,
title: meetingData.title,
status: isPendingSummary ? 'pending_summary' : 'imported',
reason: isPendingSummary ? 'summary not ready' : undefined,
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
logger.error(`Failed to import meeting ${meetingId}: ${reason}`);
failed.push({ meetingId, reason });
statuses.push({
meetingId,
title: transcript.title,
status: 'failed',
reason,
});
}
}
return {
dryRun,
totalListed: transcripts.length,
imported,
skippedExisting,
failed,
summaryPending,
statuses,
};
}
}
@@ -1,31 +0,0 @@
// Main exports for the Fireflies integration
export { config, main } from './receive-fireflies-notes';
// Types
export type {
FirefliesMeetingData,
FirefliesParticipant,
FirefliesWebhookPayload,
ProcessResult,
SummaryFetchConfig,
SummaryStrategy
} from './types';
// Services (for advanced usage)
export { FirefliesApiClient } from './fireflies-api-client';
export { MeetingFormatter } from './formatters';
export { TwentyCrmService } from './twenty-crm-service';
export { WebhookHandler } from './webhook-handler';
// Objects
export { Meeting } from './objects';
// Utilities
export { createLogger } from './logger';
export { getApiUrl, getSummaryFetchConfig, shouldAutoCreateContacts, toBoolean } from './utils';
export {
getWebhookSecretFingerprint,
isValidFirefliesPayload,
verifyWebhookSignature
} from './webhook-validator';
@@ -1,142 +0,0 @@
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
export type LoggerConfig = {
logLevel: LogLevel;
isTestEnvironment: boolean;
captureForResponse: boolean;
};
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
silent: 4,
};
const loggerRegistry = new Set<AppLogger>();
export class AppLogger {
private config: LoggerConfig;
private context: string;
private capturedLogs: string[] = [];
constructor(context: string) {
this.context = context;
this.config = {
logLevel: this.parseLogLevel(process.env.LOG_LEVEL || 'error'),
isTestEnvironment: process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined,
captureForResponse: process.env.CAPTURE_LOGS === 'true',
};
// Silence logs in test environment unless explicitly overridden
if (this.config.isTestEnvironment && process.env.LOG_LEVEL === undefined) {
this.config.logLevel = 'silent';
}
}
private parseLogLevel(level: string): LogLevel {
const normalizedLevel = level.toLowerCase() as LogLevel;
return Object.keys(LOG_LEVELS).includes(normalizedLevel) ? normalizedLevel : 'error';
}
private shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[this.config.logLevel];
}
private safeStringify(value: unknown): string {
try {
if (typeof value === 'string') return value;
return JSON.stringify(value);
} catch {
return '[unserializable]';
}
}
private captureLog(level: LogLevel, message: string, ...args: unknown[]): void {
if (!this.config.captureForResponse) {
return;
}
const timestamp = new Date().toISOString();
const formattedMessage =
args.length > 0
? `${message} ${args.map((arg) => this.safeStringify(arg)).join(' ')}`
: message;
this.capturedLogs.push(
`[${timestamp}] [${level.toUpperCase()}] [${this.context}] ${formattedMessage}`,
);
}
debug(message: string, ...args: unknown[]): void {
this.captureLog('debug', message, ...args);
if (this.shouldLog('debug')) {
// oxlint-disable-next-line no-console
console.log(`[${this.context}] ${message}`, ...args);
}
}
info(message: string, ...args: unknown[]): void {
this.captureLog('info', message, ...args);
if (this.shouldLog('info')) {
// oxlint-disable-next-line no-console
console.log(`[${this.context}] ${message}`, ...args);
}
}
warn(message: string, ...args: unknown[]): void {
this.captureLog('warn', message, ...args);
if (this.shouldLog('warn')) {
// oxlint-disable-next-line no-console
console.warn(`[${this.context}] ${message}`, ...args);
}
}
error(message: string, ...args: unknown[]): void {
this.captureLog('error', message, ...args);
if (this.shouldLog('error')) {
// oxlint-disable-next-line no-console
console.error(`[${this.context}] ${message}`, ...args);
}
}
// For fatal errors, security issues, or data corruption - always visible
critical(message: string, ...args: unknown[]): void {
this.captureLog('error', `CRITICAL: ${message}`, ...args);
// oxlint-disable-next-line no-console
console.error(`[${this.context}] CRITICAL: ${message}`, ...args);
}
getCapturedLogs(): string[] {
return [...this.capturedLogs];
}
clearCapturedLogs(): void {
this.capturedLogs = [];
}
}
export const createLogger = (context: string): AppLogger => {
const logger = new AppLogger(context);
loggerRegistry.add(logger);
return logger;
};
export const removeLogger = (logger: AppLogger): void => {
loggerRegistry.delete(logger);
};
export const getAllCapturedLogs = (): string[] => {
const allLogs: string[] = [];
for (const logger of loggerRegistry) {
allLogs.push(...logger.getCapturedLogs());
}
return allLogs.sort();
};
export const clearAllCapturedLogs = (): void => {
for (const logger of loggerRegistry) {
logger.clearCapturedLogs();
}
};
@@ -1,2 +0,0 @@
export { Meeting } from './meeting';
@@ -1,14 +0,0 @@
import { Object } from 'twenty-sdk/define';
@Object({
universalIdentifier: 'd1831348-b4a4-4426-9c0b-0af19e7a9c27',
nameSingular: 'meeting',
namePlural: 'meetings',
labelSingular: 'Meeting',
labelPlural: 'Meetings',
description:
'Meetings imported from Fireflies with AI-generated summaries, sentiment, and action items.',
icon: 'IconVideo',
})
export class Meeting {}
@@ -1,29 +0,0 @@
import { type FunctionConfig } from 'twenty-sdk/define';
import type { ProcessResult } from './types';
import { WebhookHandler } from './webhook-handler';
export const main = async (
params: unknown,
headers?: Record<string, string>
): Promise<ProcessResult> => {
const handler = new WebhookHandler();
return handler.handle(params, headers);
};
export const config: FunctionConfig = {
universalIdentifier: '2d3ea303-667c-4bbe-9e3d-db6ffb9d6c74',
name: 'receive-fireflies-notes',
description:
'Receives Fireflies webhooks, fetches meeting summaries, and stores them in Twenty.',
timeoutSeconds: 30,
triggers: [
{
universalIdentifier: 'a2117dc1-7674-4c7e-9d70-9feb9820e9e8',
type: 'route',
path: '/webhook/fireflies',
httpMethod: 'POST',
isAuthRequired: false,
},
],
};
@@ -1,577 +0,0 @@
import { createLogger } from './logger';
import type {
Contact,
CreateMeetingResponse,
CreateNoteResponse,
CreatePersonResponse,
FindMeetingResponse,
FindPeopleResponse,
FirefliesParticipant,
GraphQLResponse,
IdNode,
MeetingCreateInput,
} from './types';
const logger = createLogger('20 CRM Service');
export class TwentyCrmService {
private apiKey: string;
private apiUrl: string;
private isTestEnvironment: boolean;
constructor(apiKey: string, apiUrl: string) {
if (!apiKey) {
logger.critical('TWENTY_API_KEY is required but not provided - this is a critical configuration error');
throw new Error('TWENTY_API_KEY is required');
}
this.apiKey = apiKey;
this.apiUrl = apiUrl;
this.isTestEnvironment = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined;
}
async findExistingMeeting(title: string): Promise<IdNode | undefined> {
const query = `
query FindMeeting($title: String!) {
meetings(filter: { name: { eq: $title } }) {
edges { node { id } }
}
}
`;
const variables = { title };
const response = await this.gqlRequest<FindMeetingResponse>(query, variables);
return response.data?.meetings?.edges?.[0]?.node;
}
async findMeetingByFirefliesId(meetingId: string): Promise<IdNode | undefined> {
const query = `
query FindMeetingByFirefliesId($meetingId: String!) {
meetings(filter: { firefliesMeetingId: { eq: $meetingId } }) {
edges { node { id } }
}
}
`;
const variables = { meetingId };
const response = await this.gqlRequest<FindMeetingResponse>(query, variables);
return response.data?.meetings?.edges?.[0]?.node;
}
async matchParticipantsToContacts(
participants: FirefliesParticipant[],
): Promise<{
matchedContacts: Contact[];
unmatchedParticipants: FirefliesParticipant[];
}> {
if (participants.length === 0) {
return { matchedContacts: [], unmatchedParticipants: [] };
}
const participantsWithEmails = participants.filter(p => p.email && p.email.trim());
const participantsNameOnly = participants.filter(p => !p.email || !p.email.trim());
let matchedContacts: Contact[] = [];
let unmatchedParticipants: FirefliesParticipant[] = [];
if (participantsWithEmails.length > 0) {
const emailMatches = await this.matchByEmail(participantsWithEmails);
matchedContacts.push(...emailMatches.matchedContacts);
unmatchedParticipants.push(...emailMatches.unmatchedParticipants);
}
if (participantsNameOnly.length > 0) {
const nameMatches = await this.matchByName(participantsNameOnly, matchedContacts);
matchedContacts.push(...nameMatches.matchedContacts);
unmatchedParticipants.push(...nameMatches.unmatchedParticipants);
}
return { matchedContacts, unmatchedParticipants };
}
private async matchByEmail(participants: FirefliesParticipant[]): Promise<{
matchedContacts: Contact[];
unmatchedParticipants: FirefliesParticipant[];
}> {
const emails = participants.map(({ email }) => email).filter(Boolean);
const query = `
query FindPeople($emails: [String!]!) {
people(filter: { emails: { primaryEmail: { in: $emails } } }) {
edges { node { id emails { primaryEmail } } }
}
}
`;
const variables = { emails };
const response = await this.gqlRequest<FindPeopleResponse>(query, variables);
const people = response.data?.people;
if (!people?.edges) {
return { matchedContacts: [], unmatchedParticipants: participants };
}
const matchedContacts = people.edges.map(({ node }) => ({
id: node.id,
email: node.emails?.primaryEmail || ''
}));
const matchedEmails = new Set(
matchedContacts
.map(({ email }) => email)
.filter((email) => Boolean(email)),
);
const unmatchedParticipants = participants.filter(
({ email }) => !matchedEmails.has(email)
);
return { matchedContacts, unmatchedParticipants };
}
private async matchByName(
participants: FirefliesParticipant[],
alreadyMatchedContacts: Contact[]
): Promise<{
matchedContacts: Contact[];
unmatchedParticipants: FirefliesParticipant[];
}> {
const matchedContacts: Contact[] = [];
const unmatchedParticipants: FirefliesParticipant[] = [];
const alreadyMatchedIds = new Set(alreadyMatchedContacts.map(c => c.id));
for (const participant of participants) {
const nameMatch = await this.findContactByName(participant.name);
if (nameMatch && !alreadyMatchedIds.has(nameMatch.id)) {
matchedContacts.push(nameMatch);
alreadyMatchedIds.add(nameMatch.id);
} else {
unmatchedParticipants.push(participant);
}
}
return { matchedContacts, unmatchedParticipants };
}
private async findContactByName(name: string): Promise<Contact | null> {
if (!name || !name.trim()) {
return null;
}
const nameParts = name.trim().split(/\s+/);
const firstName = nameParts[0];
const lastName = nameParts.slice(1).join(' ');
const hasLastName = Boolean(lastName);
const query = hasLastName
? `
query FindPeopleByName($firstName: String!, $lastName: String!) {
people(filter: {
and: [
{ name: { firstName: { eq: $firstName } } }
{ name: { lastName: { eq: $lastName } } }
]
}) {
edges { node { id emails { primaryEmail } name { firstName lastName } } }
}
}
`
: `
query FindPeopleByName($firstName: String!) {
people(filter: {
name: { firstName: { eq: $firstName } }
}) {
edges { node { id emails { primaryEmail } name { firstName lastName } } }
}
}
`;
const variables: Record<string, unknown> = hasLastName
? { firstName, lastName }
: { firstName };
try {
const response = await this.gqlRequest<{ people: { edges: Array<{ node: { id: string; emails?: { primaryEmail?: string }; name?: { firstName?: string; lastName?: string } } }> } }>(query, variables);
const people = response.data?.people?.edges;
if (people && people.length > 0) {
const person = people[0].node;
return {
id: person.id,
email: person.emails?.primaryEmail || ''
};
}
if (hasLastName) {
const fuzzyQuery = `
query FindPeopleByNameFuzzy($firstName: String!) {
people(filter: { name: { firstName: { ilike: $firstName } } }) {
edges { node { id emails { primaryEmail } name { firstName lastName } } }
}
}
`;
const fuzzyResponse = await this.gqlRequest<{ people: { edges: Array<{ node: { id: string; emails?: { primaryEmail?: string }; name?: { firstName?: string; lastName?: string } } }> } }>(fuzzyQuery, { firstName: `%${firstName}%` });
const fuzzyPeople = fuzzyResponse.data?.people?.edges;
if (fuzzyPeople && fuzzyPeople.length > 0) {
const bestMatch = fuzzyPeople.find((edge) => {
const personLastName = edge.node.name?.lastName || '';
return personLastName.toLowerCase().includes(lastName.toLowerCase());
});
if (bestMatch) {
const person = bestMatch.node;
return {
id: person.id,
email: person.emails?.primaryEmail || ''
};
}
}
}
return null;
} catch {
return null;
}
}
async createContactsForUnmatched(
participants: FirefliesParticipant[],
): Promise<string[]> {
const newContactIds: string[] = [];
const participantsWithEmails = participants.filter(p => p.email && p.email.trim());
const participantsNameOnly = participants.filter(p => !p.email || !p.email.trim());
if (participantsWithEmails.length > 0) {
const emailContactIds = await this.createContactsWithEmails(participantsWithEmails);
newContactIds.push(...emailContactIds);
}
if (participantsNameOnly.length > 0) {
const nameContactIds = await this.createContactsNameOnly(participantsNameOnly);
newContactIds.push(...nameContactIds);
}
return newContactIds;
}
private async createContactsWithEmails(participants: FirefliesParticipant[]): Promise<string[]> {
const newContactIds: string[] = [];
const uniqueParticipants = participants.reduce<FirefliesParticipant[]>((unique, participant) => {
const existing = unique.find(p => p.email === participant.email);
if (!existing) {
unique.push(participant);
} else {
logger.warn(`Duplicate participant email detected: ${participant.email}. Using first occurrence.`);
}
return unique;
}, []);
for (const participant of uniqueParticipants) {
const [firstName, ...lastNameParts] = participant.name.trim().split(/\s+/);
const lastName = lastNameParts.join(' ');
const mutation = `
mutation CreatePerson($data: PersonCreateInput!) {
createPerson(data: $data) { id }
}
`;
const variables = {
data: {
name: { firstName, lastName },
emails: { primaryEmail: participant.email },
},
};
try {
const response = await this.gqlRequest<CreatePersonResponse>(mutation, variables, {
suppressErrorCodes: ['BAD_USER_INPUT'],
suppressErrorMessageIncludes: ['Duplicate Emails', 'duplicate entry'],
});
if (!response.data?.createPerson?.id) {
throw new Error(`Failed to create contact for ${participant.email}`);
}
newContactIds.push(response.data.createPerson.id);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (errorMessage.includes('Duplicate Emails') || errorMessage.includes('BAD_USER_INPUT')) {
logger.warn(`Skipping contact creation for ${participant.email} due to duplicate email constraint: ${errorMessage}`);
continue;
}
throw error;
}
}
return newContactIds;
}
private async createContactsNameOnly(participants: FirefliesParticipant[]): Promise<string[]> {
const newContactIds: string[] = [];
const uniqueParticipants = participants.reduce<FirefliesParticipant[]>((unique, participant) => {
const existing = unique.find(p =>
p.name.toLowerCase().trim() === participant.name.toLowerCase().trim()
);
if (!existing) {
unique.push(participant);
} else {
logger.warn(`Duplicate participant name detected: ${participant.name}. Using first occurrence.`);
}
return unique;
}, []);
for (const participant of uniqueParticipants) {
const existingContact = await this.findContactByName(participant.name);
if (existingContact) {
logger.warn(`Contact with name "${participant.name}" already exists. Skipping creation.`);
continue;
}
const [firstName, ...lastNameParts] = participant.name.trim().split(/\s+/);
const lastName = lastNameParts.join(' ');
const mutation = `
mutation CreatePerson($data: PersonCreateInput!) {
createPerson(data: $data) { id }
}
`;
const variables = {
data: {
name: { firstName, lastName },
},
};
try {
const response = await this.gqlRequest<CreatePersonResponse>(mutation, variables);
if (!response.data?.createPerson?.id) {
throw new Error(`Failed to create contact for ${participant.name}`);
}
newContactIds.push(response.data.createPerson.id);
logger.debug(`Created contact for name-only participant: ${participant.name}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.warn(`Failed to create contact for ${participant.name}: ${errorMessage}`);
continue;
}
}
return newContactIds;
}
async createNote(
contactId: string,
title: string,
body: string
): Promise<string> {
const noteId = await this.createNoteOnly(title, body);
await this.createNoteTarget(noteId, contactId);
return noteId;
}
async createNoteOnly(
title: string,
body: string
): Promise<string> {
const mutation = `
mutation CreateNote($data: NoteCreateInput!) {
createNote(data: $data) { id }
}
`;
const variables = {
data: {
title,
bodyV2: {
markdown: body.trim()
},
},
};
const response = await this.gqlRequest<CreateNoteResponse>(mutation, variables);
if (!response.data?.createNote?.id) {
throw new Error(`Failed to create note`);
}
return response.data.createNote.id;
}
async createNoteTarget(noteId: string, contactId: string): Promise<void> {
const mutation = `
mutation CreateNoteTarget($data: NoteTargetCreateInput!) {
createNoteTarget(data: $data) {
id
noteId
personId
}
}
`;
const variables = {
data: {
noteId,
personId: contactId,
},
};
await this.gqlRequest<{ createNoteTarget: { id: string; noteId: string; personId: string } }>(mutation, variables);
}
async createMeeting(meetingData: MeetingCreateInput): Promise<string> {
const mutation = `
mutation CreateMeeting($data: MeetingCreateInput!) {
createMeeting(data: $data) { id }
}
`;
const variables = { data: meetingData };
if (!this.isTestEnvironment) {
logger.debug('createMeeting variables:', JSON.stringify(variables, null, 2));
}
const response = await this.gqlRequest<CreateMeetingResponse>(mutation, variables);
if (!response.data?.createMeeting?.id) {
throw new Error('Failed to create meeting: Invalid response from server');
}
return response.data.createMeeting.id;
}
private async gqlRequest<T>(
query: string,
variables?: Record<string, unknown>,
options?: { suppressErrorCodes?: string[]; suppressErrorMessageIncludes?: string[] }
): Promise<GraphQLResponse<T>> {
const url = `${this.apiUrl}/graphql`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({ query, variables }),
});
if (!res.ok) {
let errorMessage = `GraphQL request failed with status ${res.status}`;
try {
const errorText = await res.text();
if (errorText) {
errorMessage += `: ${errorText}`;
}
} catch {
// Ignore error when reading response body
}
throw new Error(errorMessage);
}
const json = await res.json() as GraphQLResponse<T> & {
errors?: Array<{ message?: string; extensions?: Record<string, unknown> }>
};
if (json?.errors && Array.isArray(json.errors) && json.errors.length > 0) {
const firstError = json.errors[0];
const errorMessage = firstError?.message || 'GraphQL error';
const errorCode = firstError?.extensions?.code as string | undefined;
if (errorCode) {
throw new Error(`${errorMessage} (Code: ${errorCode})`);
}
throw new Error(errorMessage);
}
return json;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const suppressByCode = options?.suppressErrorCodes?.some((code) =>
message.includes(code),
);
const suppressByMessage = options?.suppressErrorMessageIncludes?.some((substring) =>
message.includes(substring),
);
if (!suppressByCode && !suppressByMessage) {
logger.error('GraphQL request error:', error);
}
throw error;
}
}
async createFailedMeeting(meetingData: MeetingCreateInput): Promise<string> {
const mutation = `
mutation CreateMeeting($data: MeetingCreateInput!) {
createMeeting(data: $data) { id }
}
`;
const variables = { data: meetingData };
if (!this.isTestEnvironment) {
logger.debug('createFailedMeeting variables:', JSON.stringify(variables, null, 2));
}
const response = await this.gqlRequest<CreateMeetingResponse>(mutation, variables);
if (!response.data?.createMeeting?.id) {
throw new Error('Failed to create failed meeting record: Invalid response from server');
}
return response.data.createMeeting.id;
}
async findFailedMeetings(): Promise<Array<{
id: string;
name: string;
firefliesMeetingId: string;
importError: string;
lastImportAttempt: string;
importAttempts: number;
createdAt: string;
}>> {
const query = `
query FindFailedMeetings {
meetings(filter: { importStatus: { eq: "FAILED" } }) {
edges {
node {
id
name
firefliesMeetingId
importError
lastImportAttempt
importAttempts
createdAt
}
}
}
}
`;
const response = await this.gqlRequest<{ meetings: { edges: Array<{ node: { id: string; name: string; firefliesMeetingId: string; importError: string; lastImportAttempt: string; importAttempts: number; createdAt: string } }> } }>(query);
return response.data?.meetings?.edges?.map((edge) => edge.node) || [];
}
async retryFailedMeeting(meetingId: string, updatedData: Partial<MeetingCreateInput>): Promise<void> {
const mutation = `
mutation UpdateMeeting($where: MeetingWhereUniqueInput!, $data: MeetingUpdateInput!) {
updateMeeting(where: $where, data: $data) { id }
}
`;
const variables = {
where: { id: meetingId },
data: {
...updatedData,
lastImportAttempt: new Date().toISOString(),
importAttempts: { increment: 1 }
}
};
await this.gqlRequest<{ updateMeeting: { id: string } }>(mutation, variables);
}
}
@@ -1,235 +0,0 @@
// Fireflies API Types
export type FirefliesParticipant = {
email: string;
name: string;
};
export type FirefliesWebhookPayload = {
meetingId: string;
eventType: string;
clientReferenceId?: string;
};
// Transcript sentence from Fireflies API
export type FirefliesSentence = {
index: number;
speaker_name: string;
text: string;
start_time: string;
end_time: string;
ai_filters?: {
task?: boolean;
question?: boolean;
sentiment?: string;
};
};
// Speaker analytics from Fireflies API (Business+)
export type FirefliesSpeakerAnalytics = {
speaker_id: string;
name: string;
duration: number;
word_count: number;
longest_monologue: number;
filler_words: number;
questions: number;
words_per_minute: number;
};
// Based on Fireflies GraphQL API transcript schema
// See: https://docs.fireflies.ai/graphql-api/query/transcript
export type FirefliesMeetingData = {
id: string;
title: string;
date: string;
duration: number;
participants: FirefliesParticipant[];
organizer_email?: string;
// Full transcript (Pro+)
sentences?: FirefliesSentence[];
summary: {
// Pro+ fields
action_items: string[];
keywords?: string[];
overview: string;
notes?: string; // Detailed AI-generated meeting notes
gist?: string; // 1-sentence summary
bullet_gist?: string; // Bullet point summary with emojis
short_summary?: string; // Single paragraph summary
short_overview?: string; // Brief overview
outline?: string; // Meeting outline with timestamps
shorthand_bullet?: string;
// Business+ fields
topics_discussed?: string[];
meeting_type?: string;
transcript_chapters?: string[];
};
// Business+ analytics
analytics?: {
sentiments?: {
positive_pct: number;
negative_pct: number;
neutral_pct: number;
};
categories?: {
questions: number;
tasks: number;
metrics: number;
date_times: number;
};
speakers?: FirefliesSpeakerAnalytics[];
};
meeting_info?: {
summary_status?: string;
};
// URLs
transcript_url: string;
audio_url?: string; // Pro+
video_url?: string; // Business+
meeting_link?: string; // All plans
summary_status?: string;
};
export type FirefliesTranscriptListItem = {
id: string;
title: string;
date?: string;
duration?: number;
organizer_email?: string;
participants?: string[];
transcript_url?: string;
meeting_link?: string;
summary_status?: string;
};
export type FirefliesTranscriptListOptions = {
limit?: number;
skip?: number;
organizers?: string[];
participants?: string[];
hostEmail?: string;
participantEmail?: string;
userId?: string;
channelId?: string;
mine?: boolean;
fromDate?: number;
toDate?: number;
pageSize?: number;
maxRecords?: number;
};
// Configuration Types
export type SummaryStrategy = 'immediate_only' | 'immediate_with_retry' | 'delayed_polling' | 'basic_only';
export type SummaryFetchConfig = {
strategy: SummaryStrategy;
retryAttempts: number;
retryDelay: number;
pollInterval: number;
maxPolls: number;
};
export const FIREFLIES_PLANS = {
FREE: 'free',
PRO: 'pro',
BUSINESS: 'business',
ENTERPRISE: 'enterprise',
} as const;
export type FirefliesPlan = typeof FIREFLIES_PLANS[keyof typeof FIREFLIES_PLANS];
export type WebhookConfig = {
secret: string;
apiUrl: string;
};
// Processing Result Types
export type ProcessResult = {
success: boolean;
meetingId?: string;
noteIds?: string[];
newContacts?: string[];
errors?: string[];
debug?: string[];
summaryReady?: boolean;
summaryPending?: boolean;
enhancementScheduled?: boolean;
actionItemsCount?: number;
sentimentAnalysis?: {
positive_pct: number;
negative_pct: number;
neutral_pct: number;
};
meetingType?: string;
keyTopics?: string[];
};
// Twenty CRM Types
export type GraphQLResponse<T> = {
data: T;
errors?: Array<{
message?: string;
extensions?: { code?: string }
}>;
};
export type IdNode = { id: string };
export type FindMeetingResponse = {
meetings: { edges: Array<{ node: IdNode }> };
};
export type FindPeopleResponse = {
people: { edges: Array<{ node: { id: string; emails: { primaryEmail: string } } }> };
};
export type CreatePersonResponse = {
createPerson: { id: string }
};
export type CreateNoteResponse = {
createNote: { id: string }
};
export type CreateMeetingResponse = {
createMeeting: { id: string }
};
export type Contact = {
id: string;
email: string;
};
// Twenty CRM Meeting custom field input
// Maps to fields defined in add-meeting-fields.ts
export type MeetingCreateInput = {
name: string;
noteId?: string | null;
// Basic fields (All plans)
meetingDate: string;
duration: number;
firefliesMeetingId: string;
organizerEmail?: string | null;
transcriptUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
meetingLink?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
// Pro+ fields
transcript?: string | null;
overview?: string | null;
notes?: string | null;
keywords?: string | null;
audioUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
// Business+ fields
meetingType?: string | null;
topics?: string | null;
actionItemsCount: number;
positivePercent?: number | null;
negativePercent?: number | null;
neutralPercent?: number | null;
videoUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
// Import tracking
importStatus?: 'SUCCESS' | 'PARTIAL' | 'FAILED' | 'PENDING' | 'RETRYING' | null;
importError?: string | null;
lastImportAttempt?: string | null;
importAttempts?: number | null;
};
@@ -1,41 +0,0 @@
import { FIREFLIES_PLANS, type FirefliesPlan, type SummaryFetchConfig, type SummaryStrategy } from './types';
export const toBoolean = (value: string | undefined, defaultValue: boolean): boolean => {
if (value === undefined) return defaultValue;
const normalized = value.trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes';
};
export const getApiUrl = (): string => {
return process.env.SERVER_URL || 'http://localhost:3000';
};
export const getSummaryFetchConfig = (): SummaryFetchConfig => {
const strategy = (process.env.FIREFLIES_SUMMARY_STRATEGY as SummaryStrategy) || 'immediate_with_retry';
// Ultra-conservative defaults to respect 50 requests/day API limit
// With 3 attempts at 15-minute intervals, max 3 API calls per webhook (45 minutes total)
return {
strategy,
retryAttempts: parseInt(process.env.FIREFLIES_RETRY_ATTEMPTS || '3', 10),
retryDelay: parseInt(process.env.FIREFLIES_RETRY_DELAY || '120000', 10), // 2 minutes
pollInterval: parseInt(process.env.FIREFLIES_POLL_INTERVAL || '120000', 10), // 2 minutes
maxPolls: parseInt(process.env.FIREFLIES_MAX_POLLS || '3', 10),
};
};
export const shouldAutoCreateContacts = (): boolean => {
return toBoolean(process.env.AUTO_CREATE_CONTACTS, true);
};
export const getFirefliesPlan = (): FirefliesPlan => {
const plan = (process.env.FIREFLIES_PLAN || FIREFLIES_PLANS.FREE).toLowerCase();
if (plan === FIREFLIES_PLANS.BUSINESS || plan === FIREFLIES_PLANS.ENTERPRISE) {
return plan as FirefliesPlan;
}
if (plan === FIREFLIES_PLANS.PRO) {
return FIREFLIES_PLANS.PRO;
}
return FIREFLIES_PLANS.FREE;
};
@@ -1,382 +0,0 @@
import { FirefliesApiClient } from './fireflies-api-client';
import { MeetingFormatter } from './formatters';
import { clearAllCapturedLogs, createLogger, getAllCapturedLogs } from './logger';
import { TwentyCrmService } from './twenty-crm-service';
import type { FirefliesWebhookPayload, ProcessResult } from './types';
import { getApiUrl, getFirefliesPlan, getSummaryFetchConfig, shouldAutoCreateContacts } from './utils';
import {
getWebhookSecretFingerprint,
isValidFirefliesPayload,
verifyWebhookSignature
} from './webhook-validator';
declare const process: { env: Record<string, string | undefined> };
export class WebhookHandler {
private debug: string[] = [];
private logger: ReturnType<typeof createLogger>;
constructor() {
this.logger = createLogger('fireflies');
}
private addDebugLogs(result: ProcessResult): ProcessResult {
if (process.env.CAPTURE_LOGS === 'true') {
const captured = getAllCapturedLogs();
result.debug = [...this.debug, ...captured];
} else {
delete result.debug;
}
return result;
}
async handle(params: unknown, headers?: Record<string, string>): Promise<ProcessResult> {
clearAllCapturedLogs();
this.debug = [];
const result: ProcessResult = {
success: false,
noteIds: [],
newContacts: [],
errors: [],
};
try {
this.logger.debug('invoked');
this.logger.debug(`apiUrl=${getApiUrl()}`);
const paramKeys =
params && typeof params === 'object'
? Object.keys(params as Record<string, unknown>)
: [];
this.debug.push(
`paramsType=${typeof params}`,
`paramKeys=${paramKeys.join(',') || 'none'}`
);
// 0) Validate environment configuration
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
const twentyApiKey = process.env.TWENTY_API_KEY || '';
if (!firefliesApiKey) {
this.logger.critical('FIREFLIES_API_KEY not configured - this is a critical configuration error');
throw new Error('FIREFLIES_API_KEY environment variable is required');
}
if (!twentyApiKey) {
this.logger.critical('TWENTY_API_KEY not configured - this is a critical configuration error');
throw new Error('TWENTY_API_KEY environment variable is required');
}
// 1) Parse and validate webhook payload and extract headers if wrapped together
const { payload, extractedHeaders } = this.parsePayload(params);
const finalHeaders = extractedHeaders || headers;
this.logger.debug(`payload meetingId=${payload.meetingId} eventType="${payload.eventType}"`);
// 2) Verify webhook signature
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
const secretFingerprint = getWebhookSecretFingerprint(webhookSecret);
this.logger.debug(`webhook secret fingerprint=${secretFingerprint}`);
this.verifySignature(payload, finalHeaders, webhookSecret);
this.logger.debug('signature verification: ok');
// 3) Fetch meeting data from Fireflies
const summaryConfig = getSummaryFetchConfig();
const firefliesPlan = getFirefliesPlan();
this.logger.debug(`summary strategy: ${summaryConfig.strategy} (retryAttempts=${summaryConfig.retryAttempts}, retryDelay=${summaryConfig.retryDelay}ms)`);
this.logger.debug(`fetching meeting data from Fireflies API`);
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
const { data: meetingData, summaryReady } = await firefliesClient.fetchMeetingDataWithRetry(
payload.meetingId,
summaryConfig,
firefliesPlan
);
this.logger.debug(`meeting data fetched: title="${meetingData.title}" summaryReady=${summaryReady}`);
result.summaryReady = summaryReady;
result.summaryPending = !summaryReady;
// Extract business intelligence
if (summaryReady) {
result.actionItemsCount = meetingData.summary.action_items.length;
result.keyTopics = meetingData.summary.topics_discussed;
result.meetingType = meetingData.summary.meeting_type;
if (meetingData.analytics?.sentiments) {
result.sentimentAnalysis = meetingData.analytics.sentiments;
}
}
// 4) Check for duplicate meetings
const twentyService = new TwentyCrmService(
twentyApiKey,
getApiUrl()
);
const existingMeetingById = await twentyService.findMeetingByFirefliesId(meetingData.id);
if (existingMeetingById) {
this.logger.debug(`meeting already exists by firefliesMeetingId id=${existingMeetingById.id}`);
result.success = true;
result.meetingId = existingMeetingById.id;
return this.addDebugLogs(result);
}
const existingMeetingByTitle = await twentyService.findExistingMeeting(meetingData.title);
if (existingMeetingByTitle) {
this.logger.debug(`meeting already exists by title id=${existingMeetingByTitle.id}`);
result.success = true;
result.meetingId = existingMeetingByTitle.id;
return this.addDebugLogs(result);
}
this.logger.debug('no existing meeting found, proceeding');
// 5) Match participants to existing contacts
this.logger.debug(`total participants from API: ${meetingData.participants.length}`);
meetingData.participants.forEach((p, idx) => {
this.logger.debug(`participant ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
});
const { matchedContacts, unmatchedParticipants } = await twentyService.matchParticipantsToContacts(
meetingData.participants
);
this.logger.debug(`matched=${matchedContacts.length} unmatched=${unmatchedParticipants.length}`);
unmatchedParticipants.forEach((p, idx) => {
this.logger.debug(`unmatched ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
});
// 6) Optionally create contacts
const autoCreate = shouldAutoCreateContacts();
const newContactIds = autoCreate
? await twentyService.createContactsForUnmatched(unmatchedParticipants)
: [];
result.newContacts = newContactIds;
this.logger.debug(`autoCreate=${autoCreate} createdContacts=${newContactIds.length}`);
// 7) Create note first (so we can link to it from the meeting)
const allContactIds = [...matchedContacts.map(({ id }) => id), ...newContactIds];
const noteBody = MeetingFormatter.formatNoteBody(meetingData);
const noteId = await twentyService.createNoteOnly(
`Meeting: ${meetingData.title}`,
noteBody
);
result.noteIds = [noteId];
this.logger.debug(`created note id=${noteId}`);
// 8) Create meeting with direct relationship to the note
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
this.logger.debug(`meeting duration: ${meetingData.duration} min (raw from API) → ${meetingInput.duration} min (rounded)`);
result.meetingId = await twentyService.createMeeting(meetingInput);
this.logger.debug(`created meeting id=${result.meetingId} with noteId=${noteId}`);
// 9) Link note to participants (Meeting link is handled via the relation field)
await this.linkNoteToParticipants(
twentyService,
noteId,
allContactIds
);
this.logger.debug(`linked note to ${allContactIds.length} participants`);
result.success = true;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`error: ${message}`);
result.errors?.push(message);
// Try to create a failed meeting record for tracking
await this.createFailedMeetingRecord(params, message);
}
return this.addDebugLogs(result);
}
private parsePayload(params: unknown): { payload: FirefliesWebhookPayload; extractedHeaders?: Record<string, string> } {
let normalizedParams = params;
let extractedHeaders: Record<string, string> | undefined;
// Handle string-encoded params
if (typeof normalizedParams === 'string') {
this.logger.debug(`received params as string length=${normalizedParams.length}`);
try {
const parsed = JSON.parse(normalizedParams);
normalizedParams = parsed;
if (parsed && typeof parsed === 'object') {
const parsedKeys = Object.keys(parsed as Record<string, unknown>);
this.logger.debug(`parsed params keys: ${parsedKeys.join(',') || 'none'}`);
}
} catch (parseError) {
this.logger.error(`error parsing string params: ${String(parseError)}`);
throw new Error('Invalid or missing webhook payload');
}
}
// Handle wrapped payloads and extract headers if present
let payload: FirefliesWebhookPayload | undefined;
if (isValidFirefliesPayload(normalizedParams)) {
payload = normalizedParams as FirefliesWebhookPayload;
} else if (normalizedParams && typeof normalizedParams === 'object') {
const wrapper = normalizedParams as Record<string, unknown>;
// Extract headers if present in wrapper
if (wrapper.headers && typeof wrapper.headers === 'object' && !Array.isArray(wrapper.headers)) {
extractedHeaders = wrapper.headers as Record<string, string>;
const headerKeys = Object.keys(extractedHeaders);
this.logger.debug(`extracted headers from wrapper: ${headerKeys.join(',')}`);
}
const wrapperKeys = ['params', 'payload', 'body', 'data', 'event'];
for (const key of wrapperKeys) {
const candidate = wrapper[key];
if (isValidFirefliesPayload(candidate)) {
this.logger.debug(`detected payload under wrapper key "${key}"`);
payload = candidate as FirefliesWebhookPayload;
break;
}
}
}
if (!payload) {
this.logger.error('error: Invalid or missing webhook payload');
throw new Error('Invalid or missing webhook payload');
}
// Log payload keys for debugging
const payloadRecord = payload as Record<string, unknown>;
const payloadKeys = Object.keys(payloadRecord);
if (payloadKeys.length > 0) {
this.logger.debug(`payload keys: ${payloadKeys.join(',')}`);
}
return { payload, extractedHeaders };
}
private verifySignature(
payload: FirefliesWebhookPayload,
headers: Record<string, string> | undefined,
webhookSecret: string
): void {
// Extract headers
const normalizedHeaders = headers || {};
const headerKeys = Object.keys(normalizedHeaders);
if (headerKeys.length > 0) {
this.logger.debug(`header keys: ${headerKeys.join(',')}`);
}
this.debug.push(`headerKeys=${headerKeys.join(',') || 'none'}`);
const headerSignature = Object.entries(normalizedHeaders).find(
([key]) => key.toLowerCase() === 'x-hub-signature',
)?.[1];
const payloadRecord = payload as Record<string, unknown>;
const payloadSignature =
typeof payloadRecord['x-hub-signature'] === 'string'
? (payloadRecord['x-hub-signature'] as string)
: undefined;
if (payloadSignature) {
this.logger.debug('found signature inside payload');
}
const signature =
(typeof headerSignature === 'string' ? headerSignature : undefined) || payloadSignature;
const payloadForSignature =
payloadSignature && 'x-hub-signature' in payloadRecord
? Object.fromEntries(
Object.entries(payloadRecord).filter(
([key]) => key.toLowerCase() !== 'x-hub-signature',
),
)
: payloadRecord;
const body =
typeof normalizedHeaders['body'] === 'string'
? normalizedHeaders['body']
: JSON.stringify(payloadForSignature);
const signatureCheck = verifyWebhookSignature(body, signature, webhookSecret);
if (!signatureCheck.isValid) {
this.debug.push(
`signatureProvided=${Boolean(signature)}`,
`signatureMatched=${signatureCheck.isValid}`,
`webhookSecretFingerprint=${getWebhookSecretFingerprint(webhookSecret)}`
);
this.logger.debug(
`signature check failed. headerPresent=${Boolean(
headerSignature,
)} payloadSignaturePresent=${Boolean(payloadSignature)}`,
);
this.logger.debug(`provided signature present=${Boolean(signature)}`);
this.logger.critical('Invalid webhook signature - potential security threat detected in production');
throw new Error('Invalid webhook signature');
}
}
private async linkNoteToParticipants(
twentyService: TwentyCrmService,
noteId: string,
contactIds: string[]
): Promise<void> {
for (const contactId of contactIds) {
try {
await twentyService.createNoteTarget(noteId, contactId);
this.logger.debug(`linked note ${noteId} to person ${contactId}`);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`failed to link note to person ${contactId}: ${message}`);
}
}
}
private async createFailedMeetingRecord(params: unknown, error: string): Promise<void> {
try {
const twentyApiKey = process.env.TWENTY_API_KEY || '';
if (!twentyApiKey) {
this.logger.debug('Cannot create failed meeting record: TWENTY_API_KEY not configured');
return;
}
let meetingId = 'unknown';
let meetingTitle = 'Unknown Meeting';
const { payload } = this.parsePayload(params);
if (payload?.meetingId) {
meetingId = payload.meetingId;
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
if (firefliesApiKey) {
try {
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
const meetingData = await firefliesClient.fetchMeetingData(meetingId);
meetingTitle = meetingData.title || meetingTitle;
} catch (fetchError) {
this.logger.debug(
`Could not fetch meeting title: ${
fetchError instanceof Error ? fetchError.message : 'Unknown error'
}`,
);
}
}
}
const twentyService = new TwentyCrmService(twentyApiKey, getApiUrl());
const failedMeetingData = MeetingFormatter.toFailedMeetingCreateInput(
meetingId,
meetingTitle,
error
);
const failedMeetingId = await twentyService.createFailedMeeting(failedMeetingData);
this.logger.debug(`Created failed meeting record: ${failedMeetingId}`);
} catch (recordError) {
this.logger.error(
`Failed to create failed meeting record: ${
recordError instanceof Error ? recordError.message : 'Unknown error'
}`,
);
}
}
}
@@ -1,54 +0,0 @@
import { createHash, createHmac } from 'crypto';
import type { FirefliesWebhookPayload } from './types';
export type SignatureVerificationResult = {
isValid: boolean;
computedSignature?: string;
};
export const verifyWebhookSignature = (
body: string,
signature: string | undefined,
secret: string
): SignatureVerificationResult => {
if (!signature) {
return { isValid: false };
}
try {
const hmac = createHmac('sha256', secret);
hmac.update(body, 'utf8');
const computed = hmac.digest('hex');
const computedSignature = `sha256=${computed}`;
const isValid = signature === computedSignature;
return { isValid, computedSignature };
} catch {
return { isValid: false };
}
};
export const getWebhookSecretFingerprint = (secret: string): string => {
return createHash('sha256').update(secret).digest('hex').substring(0, 8);
};
export const isValidFirefliesPayload = (
params: unknown
): params is FirefliesWebhookPayload => {
if (!params || typeof params !== 'object') {
return false;
}
const payload = params as Record<string, unknown>;
return (
typeof payload['meetingId'] === 'string' &&
payload['meetingId'].length > 0 &&
typeof payload['eventType'] === 'string' &&
payload['eventType'].length > 0 &&
(payload['clientReferenceId'] === undefined ||
typeof payload['clientReferenceId'] === 'string')
);
};
@@ -1,26 +0,0 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
File diff suppressed because it is too large Load Diff
@@ -15,5 +15,23 @@
}
],
"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."
}
]
}
]
}
}
]
}
@@ -1,6 +1,6 @@
{
"name": "github-connector",
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -77,6 +77,7 @@ describe('PullRequestReview object', () => {
expect(names).toContain('firstSubmittedAt');
expect(names).toContain('lastSubmittedAt');
expect(names).toContain('eventCount');
expect(names).toContain('isSelfReview');
expect(names).toContain('reviewer');
expect(names).toContain('pullRequest');
expect(names).toContain('reviewEvents');
@@ -82,7 +82,17 @@ describe('verifyGitHubSignature', () => {
});
describe('getRawBodyForSignature', () => {
it('returns the string as-is for string body', () => {
it('prefers event.rawBody when the runtime forwarded it', () => {
const original = '{ "action": "opened", "number": 42 }';
expect(
getRawBodyForSignature({
body: { action: 'opened', number: 42 },
rawBody: original,
}),
).toBe(original);
});
it('falls back to string body when rawBody is not provided', () => {
expect(
getRawBodyForSignature({ body: '{"a":1}', isBase64Encoded: false }),
).toBe('{"a":1}');
@@ -119,3 +129,22 @@ describe('verifyGitHubSignature with parsed body', () => {
});
});
});
describe('end-to-end: server forwards rawBody, signature verifies', () => {
it('verifies a signature when rawBody is present alongside parsed body', () => {
const original = '{ "action": "opened", "number": 42 }';
const event = {
body: { action: 'opened', number: 42 },
rawBody: original,
isBase64Encoded: false,
};
const rawBody = getRawBodyForSignature(event);
const result = verifyGitHubSignature({
rawBody,
signatureHeader: sign(original),
secret: SECRET,
});
expect(result.ok).toBe(true);
});
});
@@ -7,7 +7,11 @@ export type SignatureVerificationResult =
export function getRawBodyForSignature(event: {
body: unknown;
isBase64Encoded?: boolean;
rawBody?: string;
}): string | null {
if (typeof event.rawBody === 'string') {
return event.rawBody;
}
const raw = event.body;
if (raw == null) return '';
if (typeof raw === 'string') {
@@ -1,4 +1,5 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { type RoutePayload } from 'twenty-sdk/logic-function';
import { getClient } from 'src/modules/shared/twenty-client';
export type StatsPeriod = 'week' | 'month' | '3months' | 'year';
@@ -56,7 +57,11 @@ const PERIOD_CONFIG: Record<
{ granularity: Granularity; rangeMs: number; bucketCount: number }
> = {
week: { granularity: 'day', rangeMs: 7 * 24 * 3600 * 1000, bucketCount: 7 },
month: { granularity: 'day', rangeMs: 30 * 24 * 3600 * 1000, bucketCount: 30 },
month: {
granularity: 'day',
rangeMs: 30 * 24 * 3600 * 1000,
bucketCount: 30,
},
'3months': {
granularity: 'week',
rangeMs: 13 * 7 * 24 * 3600 * 1000,
@@ -98,7 +103,10 @@ const bucketStartFor = (d: Date, granularity: Granularity): Date => {
};
const formatBucketLabel = (start: Date, granularity: Granularity): string => {
const month = start.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
const month = start.toLocaleString('en-US', {
month: 'short',
timeZone: 'UTC',
});
if (granularity === 'month') {
return `${month} ${String(start.getUTCFullYear()).slice(2)}`;
}
@@ -290,7 +298,12 @@ const handler = async (
const res = await client.query({
pullRequestReviews: {
__args: {
filter: { reviewerId: { eq: contributorId } },
filter: {
and: [
{ reviewerId: { eq: contributorId } },
{ isSelfReview: { eq: false } },
],
},
orderBy: [{ firstSubmittedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
@@ -1,4 +1,5 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { type RoutePayload } from 'twenty-sdk/logic-function';
import { countAcrossRepos } from 'src/modules/github/connector/count-across-repos';
import { countContributors } from 'src/modules/github/contributor/graphql/github/count-contributors';
@@ -7,11 +8,7 @@ type CountContributorsPayload = {
};
const handler = async (event: RoutePayload<CountContributorsPayload>) =>
countAcrossRepos(
event.body?.repos,
countContributors,
'count-contributors',
);
countAcrossRepos(event.body?.repos, countContributors, 'count-contributors');
export default defineLogicFunction({
universalIdentifier: 'fe0a6f00-0d63-4cb9-9b3c-1d8186181830',
@@ -1,4 +1,5 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { type RoutePayload } from 'twenty-sdk/logic-function';
import {
fetchContributors,
type GqlContributor,
@@ -41,7 +42,11 @@ const handler = async (event: RoutePayload<FetchContributorsPayload>) => {
name: c.login,
githubId: c.databaseId ?? 0,
avatarUrl: c.avatarUrl
? { primaryLinkLabel: c.login, primaryLinkUrl: c.avatarUrl, secondaryLinks: null }
? {
primaryLinkLabel: c.login,
primaryLinkUrl: c.avatarUrl,
secondaryLinks: null,
}
: null,
}));
@@ -1,4 +1,5 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { type RoutePayload } from 'twenty-sdk/logic-function';
import {
searchContributors,
type ContributorSearchResult,
@@ -1,4 +1,5 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { type RoutePayload } from 'twenty-sdk/logic-function';
import { isBotLogin } from 'src/modules/github/contributor/utils/is-bot-login';
import { getClient } from 'src/modules/shared/twenty-client';
@@ -107,7 +108,10 @@ const tally = (
}
}
return Array.from(counts.values())
.sort((a, b) => b.count - a.count || (a.ghLogin ?? '').localeCompare(b.ghLogin ?? ''))
.sort(
(a, b) =>
b.count - a.count || (a.ghLogin ?? '').localeCompare(b.ghLogin ?? ''),
)
.slice(0, limit);
};
@@ -148,78 +152,83 @@ const handler = async (
const client = getClient();
const authoredResult: { items: PrNode[]; truncated: boolean } =
kind === 'reviewers' ? { items: [], truncated: false } : await paginateUntil<PrNode>(
async (cursor) => {
const res = await client.query({
pullRequests: {
__args: {
orderBy: [{ githubCreatedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: {
node: {
githubCreatedAt: true,
author: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
kind === 'reviewers'
? { items: [], truncated: false }
: await paginateUntil<PrNode>(
async (cursor) => {
const res = await client.query({
pullRequests: {
__args: {
orderBy: [{ githubCreatedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: {
node: {
githubCreatedAt: true,
author: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
},
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
},
});
return (
(res.pullRequests as Connection<PrNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequests as Connection<PrNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.githubCreatedAt) return false;
return new Date(n.githubCreatedAt).getTime() < sinceMs;
},
);
(n) => {
if (!n.githubCreatedAt) return false;
return new Date(n.githubCreatedAt).getTime() < sinceMs;
},
);
const reviewedResult: { items: ReviewNode[]; truncated: boolean } =
kind === 'authors' ? { items: [], truncated: false } : await paginateUntil<ReviewNode>(
async (cursor) => {
const res = await client.query({
pullRequestReviews: {
__args: {
orderBy: [{ firstSubmittedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: {
node: {
firstSubmittedAt: true,
reviewer: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
kind === 'authors'
? { items: [], truncated: false }
: await paginateUntil<ReviewNode>(
async (cursor) => {
const res = await client.query({
pullRequestReviews: {
__args: {
filter: { isSelfReview: { eq: false } },
orderBy: [{ firstSubmittedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: {
node: {
firstSubmittedAt: true,
reviewer: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
},
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
},
});
return (
(res.pullRequestReviews as Connection<ReviewNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequestReviews as Connection<ReviewNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.firstSubmittedAt) return false;
return new Date(n.firstSubmittedAt).getTime() < sinceMs;
},
);
(n) => {
if (!n.firstSubmittedAt) return false;
return new Date(n.firstSubmittedAt).getTime() < sinceMs;
},
);
const topAuthors = tally(
authoredResult.items.map((pr) => ({ contributor: pr.author })),
@@ -1,4 +1,5 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { type RoutePayload } from 'twenty-sdk/logic-function';
import { countAcrossRepos } from 'src/modules/github/connector/count-across-repos';
import { countIssues } from 'src/modules/github/issue/graphql/github/count-issues';
@@ -1,4 +1,5 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { type RoutePayload } from 'twenty-sdk/logic-function';
import {
fetchIssues,
type GqlIssue,
@@ -65,9 +66,8 @@ const handler = async (event: RoutePayload<FetchIssuesPayload>) => {
authorId: issue.author ? (idByLogin.get(issue.author.login) ?? null) : null,
}));
await timed(
`fetch-issues:upsertIssues ${tag} (${issueData.length})`,
() => batchUpsertIssues(issueData),
await timed(`fetch-issues:upsertIssues ${tag} (${issueData.length})`, () =>
batchUpsertIssues(issueData),
);
const totalMs = Date.now() - handlerStart;
@@ -1,4 +1,5 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { type RoutePayload } from 'twenty-sdk/logic-function';
import type { GitHubWebhookPayload } from 'src/modules/github/connector/webhook-payload';
import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item';
import { fetchProjectItemByNodeId } from 'src/modules/github/project-item/graphql/github/fetch-project-item-by-node-id';
@@ -43,7 +44,9 @@ async function handlePullRequestEvent(payload: GitHubWebhookPayload) {
const idByLogin = await upsertContributorsByLogin([pr.user, pr.merged_by]);
const authorId = idByLogin.get(pr.user.login) ?? null;
const mergerId = pr.merged_by ? (idByLogin.get(pr.merged_by.login) ?? null) : null;
const mergerId = pr.merged_by
? (idByLogin.get(pr.merged_by.login) ?? null)
: null;
const canonical = pullRequestFromWebhook(pr, repository.full_name);
@@ -81,7 +84,9 @@ async function handlePullRequestReviewEvent(payload: GitHubWebhookPayload) {
{
...prCanonical,
authorId: idByLogin.get(pr.user.login) ?? null,
mergerId: pr.merged_by ? (idByLogin.get(pr.merged_by.login) ?? null) : null,
mergerId: pr.merged_by
? (idByLogin.get(pr.merged_by.login) ?? null)
: null,
},
]);
@@ -175,7 +180,8 @@ async function handleProjectV2ItemEvent(
return { skipped: true, reason: 'delete not implemented' };
}
const node = testProjectItem ?? (await fetchProjectItemByNodeId(item.node_id));
const node =
testProjectItem ?? (await fetchProjectItemByNodeId(item.node_id));
if (!node) {
return { skipped: true, reason: 'project item not found on GitHub' };
}
@@ -1,4 +1,5 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { type RoutePayload } from 'twenty-sdk/logic-function';
import {
getGithubProjects,
type GithubProject,
@@ -1,4 +1,5 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { type RoutePayload } from 'twenty-sdk/logic-function';
import { fetchProjectItems } from 'src/modules/github/project-item/graphql/github/fetch-project-items';
import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item';
import { batchUpsertProjectItems } from 'src/modules/github/project-item/graphql/mutations/batch-upsert';

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