Compare commits

...
Author SHA1 Message Date
Abdullah 9483104ee4 feat(website): add sliding hero tabs and improve mobile nav color sync
Introduce a sliding tab indicator for the product AI hero and align the
sticky nav transition on mobile with the intro/AI boundary using measured
nav height, earlier lead offset, and blur-free solid backgrounds.
2026-05-20 04:58:57 +05:00
Abdullah e37517dc3d fix(website): polish product hero opacity sync and hydration
Align dark overlay and nav opacity with the AI panel when fully in view,
use stable tab ids and remove Edit icon clipPath useId to fix SSR mismatches,
and stabilize menu scroll effect dependencies.
2026-05-20 04:22:02 +05:00
Abdullah a4161833d0 feat(website): sync product nav with hero scroll color transition
Drive the product page navbar from hero scroll progress with opacity-based
light-to-dark transitions, including fade back to light when leaving the AI section.
2026-05-20 04:09:13 +05:00
Abdullah ac03df1f5f feat(website): add scroll-driven product hero with stacked mobile layout
Replace the static product hero with a unified intro-to-AI scroll transition on
desktop and a stacked mobile layout, including taller preview sizing and updated
AI heading copy.
2026-05-20 04:09:08 +05:00
Félix MalfaitandGitHub d17393e91d feat(website): migrate dev hostname website-new.twenty-main.com → website.twenty-main.com (#20747)
## Summary
Last "-new" trace in the source repo, follow-up to the rename in #20745.
The dev Worker custom domain swaps from `website-new.twenty-main.com` to
`website.twenty-main.com`, matching the prod pattern (no "-new"
anywhere).

## Live operations already performed
- Deleted the legacy CNAME at `website.twenty-main.com` that pointed at
the dev EKS NLB (record id `52b4a4174dfd382ecf38111b7f08e642`, was the
Docusaurus dev deploy that had been 503'ing)
- Redeployed `twenty-website-dev` Worker — Wrangler provisioned the new
custom domain via the CF API
- Verified `https://website.twenty-main.com/` returns 200 with
`x-opennext: 1` and `x-nextjs-cache: HIT`

The old hostname `website-new.twenty-main.com` is now unbound; Wrangler
removed its DNS record when the route disappeared from this file.
Visitors get 522, which is the desired state for a retired hostname.

## Companion infra PR
https://github.com/twentyhq/twenty-infra/pull/683 — removes
`charts/dev/apps/website` Helm chart (the legacy Docusaurus deploy) +
ArgoCD app, updates `cloudflare/website/dev.env` and
`docs/4-environments.md`.

## Out of scope
- Legacy `website.twenty-staging.com` and `website.twenty.com` are still
alive serving Docusaurus content. Decommissioning those is a separate
decision (those URLs may still be linked externally).
2026-05-19 23:55:08 +02:00
Félix MalfaitandGitHub 658bdf3e57 chore(website): rename twenty-website-new → twenty-website (#20745)
## Summary
Follow-up to the Cloudflare/OpenNext migration (#20741). Now that the
legacy `twenty-website` package was already removed in #20270, the
`-new` suffix on the marketing site package is no longer meaningful.

## What changes
- **Directory rename**: `git mv packages/twenty-website-new
packages/twenty-website` (1213 files moved, no content change)
- **Package + nx config**: `package.json` and `project.json` name fields
updated, `sourceRoot` repointed
- **Source refs**: `load-local-articles.ts` and
`load-local-release-notes.ts` had a hardcoded `'twenty-website-new'`
segment in their monorepo-root fallback path;
`app/[locale]/releases/page.tsx` had display strings showing where to
add content
- **External refs**: root `package.json` workspaces, root `CLAUDE.md` /
`README.md`, `twenty-sdk` + `create-twenty-app` READMEs,
`.vscode/twenty.code-workspace`, `.cursor/rules/changelog-process.mdc`,
Crowdin config + the three `website-i18n-*` CI workflows +
`ci-website.yaml`
- **Docker cleanup**:
`packages/twenty-docker/twenty-website-new/Dockerfile` deleted; the two
Makefile targets (`prod-website-new-build` / `prod-website-new-run`)
that referenced it removed — EKS deploy was retired in the Cloudflare
migration
- **`yarn.lock`** regenerated against the new workspace path

## What's deliberately not in this PR
The dev hostname `website-new.twenty-main.com` in `wrangler.jsonc` stays
for now. Migrating it to `website.twenty-main.com` needs coordinated DNS
deletion (current CNAME points at the legacy Docusaurus NLB and serves
503s) and removal of the matching legacy `website` Helm chart in
`twenty-infra`. Flagged as a separate cleanup.

Companion infra PR: https://github.com/twentyhq/twenty-infra/pull/682
(workflow paths + Terraform ECR + docs)

## Test plan
- [x] `yarn install --immutable` resolves clean against the new path
- [x] `npx nx typecheck twenty-website` passes
- [x] `npx nx lint twenty-website` passes
- [ ] CI on this PR confirms the same on a fresh checkout
- [ ] After merge: trigger `Deploy Website` workflow against
`environment=dev` to confirm the renamed working-directory deploys
correctly
2026-05-19 23:42:09 +02:00
Félix MalfaitandGitHub 24a836cc7d feat(website-new): add Cloudflare Workers deployment via OpenNext (#20741)
## Summary
- Adds `@opennextjs/cloudflare` adapter so `packages/twenty-website-new`
can deploy to Cloudflare Workers
- Two environments (`dev` / `prod`) wired via `wrangler.jsonc` env
blocks
- Existing Docker / EKS build path is untouched in this PR — the cutover
happens in the paired infra PR

Pairs with: https://github.com/twentyhq/twenty-infra/pull/__ (to be
opened, will swap CI + decommission Helm/ArgoCD)

## Files added
- `packages/twenty-website-new/wrangler.jsonc` — Worker config,
`nodejs_compat` flag, R2 incremental cache, Cloudflare `IMAGES` binding,
env-specific routes (`website-new.twenty-main.com` for dev; `twenty.com`
+ `www.twenty.com` for prod)
- `packages/twenty-website-new/open-next.config.ts` — minimal config
using `r2IncrementalCache`
- `packages/twenty-website-new/.dev.vars.example` — local secrets
template (`STRIPE_SECRET_KEY`, `ENTERPRISE_JWT_PRIVATE_KEY`)
- `packages/twenty-website-new/public/_headers` — immutable cache
headers for `/_next/static/*`

## Files modified
- `packages/twenty-website-new/package.json` — adds
`@opennextjs/cloudflare`, `wrangler` to devDeps; adds `preview`,
`deploy:dev`, `deploy:prod`, `cf-typegen` scripts
- `packages/twenty-website-new/next.config.ts` — calls
`initOpenNextCloudflareForDev()` (no-op outside `next dev`); preserves
Linaria CommonJS export
- `packages/twenty-website-new/.gitignore` — ignores `.open-next/`,
`.wrangler/`, `.dev.vars`, generated `cloudflare-env.d.ts`

## Compatibility notes
- `enterprise-jwt.ts` uses Node `crypto` + `Buffer` — works on Workers
with the `nodejs_compat` flag (compat date 2025-01-15, well past the
2024-09-23 minimum)
- `sharp` stays as a build-time dep (Next/Image asset processing);
runtime image optimization routes through the Cloudflare `IMAGES`
binding
- Linaria runs at build time, unaffected
- Stripe SDK is HTTP-based, fine on Workers

## One-time CF setup required before this PR is useful
The infra PR adds GitHub Actions wiring, but the Cloudflare account
itself needs:
- R2 buckets: `twenty-website-cache-dev`, `twenty-website-cache-prod`
- Worker secrets per env (via `wrangler secret put --env <dev|prod>`):
`STRIPE_SECRET_KEY`, `ENTERPRISE_JWT_PRIVATE_KEY`
- An API token with `Workers Scripts:Edit`, `Workers R2 Storage:Edit`,
`Zone DNS:Edit` on the `twenty.com` zone — stored as
`CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` in the infra repo's
GitHub secrets
- The Cloudflare Images subscription enabled on the account (binding is
configured; \$5/mo + per-transformation pricing)

## Follow-up (out of scope)
- Rename `packages/twenty-website-new` → `packages/twenty-website` and
delete the legacy `packages/twenty-website` (mechanical, separate PR to
keep this diff reviewable)
- Remove `packages/twenty-docker/twenty-website-new/` once the EKS
deploy is fully retired

## Test plan
- [ ] `yarn install` resolves new devDeps cleanly
- [ ] `cd packages/twenty-website-new && npx next build` still succeeds
(Linaria path untouched)
- [ ] `yarn preview` builds the Worker locally and serves on
http://localhost:8788
- [ ] Smoke: `/`, `/pricing`, an enterprise-key-signing flow (needs
`.dev.vars` populated)
- [ ] After CF resources are provisioned: `yarn deploy:dev` succeeds and
`website-new.twenty-main.com` serves the new Worker
2026-05-19 23:23:54 +02:00
Charles BochetandGitHub 1a9f786e42 refactor(filters): pass fieldMetadataItems array to dispatcher (#20737)
## Summary

Alternative to #20717. Same goal (clean up the filter dispatcher API
after #20670) but smaller and follows the codebase's "pass data, not
behavior" style.

The dispatcher takes a `fieldMetadataItems: FieldShared[]` array
directly instead of a `findFieldMetadataItemById: (id) => FieldShared |
undefined` callback. The util builds the id lookup internally — once per
call, used for both source-field and relation-target-field lookups. No
new types, no separate hydration step.

## What changes

**`twenty-shared`**
- `computeRecordGqlOperationFilter` /
`turnRecordFilterIntoRecordGqlOperationFilter` /
`turnRecordFilterGroupsIntoGqlOperationFilter`: replace
`findFieldMetadataItemById` param with `fieldMetadataItems` /
`fieldMetadataItemById` (internal Map).
- Remove the exported `FindFieldMetadataItemById` type.
- `turnAnyFieldFilterIntoRecordGqlFilter`: rename its internal
`fieldById` Map for consistency.
- Tests updated to pass arrays.

**Frontend (15 call sites)**
- Switch from `fieldMetadataItemByIdMapSelector` to
`flattenedFieldMetadataItemsSelector`.
- Pass `fieldMetadataItems: flattenedFieldMetadataItems` to the
dispatcher.
- `useFindManyRecordsSelectedInContextStore` keeps the Map selector
because it still does a per-filter lookup for the soft-delete check.

**Server (5 call sites)**
- Pass
`Object.values(flatFieldMetadataMaps.byUniversalIdentifier).filter(isDefined)`.

## Why this over #20717

#20717 moves resolution into a separate hydration step + introduces a
`HydratedRecordFilter` type. The bug that #20717 originally surfaced was
Sentry catching 4 critical runtime errors during review
(`fieldMetadataItemByIdMap` declared but not passed). The added type and
the explicit hydration boundary are extra surface area for not much
benefit — the existing API was a callback wrapping a Map at every call
site, and the natural simplification is to just pass the Map (or its
array) directly.

Net diff: **196 insertions, 203 deletions** (~7 lines net removed). 32
files.

## Test plan
- [x] Shared filter unit tests pass (461 tests)
- [x] Frontend filter/context-store tests pass (13 tests)
- [x] Frontend typecheck passes
- [x] Server typecheck passes
- [x] Lint passes (frontend + server)
- [ ] Integration tests on #20670 still pass — workflow find-records +
chart-data with relation-traversal filter still work end-to-end through
the new array param
2026-05-19 22:49:53 +02:00
WeikoandGitHub 265d2edc83 Fix QueryRunnerAlreadyReleasedError in sign-in-up service (#20734)
## Context

When signing up on a new workspace,
`SignInUpService.signUpOnNewWorkspace`
manually drove a transaction with `createQueryRunner` /
`startTransaction` /
`commitTransaction` / `rollbackTransaction` / `release`.

If the underlying Postgres connection dropped mid-transaction
(`idle_in_transaction_session_timeout`, server-side termination), the
`pg`
client's `'error'` event fires. TypeORM's connect-time listener responds
by
calling `release()` on the `QueryRunner`, which sets `isReleased = true`
but
deliberately does **not** touch `isTransactionActive`.

The `catch` branch then hit:

```ts
if (queryRunner.isTransactionActive) {
  await queryRunner.rollbackTransaction(); // throws QueryRunnerAlreadyReleasedError
}
throw error;
```
Error from Sentry
```typescript
QueryRunnerAlreadyReleasedError: Query runner already released. Cannot run queries anymore.
    at PostgresQueryRunner.query (.../PostgresQueryRunner.js:177)
    at PostgresQueryRunner.rollbackTransaction (.../PostgresQueryRunner.js:167)
    at SignInUpService.signUpOnNewWorkspace (.../sign-in-up.service.js:370)
```

## Changes

Replaced the hand-rolled transaction with
this.dataSource.transaction(...).
TypeORM's built-in wrapper already does what we need:

- starts/commits/rolls back the transaction
- wraps rollback in try { ... } catch { /* ignore */ }, so a connection
drop no longer masks the real error
- releases the QueryRunner unconditionally

## Note
Other fix would have been to do this
```typescript
  if (queryRunner.isTransactionActive && **!queryRunner.isReleased**) {
     try {
       await queryRunner.rollbackTransaction();
     } catch {
```
2026-05-19 15:32:36 +00:00
d345a6b2d6 Twenty fireflies integration (#20618)
Co-authored-by: martmull <[email protected]>
2026-05-19 15:23:36 +00:00
f8605763dd i18n - docs translations (#20736)
Created by Github action

Co-authored-by: github-actions <[email protected]>
2026-05-19 17:25:12 +02:00
WeikoandGitHub ac432d3195 Add @WasRemovedInUpgrade decorator (#20729)
## Summary

Adds the symmetric counterpart to `@WasIntroducedInUpgrade` for the
upgrade-aware ORM. Today the framework can describe "this column will
exist once upgrade X applies" but not "this
column will stop existing once upgrade X applies". Plain field deletion
only works when nothing writes to the table during the mid-state window
between the binary booting and the drop
migration completing for a given workspace — fine for sparse tables
(`DropWorkspaceVersionColumn`, `DropPostgresCredentialsTable`), risky
for hot-write tables.

This PR ships the primitive on its own so the upcoming
`rolePermissionFlag.flag` drop has the framework support it needs. No
in-tree consumer yet — coverage is via unit tests against
synthetic entities.

### What's in it

- **New `@WasRemovedInUpgrade({ upgradeCommandName })` decorator**
(class- or property-scope) — mirrors `@WasIntroducedInUpgrade`, uses the
shared
`defineUpgradeMetadataOnClassOrProperty` helper, exposes class +
property getters.
- **`resolveEntityShapeAtUpgradeCursor`** now folds applied-removals
into the existing `hiddenPropertyNames` set. Intro-pending and
removal-applied share one hide bucket — both ask
TypeORM for the same thing.
- **`UpgradeAwareEntityMetadataAdapter`** now disables `isSelect`,
`isInsert`, **and** `isUpdate` for any hidden column, restoring
canonical values when the column comes back.
Previously only `isSelect` was flipped, which left an
INSERT-into-nonexistent-column hole the intro path was tacitly relying
on application code to avoid; this PR closes that hole for
both directions.
- **`validateUpgradeAwareEntityDecorators`** validates
`@WasRemovedInUpgrade` `upgradeCommandName` references, and surfaces a
new `removal-before-introduction` problem when a property
has both decorators with the removal step preceding the introduction
step.
2026-05-19 14:57:05 +00:00
e463a09e17 chore(server): remove unused CommandLogger from command module (#20638)
## Summary

This PR removes the unused `CommandLogger` implementation located at:

```
/commands/command-logger.ts
```


The Command application context is bootstrapped using `LoggerService`
from:

```ts
import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
...
const loggerService = app.get(LoggerService);
...
// Inject our logger
app.useLogger(loggerService);
...

```

So `CommandLogger` is not imported, injected, or referenced anywhere in
the Command execution flow and is safe to remove.

## Note
There is another `CommandLogger` class at:
```
/database/commands/logger.ts
```

This one is only used within `database-command` module and is unrelated
to the Command module logger being removed in this PR.

Co-authored-by: Charles Bochet <[email protected]>
2026-05-19 14:44:34 +00:00
nitinandGitHub 83b10ad698 fix(server): sync command menu item availability expressions on existing workspaces (#20719)
Two fixes via one workspace command:

1. Gates 5 standard command menu items behind `pageType == "INDEX_PAGE"`
--
   `importRecords`, `exportView`, `seeDeletedRecords`, `createNewView`, 
`hideDeletedRecords`. They currently appear (and crash or do nothing) on
   RECORD_PAGE.

2. Fixes Edit Layout missing from older workspaces -- root cause is 
`conditionalAvailabilityExpression` drift between source-of-truth
constants
and the workspace DB (e.g. #20556 removed a feature flag from the
expression
   without syncing existing workspaces).

The 2-6 workspace command iterates all `STANDARD_COMMAND_MENU_ITEMS` and
reconciles any `conditionalAvailabilityExpression` that differs from the
constant. Idempotent -- already-correct rows are skipped.

Deferred: `deleteRecords` doesn't refetch the current record after
deletion
on RECORD_PAGE (mutation fires but UI shows stale state until refresh)
--
different fix shape (frontend handler), separate PR.
2026-05-19 14:42:40 +00:00
Abdul RahmanandGitHub 08e7e4819b use declared outputSchema for logic-function steps (#20679)
When a logic function declares
`workflowActionTriggerSettings.outputSchema`, use it as the step's
initial output schema so downstream steps can pick variables without
first running the Test tab. A successful test run still overrides the
schema with the inferred shape, preserving "test wins" behavior. Falls
back to the existing "Generate Function Output" LINK placeholder when no
schema is declared (custom code steps, older functions).


https://github.com/user-attachments/assets/af9c45ed-d623-4234-be9f-46812fd06e2e
2026-05-19 14:41:00 +00:00
EtienneandGitHub 827f24df2b fix(ai) - add ai model preferences fallback (#20704)
**Problem** 
AI_MODEL_PREFERENCES, JSON env var is not supported +
IS_CONFIG_VARIABLES_IN_DB_ENABLED=false in twenty cloud server
-> No option to set AI_MODEL_PREFERENCES

**Solution** 
AI_MODEL_PREFERENCES supports three override sources beyond the
hardcoded code defaults, in priority order:

- DB (IS_CONFIG_VARIABLES_IN_DB_ENABLED=true), the only writable source;
admin-panel mutations persist here
- ENV not usable in Twenty Cloud, which does not handle JSON-format env
vars
- **Introduced in this PR** --> File
(AI_MODEL_PREFERENCES_STORAGE_PATH), a read-only startup fallback, the
only viable override in Cloud/self-managed deployments where DB config
is disabled and JSON env vars are unsupported.
2026-05-19 13:19:24 +00:00
martmullGitHubcubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
d5ff9eb515 Create twenty app improvements (#20688)
create-twenty-app updates:
- remove --example option
- sync --once when scaffolding an applicaiton
- rename --api-url option to --workspace-url
- create a standalone page when scaffolding an app
<img width="1494" height="765" alt="image"
src="https://github.com/user-attachments/assets/0e35ed0c-b0aa-466c-9f56-7939294fd2cf"
/>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-05-19 13:12:47 +00:00
71a7a3c42d i18n - website translations (#20724)
Created by Github action

Co-authored-by: github-actions <[email protected]>
2026-05-19 15:17:23 +02:00
cbac2ba0bf i18n - translations (#20725)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-05-19 15:15:56 +02:00
4452b0f03d i18n - website translations (#20723)
Created by Github action

Co-authored-by: github-actions <[email protected]>
2026-05-19 15:09:49 +02:00
Paul RastoinandGitHub 57f13c9b92 [CONNECTED_ACCOUNT_BREAKING_CHANGE] Encrypt ConnectedAccount connectionParameters (#20673)
# Introduction
Prevent any cross user `connectedAccount` `connectionParamaters` leak
Also encrypt in db all `connectionParameters` password
Never return any password through `DTO` anymore
The settings now allow update mutation without providing the password in
edition mode

Verified all `connectionParameters.password` interaction

## Integration tests
- Added more coverage for both failing and successful paths
- Introduced a new env var that allow bypass the provider connection
test

## Legacy connected Account decryption support
Stop allowing non encrypted decryption on `accessToken` and
`refreshToken`, only allow legacy decryption on refactored
`connectionParameters`

## Upsert ownership
Completely got rid of the connected workspace schema context which is
legacy
Also now a user can only upsert a connected account for him only..

## New UI
<img width="1770" height="1852" alt="image"
src="https://github.com/user-attachments/assets/55c1dc89-42ff-4084-95e2-cc5f9e23753b"
/>
If in edition the password is by default disabled
It needs to be selected as being edited to be enabled

## Next
- Refactor tool permissions flag not to include connected accounts
- Remove the legacy connected standard object
- Refactor and improve connected account resolver auth
2026-05-19 12:56:44 +00:00
neo773andGitHub 72c0c36db5 fix(twenty-front): prevent connected account row overflow on long status label (#20713)
Reproducible on German language

Before

<img width="638" height="262" alt="SCR-20260519-ofhi"
src="https://github.com/user-attachments/assets/633ddd9a-203d-472a-bf29-379d6f088e80"
/>


After

<img width="806" height="434" alt="SCR-20260519-oesa"
src="https://github.com/user-attachments/assets/ca37c06b-e439-4156-8447-0e75a5f3fe9c"
/>


/closes #20594
2026-05-19 12:39:00 +00:00
Charles BochetandGitHub 77514ad14a fix(server): backport relationTargetFieldMetadataId column-add to 2.4 and 2.5 fast instance (#20721)
## Summary

Cross-version upgrade from a **v2.3 or v2.4 baseline** to v2.6.x
currently fails at the 2.5 workspace command
`NormalizeCompositeFieldDefaults`:

```
[QueryFailedError] column ViewFilterEntity.relationTargetFieldMetadataId does not exist
  at WorkspaceFlatViewFilterMapCacheService.computeForCache
```

Reproduced locally via Docker cross-version upgrade (v2.6.1 against
`twentycrm/twenty:v2.3` and `:v2.4` images on a freshly-seeded DB).

### Root cause

The column-add is already declared in two places:
-
`2-3/.../1747234300000-add-relation-target-field-metadata-id-to-view-filter`
(backport from #20664)
-
`2-6/.../1798000005000-add-relation-target-field-metadata-id-to-view-filter`

But the runner's `resolveStartCursor`
(`upgrade-sequence-runner.service.ts`) advances forward from
`lastAttemptedCommandName` and never re-runs commands inserted *behind*
the cursor:

- **fresh install through 1.23 → 2.6.x**: cursor < 2.3 → 2.3 backport
runs → column added before 2.5 workspace ✓
- **v2.3 baseline → 2.6.x**: cursor past 2.3 → 2.3 backport skipped →
2.5 workspace `NormalizeCompositeFieldDefaults` crashes ✗
- **v2.4 baseline → 2.6.x**: cursor past 2.4 → 2.3 backport skipped →
same crash ✗
- **v2.5 baseline → 2.6.x**: cursor past 2.5 → 2.5 workspace already
applied (ran against v2.5 source's older entity without the column) →
2.6 fast adds the column ✓

The 2.6 fast `1798000005000` runs *after* the 2.5 workspace command, too
late to help v2.3 / v2.4 baselines.

### Fix

Mirror the existing 2.3 Early backport at two more versions:

-
`2-4/.../1747234400000-add-relation-target-field-metadata-id-to-view-filter`
— covers v2.3 baseline (runs in 2.4 fast, before any 2.4/2.5 workspace
command)
-
`2-5/.../1747234500000-add-relation-target-field-metadata-id-to-view-filter`
— covers v2.4 baseline (runs in 2.5 fast, before
`NormalizeCompositeFieldDefaults`)

Both use `ADD COLUMN IF NOT EXISTS` (idempotent) and `DROP COLUMN IF
EXISTS` for the down. No FK / index — those still live in the 2.6 file,
which runs as a no-op for the column on already-fixed DBs.

Pre-2.6 codebases can't use `@WasIntroducedInUpgrade` (#20686 only lands
in 2.6), so this "ladder of backports" remains the operative pattern.

## Audit context

Locally walked `v1.23 / v2.0 / v2.1 / v2.2 / v2.3 / v2.4 / v2.5 →
v2.6.1`:

| Baseline | Result |
|---|---|
| v1.23 | PASS |
| v2.0 | PASS |
| v2.1 | PASS |
| v2.2 | PASS |
| **v2.3** | **FAIL** (this PR) |
| **v2.4** | **FAIL** (this PR) |
| v2.5 | PASS |
2026-05-19 14:46:56 +02:00
3d5c6cb0e5 i18n - website translations (#20722)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-05-19 14:34:21 +02:00
Abdullah.andGitHub 4b12fda3f5 [Website] Hide Product and Articles from navigation and remove language switcher. (#20718)
Restore "Why" as the top-level nav item, remove Product and Articles
from menu and footer, and hide the language switcher in the footer for
this release. Pages remain accessible via direct URL and stay indexed.

Will re-add once the release is out.
2026-05-19 12:14:55 +00:00
MarieandGitHub 9fddaf53d5 Fix BUILDER_INTERNAL_SERVER_ERROR message (#20720)
The throw site was passing (code, message) to a constructor whose
signature is (message, code), so exception.message ended up as the
literal string "BUILDER_INTERNAL_SERVER_ERROR" and the real
error.message was stored in exception.code where nothing reads it.
Swapping the two args puts the real error message back into
exception.message, which is the field Yoga's error handler copies into
the GraphQL response's top-level message — and that's the field the CLI
prints.
2026-05-19 12:09:31 +00:00
Charles BochetandGitHub 3512849004 refactor(server): drop logo select workaround in flat-application cache (#20708)
## Summary

Replaces the temporary `select: { ... }` workaround in
`WorkspaceFlatApplicationMapCacheService` (introduced by #20159) with a
property-level `@WasIntroducedInUpgrade` decorator on
`ApplicationEntity.logo`.

#20159's own description called itself out: *"This is a temporary fix
for cross-version upgrade process, a better fix would be to expose an
hasInstanceCommandBeenRun() util (and later a decorator)"*. The
decorator now exists, courtesy of #20686.

## Root cause recap

`ApplicationEntity.logo` is added by
`2-2-instance-command-fast-1777539664664-add-logo-to-application.ts`.
The column is declared on the entity class, so before that instance
command runs (i.e. on a cross-version upgrade from a 2.1 or older
baseline), TypeORM's bare `repository.find()` emits `SELECT \"logo\" …`
against a table that doesn't have the column yet → upgrade aborts.
#20159 worked around this by listing every column **except** `logo` in
an explicit `select`, with an `as unknown as
FindOptionsSelect<ApplicationEntity>` cast.
2026-05-19 10:41:22 +00:00
Charles BochetandGitHub 72ce77864e feat(server): Enterprise cron that rotates the current JWT signing key (#20612)
## Summary
Adds a daily Enterprise-only cron that rotates the current ES256 JWT
signing key once it has been current for `SIGNING_KEY_ROTATION_DAYS`.
Manual rotation from the admin panel is unaffected.

### Behaviour
- `SIGNING_KEY_ROTATION_DAYS` is **opt-in**: when unset, the cron is a
no-op.
- Rotation flips `isCurrent` and clears the previous key's `privateKey`
in the same transaction, then inserts the new `isCurrent=true` row.
- The previous key's row is kept (`revokedAt` stays `null`) so its
`publicKey` can keep verifying tokens it signed until they expire; only
the encrypted `privateKey` is wiped since it can no longer be used to
sign.
- **No auto-revocation** — revoking a key remains a manual admin action,
reserved for leak / emergency response.
- The cron is also a no-op when `EnterprisePlanService.isValid()` is
`false`.

### Wiring
- `JwtKeyManagerService.rotateCurrent()`
- `SigningKeyRotationService.rotateIfDue()` (reads
`SIGNING_KEY_ROTATION_DAYS`, skips when unset)
- `RotateSigningKeysCronJob` (Enterprise-gated, rethrows on failure)
registered in `JwtModule`
- `RotateSigningKeysCronCommand` registered with `cron:register:all`
- `ROTATE_SIGNING_KEYS_CRON_PATTERN = '15 3 * * *'` (daily, no-op until
threshold)

Operator documentation lives in #20611 (docs PR).
2026-05-19 10:41:04 +00:00
neo773andGitHub 6cd069ce40 messaging minor perf improvement (#20687)
This PR adds two changes

1. Pass `lite:true` to `ExecuteInWorkspaceContextOptions` introduced in
https://github.com/twentyhq/twenty/pull/18376

2. Remove redundant gmail alias call, it adds 300ms every cron job, we
only do it once now when user connects, realistically I don't see people
changing their aliases every day you only set it up once

actual real diff is small, it's just prettier format contributing to
diff

Objective decrease total time take per job
2026-05-19 10:38:34 +00:00
db4a05301a i18n - website translations (#20712)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-05-19 12:27:49 +02:00
Abdullah.andGitHub f9e3683518 [Website] Change product hero to reveal tabs on scroll. (#20707)
Here's the video - it still needs refinement, but we want to hide
articles and product pages to push out a release today. Merging this as
a checkpoint so the next PR can hide these pages from navigation for the
release.


https://github.com/user-attachments/assets/eb5048b2-d3df-4920-a62a-5b2617d11e4a
2026-05-19 10:12:44 +00:00
fecea1bfae i18n - translations (#20710)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-05-19 12:13:33 +02:00
3281d37bdf Fix(twenty-front): BlockNote slash command shows empty state when no match (#20689)
Fixes: #20625
Original PR: #20626

New changes:
- Now the text says "Close menu" instead of "No command found".
- "Close menu" is interactive like other commands ( With keyboard and
with mouse ).
- It closes automatically when user type 3 extra character past the
point of no result.


https://github.com/user-attachments/assets/9c1315b4-5a63-424d-8da8-dc1283535725

---------

Co-authored-by: Charles Bochet <[email protected]>
2026-05-19 09:47:18 +00:00
Félix MalfaitandGitHub 291ce5ccdb fix(filters): make filter dispatcher own relation-target resolution (#20670)
## Summary

Two relation-traversal bugs surfaced post-merge of #20533, both rooted
in the same architectural smell: the GraphQL filter dispatcher took a
flat `fields: FieldShared[]` array and silently dropped any filter whose
`relationTargetFieldMetadataId` wasn't in that array. Callers had to
remember to pre-augment the list with relation targets — and 16+ call
sites did not all know this.

This PR fixes both bugs and removes the smell.

### Bug 1 — Save as new view loses the relation target

`useCreateViewFromCurrentView` built the create-filter input without
`relationTargetFieldMetadataId`. The saved view's filter persisted
without the traversal — on reload the chip showed "Company contains
'air'" instead of "Company → Name contains 'air'". Discarded at save
time, not at read time.

Fix: include `relationTargetFieldMetadataId` in the create input.
(Commit 1.)

### Bug 2 — Workflow Search Records drops one-hop traversals

`FindRecordsWorkflowAction` built its fields list from
`flatObjectMetadata.fieldIds` only (source object's fields). The shared
dispatcher then couldn't resolve the relation target field on the
related object and silently dropped the filter — a configured "People
where Company → Name Contains 'Airbnb'" came through as `{ and: [] }`.

This was the same shape as bugs already fixed in 5 other call sites
(chart filters, view filters, record table, etc.). The pattern was:
caller forgets to augment fields → dispatcher silently drops the filter.

Fix (commit 2): change the dispatcher to take a
`findFieldMetadataItemById: (id) => FieldShared | undefined` resolver
callback. Both source-field and relation-target-field lookups go through
the same resolver, so callers no longer need to know about the
augmentation requirement. Frontend callers pass a workspace-wide
resolver built from `flattenedFieldMetadataItemsSelector`; server
callers wrap `findFlatEntityByIdInFlatEntityMaps` on
`flatFieldMetadataMaps`. In both cases relation-target lookups just
work, because the resolver can see fields on related objects.

## Why this matters

Before: "if you call the dispatcher, pre-augment your fields list with
relation targets, or filters get silently dropped." An invariant only
enforceable by code review, broken often enough to ship two user-visible
bugs in one week.

After: the dispatcher resolves field ids itself. There's no list to
forget to augment. The failure mode (filter silently dropped) becomes
structurally impossible at the dispatcher boundary.

Net diff: 240 insertions, 319 deletions. Removed
`augmentFieldsWithRelationTargets` (frontend) and the workflow
whack-a-mole code (server).

## Test plan
- [ ] Save view: create an advanced filter using a one-hop relation
traversal, click "Save as new view", reload, confirm the chip still
reads "Source → Target operator value"
- [ ] Workflow: configure a Search Records action with a
relation-traversal filter, run the workflow, confirm the filter is
actually applied
- [ ] Dashboard chart: configure a chart with a relation-traversal
filter, confirm the chart data respects it
- [ ] Record table, group-by, calendar, total count, footer aggregates:
all continue to work with both plain and relation-traversal filters
2026-05-19 11:55:22 +02:00
martmullandGitHub a9634b027e Stop bundling twenty-ui react cjs runtime code (#20703)
For this front component using Avatar from `twenty-sdk/ui`

```typescript
import { defineFrontComponent } from 'twenty-sdk/define';
import { Avatar } from 'twenty-sdk/ui';

// React component - implement your UI here
const Component = () => {
  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
      <Avatar avatarUrl={null} placeholder={'test'} />
      <h1>My new component!</h1>
      <p>This is your front component: fc-test</p>
    </div>
  );
};

export default defineFrontComponent({ ... });
```

## Before
<img width="3024" height="1964" alt="image"
src="https://github.com/user-attachments/assets/64479d9a-2316-4782-9552-c3982d85f97c"
/>

## After

<img width="1150" height="567" alt="image"
src="https://github.com/user-attachments/assets/67f34da2-a546-40e3-9f5d-62a5de6e4146"
/>
2026-05-19 09:26:41 +00:00
ae41751a8c i18n - docs translations (#20705)
Created by Github action

Co-authored-by: github-actions <[email protected]>
2026-05-19 11:34:14 +02:00
11d8679f65 i18n - docs translations (#20702)
Created by Github action

Co-authored-by: github-actions <[email protected]>
2026-05-19 09:40:04 +02:00
05f31c1837 docs(self-host): document ENCRYPTION_KEY, FALLBACK_ENCRYPTION_KEY and key rotation procedures (#20611)
## Summary
- Documents the new at-rest encryption envelope (`ENCRYPTION_KEY` /
`FALLBACK_ENCRYPTION_KEY`) introduced in v2.5+ and clarifies its
relationship to the legacy `APP_SECRET`-as-encryption-key path.
- Adds a new dedicated **Key rotation** guide covering manual /
Enterprise-cron JWT signing-key rotation, signing-key revocation, and
the online `ENCRYPTION_KEY` rotation procedure (including the new
\`secret-encryption:rotate\` CLI shipped in a follow-up PR).
- Updates the docker-compose quickstart to generate a dedicated
\`ENCRYPTION_KEY\` from day 1.
- Mentions the v2.5+ enc:v2 backfill in the upgrade guide.

English-only — the localized mirrors will be picked up by i18n CI.

## Test plan
- [ ] Mintlify build passes locally / in CI
- [ ] Sidebar entry renders under **Self-Host → Key rotation**
- [ ] Internal links to /developers/self-host/capabilities/key-rotation
resolve from setup.mdx, docker-compose.mdx and upgrade-guide.mdx

---------

Co-authored-by: github-actions <[email protected]>
2026-05-19 06:45:40 +00:00
2a92f34d06 chore: bump version to 2.7.0 (#20693)
## Summary

- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version

## Checklist

- [ ] Verify version constants are correct

Co-authored-by: Github Action Deploy <[email protected]>
2026-05-19 08:10:57 +02:00
d9b125efc5 i18n - website translations (#20694)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-05-19 08:10:35 +02:00
Charles BochetandGitHub e6b7f31fea fix(front): prevent standalone page layout crash from useTargetRecord (#20698)
## Context

Reported in production on `engineering.twenty.com` — standalone page
layouts (e.g. "Release overview") crash with the React error boundary
fallback ("Sorry, something went wrong"). The console shows:

```
Error: useTargetRecord must be used within a record page context (targetRecordIdentifier is required)
```

The minified stack trace points at `SidePanelToggleButto…`, but that's
just the bundle chunk name — the actual call site is
`PageLayoutTabsRenderer`.

## Root cause

#19296 added an unconditional `useTargetRecord()` call inside
`PageLayoutTabsRenderer` so it could read the target object's metadata
and hide tabs whose widgets reference deactivated relations:

```ts
const targetRecord = useTargetRecord();

const { objectMetadataItem } = useObjectMetadataItem({
  objectNameSingular: targetRecord.targetObjectNameSingular,
});
```

But `PageLayoutTabsRenderer` runs on **both** record pages and
standalone pages. On standalone pages, `StandalonePageLayoutPage`
intentionally sets `targetRecordIdentifier: undefined` in
`LayoutRenderingProvider`, which makes `useTargetRecord()` throw — and
the follow-up `useObjectMetadataItem()` would also throw on miss.
2026-05-19 00:40:37 +02:00
Charles BochetandGitHub bad1f20012 fix(server): handle legacy PK name in 2.6 rename-permission-flag upgrade (#20697)
## Summary

The 2.6 `RenamePermissionFlagToRolePermissionFlag` upgrade command
failed on staging and dev with:

```
[QueryFailedError] constraint "PK_a02789db60620a1e9f90147b50f" for table "rolePermissionFlag" does not exist
in RenamePermissionFlagToRolePermissionFlag1778235340020 (2.6.0) (instance fast)
```

### Root cause

TypeORM names PKs as `PK_<sha1(tableName_sortedColumnNames)[:27]>`. So:
- `permissionFlag_id` → `PK_a02789db60620a1e9f90147b50f`
- `settingPermission_id` → `PK_8c144a021030d7e3326835a04c8`
- `rolePermissionFlag_id` → `PK_76591adc8035c2e7b0cd6115136`

On databases initially migrated before the v1.5.5 migration squash
(#15183), the table was renamed `settingPermission` → `permissionFlag`
via the pre-squash migration
`1753149175945-renameSettingPermissionToPermissionFlag.ts`. That
migration renamed the table, the column, the unique index, and the role
FK, but **never renamed the PK constraint** — and Postgres does not
auto-rename constraints on `ALTER TABLE ... RENAME TO`. Those instances
therefore still carry the legacy PK name
`PK_8c144a021030d7e3326835a04c8`.

Fresh installs (squashed `setupMetadataTables` migration) instead have
the expected `PK_a02789db60620a1e9f90147b50f`.

The 2.6 upgrade only handled the fresh-install name, so it broke for any
DB that went through the historical rename chain.

### Fix

Replace the brittle `RENAME CONSTRAINT` with `DROP CONSTRAINT IF EXISTS`
for both historical PK names, followed by `ADD CONSTRAINT ... PRIMARY
KEY ("id")` with the canonical new name. The migration now converges to
the same PK name regardless of the DB's history.

The same pattern is applied symmetrically in `down()`.

### Why this is safe

- The whole instance command runs in a transaction
(`InstanceCommandRunnerService.runFastInstanceCommand`).
- The first statement (`ALTER TABLE ... RENAME TO`) takes `ACCESS
EXCLUSIVE` on the table, so the drop/add window for the PK is invisible
to any concurrent writer — they queue on the lock until commit.
- No FK references `rolePermissionFlag.id` at this point in the sequence
(migration 22 introduces an FK pointing at the new `permissionFlag`
catalog created in migration 21, not at the renamed grant table), so
dropping the PK does not cascade or block.
- `NOT NULL` and the `uuid_generate_v4()` default on `id` are
column-level and remain in place when the PK is dropped.

## Test plan

- [ ] Run 2.6 upgrade against a fresh-install database (PK =
`PK_a02789db60620a1e9f90147b50f`) — should succeed.
- [ ] Run 2.6 upgrade against a pre-squash database (PK =
`PK_8c144a021030d7e3326835a04c8`, reproducible on current staging/dev) —
should now succeed.
- [ ] Verify post-migration: `rolePermissionFlag` exists, PK is named
`PK_76591adc8035c2e7b0cd6115136`, all FKs and indexes named as expected.
- [ ] Run `down()` and verify table returns to `permissionFlag` with PK
`PK_a02789db60620a1e9f90147b50f`.
- [ ] Subsequent migrations (`1778235340021` permission-flag catalog,
`1778235340022` link, `1778235340023` backfill) still apply cleanly.
2026-05-18 23:04:15 +02:00
ce8ef261c1 Update pricing plan cards (#20614)
## Summary
- Update pricing top-card bullets to use workflow credits, keep full
customization, and show Organization-only features accurately.
- Make custom AI models self-host Organization-only and move custom
domain into the Cloud Organization card.
- Align pricing comparison rows for row-level permissions, encryption
key rotation, API call limits, custom domain availability, and self-host
custom objects/fields.

## Validation
- `lingui extract --overwrite --clean`
- `lingui compile --typescript`
- `node scripts/check-section-shape.mjs`
- `git diff --check`
- Playwright snapshot of `http://localhost:3002/pricing`

---------

Co-authored-by: Charles Bochet <[email protected]>
2026-05-18 19:43:15 +00:00
Charles BochetandGitHub 1d3d3999e2 feat(server): upgrade-aware entity decorators for cross-version upgrades (#20686)
## What

When the same PR introduces a new core entity *and* adds a cache
provider that queries it, every workspace step from older versions that
runs before the introducing instance step hits `relation … does not
exist` — the cause of the failed [v2.6.0 staging-ci
run](https://github.com/twentyhq/twenty-infra/actions/runs/26042742000).
Same class of failure for renamed core entities and for new FK columns
hidden inside relation loads.

This PR adds **upgrade-aware entity decorators** + a runtime that adapts
TypeORM's view of the schema to the current `core.upgradeMigration`
cursor.

## Strategy

```
                    ┌────────────────────────────────┐
                    │  @Entity classes (final shape) │
                    │   + @WasIntroducedInUpgrade    │
                    │   + @WasRenamedInUpgrade       │
                    └───────────────┬────────────────┘
                                    │
              UpgradeSequenceRunner.run()
              ┌─────────────────────┴─────────────────────┐
              ▼                                           ▼
       step N+1 begins                          step N just completed
              │                                           │
              └────────► adapter.refresh() ◄──────────────┘
                            │
            reads core.upgradeMigration via
            UpgradeMigrationService.getLastAttemptedInstanceCommand
                            │
                            ▼
       ┌────────────────────────────────────────────────────────┐
       │  UpgradeAwareEntityMetadataAdapter                     │
       │  • mutates EntityMetadata.tableName / tablePath        │
       │     -> historical name for renames not yet applied     │
       │  • flips column.isSelect = false for not-yet-introduced│
       │     columns                                            │
       │  • tracks per-entity availability sidecar              │
       └─────────────────┬──────────────────────────────────────┘
                         │
                         ▼
       DataSource.getRepository wrapped at TypeOrmModule.forRoot:
       repo.find() / findOne() / count() / …
       ┌─────────────────────────────────────────┐
       │  wrapRepositoryWithUpgradeAwareProxy    │
       │  • entity unavailable -> short-circuit  │
       │     (find -> [], count -> 0,            │
       │      findOneOrFail -> EntityNotFound)   │
       │  • write -> Promise.reject(             │
       │      UpgradeUnavailableEntityWriteEx)   │
       │  • find({ relations: ['X'] }) with X    │
       │     unavailable -> X stripped           │
       └─────────────────────────────────────────┘
```

The decorator strings reference real `core.upgradeMigration.name` values
(`${version}_${className}_${timestamp}`). A boot-time validator walks
the actual `UpgradeSequenceReaderService.getUpgradeSequence()` and fails
fast on typos.

## Files

- New decorators:
`engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator.ts`,
`was-renamed-in-upgrade.decorator.ts`
- Runtime: `engine/twenty-orm/upgrade-aware/` (adapter, proxy, install
hook, state singleton, exceptions)
- Wired into `UpgradeSequenceRunnerService` (`refresh()` between steps)
and `TypeOrmModule.forRoot` (proxy install)
- 2-6 entity decorations: `RolePermissionFlagEntity` (rename history +
new `permissionFlagId` column), `PermissionFlagEntity` (new catalog)

## Validation

End-to-end local cross-version upgrade (v1.22 → HEAD): `28 workspace(s)
succeeded, 0 failed`; `upgrade:status → Instance: Up to date, 4 up to
date, 0 behind, 0 failed`. Full log excerpts and the
second-failure-found-and-fixed (`WorkspaceRolesPermissionsCacheService`
relation load) in [this
comment](https://github.com/twentyhq/twenty/pull/20686#issuecomment-4480036816).

## Test plan

- [x] Adapter spec covers rename mutation; proxy spec covers `find()`
short-circuit on unavailable entity. Resolver + validator + decorators
are covered by `resolve-entity-shape-at-upgrade-cursor.util.spec.ts`
(integration-level via real decorator application).
- [x] `nx lint:diff-with-main twenty-server` + `nx typecheck
twenty-server` clean
- [x] All 82 affected tests passing
- [ ] Cross-version-upgrade CI re-runs after this lands; v2.6.0 retag
once green

## Follow-ups deferred

- v2.7 `connectionProvider` rename repro as a permanent end-to-end test
artifact
- Extending the proxy to also cover `EntityManager.getRepository` and
`createQueryBuilder` if a non-`find()` upgrade-time consumer surfaces
2026-05-18 21:38:20 +02:00
EtienneandGitHub 89579f5225 fix(ai-chat) - upload files (#20681)
closes https://github.com/twentyhq/twenty/issues/20437

bonus : persist file filename for UI display
2026-05-18 15:50:02 +00:00
132d997474 i18n - translations (#20685)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-05-18 17:42:30 +02:00
7c252ff233 Fix 19026 deactivated relation unassignable (#19296)
PR to fix the bug #19026 

This PR will ensure that if an object has some relation deactivated, the
relation will not be visible in the side panel tab and will not be
assignable in the deactivated relation.

## Notes deactivated in People
<img width="1068" height="1106" alt="image"
src="https://github.com/user-attachments/assets/e8c2dbf3-5391-4dbc-8e40-79fcc44e8158"
/>

## Notes not visible in the side panel of people
<img width="2390" height="892" alt="image"
src="https://github.com/user-attachments/assets/308a78aa-2c6d-4d3d-b67c-b49795839aae"
/>

---------

Co-authored-by: Thomas Trompette <[email protected]>
2026-05-18 15:25:51 +00:00
nitinandGitHub 4ba9c0ca0b [Navigation Drawer] Multiple fixes in settings and app drawer (#20634)
closes -
https://discord.com/channels/1130383047699738754/1487720717192527942



https://github.com/user-attachments/assets/6db2df8b-be01-4b5f-a958-575d87b41559

~~waiting on @Bonapara 's feedback!~~
2026-05-18 15:22:03 +00:00
Charles BochetandGitHub d03480472c perf(server): index messageChannel/calendarChannel for per-workspace sync crons (#20678)
## Summary

The messaging/calendar import crons each iterate every active workspace
and execute one `find` per workspace against `core."messageChannel"` /
`core."calendarChannel"` with the shape:

```
WHERE "workspaceId" = $1 AND "isSyncEnabled" = true AND "syncStage" = $2 [AND "type" <> $3]
```

There is currently no index supporting that shape, so the planner does a
seq scan on each table for every iteration. On prod-eu (RDS Performance
Insights, `rds-prod-eu-one`), these two queries are the top two by load
— together ~12 AAS, ~12 calls/sec — and have been the primary
contributor to the sustained 100% CPU since active workspace count grew.

This PR adds composite indexes on `(workspaceId, isSyncEnabled,
syncStage)` for both tables as an instance migration in 2.6.0.
2026-05-18 14:31:03 +00:00
8f3c336e62 i18n - docs translations (#20680)
Created by Github action

Co-authored-by: github-actions <[email protected]>
2026-05-18 15:37:33 +02:00
1751 changed files with 28402 additions and 8058 deletions
+21 -21
View File
@@ -106,24 +106,24 @@ Replace `{VERSION}` with the actual version number (e.g., `1.9.0`)
### 2. Create File Structure
**Create changelog file:**
- Path: `packages/twenty-website-new/src/content/releases/{VERSION}.mdx`
- Example: `packages/twenty-website-new/src/content/releases/1.9.0.mdx`
- Path: `packages/twenty-website/src/content/releases/{VERSION}.mdx`
- Example: `packages/twenty-website/src/content/releases/1.9.0.mdx`
**Create image folder:**
- Path: `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
- Example for version 1.9.0: `packages/twenty-website-new/public/images/releases/1.9/`
- Example for version 2.0.0: `packages/twenty-website-new/public/images/releases/2.0/`
- Path: `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
- Example for version 1.9.0: `packages/twenty-website/public/images/releases/1.9/`
- Example for version 2.0.0: `packages/twenty-website/public/images/releases/2.0/`
```bash
# Create the image folder
mkdir -p packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
mkdir -p packages/twenty-website/public/images/releases/{MINOR_VERSION}
```
### 3. Move Illustration Files
**Source:** `/Users/thomascolasdesfrancs/Downloads/🆕`
**Destination:** `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
**Destination:** `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
**Naming Convention:** `{VERSION}-descriptive-name.webp`
@@ -133,8 +133,8 @@ Examples:
```bash
# Move and rename source files, then convert to webp if needed
cp ~/Downloads/🆕/source-file.png packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
cd packages/twenty-website-new && node scripts/convert-png-to-webp.mjs
cp ~/Downloads/🆕/source-file.png packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
cd packages/twenty-website && node scripts/convert-png-to-webp.mjs
```
### 4. Research Features (if needed)
@@ -183,7 +183,7 @@ Description of the third feature.
- **NEVER mention the brand name "Twenty"** in changelog text - use "your workspace", "the platform", or similar neutral references instead
**Reference Previous Changelogs:**
- Check `packages/twenty-website-new/src/content/releases/` for examples
- Check `packages/twenty-website/src/content/releases/` for examples
- Recent releases: 1.7.0.mdx, 1.6.0.mdx, 1.5.0.mdx
### 6. Review
@@ -191,10 +191,10 @@ Description of the third feature.
Open the changelog file for review:
```bash
# Open in Cursor
cursor packages/twenty-website-new/src/content/releases/{VERSION}.mdx
cursor packages/twenty-website/src/content/releases/{VERSION}.mdx
# Open image folder to verify illustrations
open packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
open packages/twenty-website/public/images/releases/{MINOR_VERSION}
```
Review checklist:
@@ -222,8 +222,8 @@ I've created the changelog for version {VERSION}. Here's the content for your re
[Show full MDX content]
Images moved to:
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
Please review the content. Once you approve, I'll commit the changes and create the pull request.
```
@@ -242,8 +242,8 @@ Possible user responses:
git status
# Add files
git add packages/twenty-website-new/src/content/releases/{VERSION}.mdx
git add packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
git add packages/twenty-website/src/content/releases/{VERSION}.mdx
git add packages/twenty-website/public/images/releases/{MINOR_VERSION}/
# Commit
git commit -m "Add {VERSION} release changelog"
@@ -266,7 +266,7 @@ This release includes:
- Feature 2
- Feature 3
Changelog file: \`packages/twenty-website-new/src/content/releases/{VERSION}.mdx\`
Changelog file: \`packages/twenty-website/src/content/releases/{VERSION}.mdx\`
Release date: {DATE}" \
--base main \
--head {VERSION}
@@ -280,13 +280,13 @@ Or visit: `https://github.com/twentyhq/twenty/pull/new/{VERSION}`
- **Format**: `{MAJOR}.{MINOR}.{PATCH}.mdx`
- **Convention**: One file per complete version
- **Examples**: `1.6.0.mdx`, `1.7.0.mdx`, `2.0.0.mdx`
- **Location**: `packages/twenty-website-new/src/content/releases/`
- **Location**: `packages/twenty-website/src/content/releases/`
### Image Folders
- **Format**: `{MAJOR}.{MINOR}/`
- **Convention**: One folder per minor version (shared across patches)
- **Examples**: `1.6/`, `1.7/`, `2.0/`
- **Location**: `packages/twenty-website-new/public/images/releases/`
- **Location**: `packages/twenty-website/public/images/releases/`
### Image Files
- **Format**: `{VERSION}-descriptive-name.webp`
@@ -311,8 +311,8 @@ Features to document:
3. ___________________________
Branch name: {VERSION}
Changelog path: packages/twenty-website-new/src/content/releases/{VERSION}.mdx
Images path: packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
Changelog path: packages/twenty-website/src/content/releases/{VERSION}.mdx
Images path: packages/twenty-website/public/images/releases/{MINOR_VERSION}/
```
## Tips
+2 -2
View File
@@ -1,5 +1,5 @@
#
# Crowdin CLI configuration for Website translations (twenty-website-new)
# Crowdin CLI configuration for Website translations (twenty-website)
# Project ID: 4
# See https://crowdin.github.io/crowdin-cli/configuration for more information
#
@@ -16,7 +16,7 @@ files:
#
# Source file - PO file for Lingui
#
- source: packages/twenty-website-new/src/locales/en.po
- source: packages/twenty-website/src/locales/en.po
#
# Translation files path
#
@@ -1,201 +0,0 @@
name: CI Hello world App E2E
on:
# Temporarily disabled — will be re-enabled when example apps are published.
# push:
# branches:
# - main
#
# pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/create-twenty-app/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
create-app-e2e-hello-world:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Set CI version and prepare packages for publish
run: |
CI_VERSION="0.0.0-ci.$(date +%s)"
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx run $pkg:set-local-version --releaseVersion=$CI_VERSION
done
- name: Build packages
run: |
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx build $pkg
done
- name: Install and start Verdaccio
run: |
npx verdaccio --config .github/verdaccio-config.yaml &
for i in $(seq 1 30); do
if curl -s http://localhost:4873 > /dev/null 2>&1; then
echo "Verdaccio is ready"
break
fi
echo "Waiting for Verdaccio... ($i/30)"
sleep 1
done
- name: Publish packages to local registry
run: |
yarn config set npmRegistryServer http://localhost:4873
yarn config set unsafeHttpWhitelist --json '["localhost"]'
yarn config set npmAuthToken ci-auth-token
for pkg in $PUBLISHABLE_PACKAGES; do
cd packages/$pkg
yarn npm publish --tag ci
cd ../..
done
- name: Scaffold app using published create-twenty-app
run: |
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --example hello-world --display-name "Test hello-world app" --description "E2E test hello-world app" --api-url http://localhost:3000
- name: Install scaffolded app dependencies
run: |
cd /tmp/e2e-test-workspace/test-app
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
echo "--- Installing last SDK versions ---"
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
- name: Verify installed app versions
run: |
cd /tmp/e2e-test-workspace/test-app
echo "--- Checking package.json references correct SDK version ---"
node -e "
const pkg = require('./package.json');
const sdkVersion = pkg.dependencies['twenty-sdk'];
if (!sdkVersion.startsWith('0.0.0-ci.')) {
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
process.exit(1);
}
console.log('SDK version in scaffolded app:', sdkVersion);
"
- name: Verify SDK CLI is available
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty --version
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
- name: Deploy scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty deploy
- name: Install scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty install
- name: Execute hello-world logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName hello-world-logic-function)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
- name: Execute create-hello-world-company logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-hello-world-company)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
- name: Run scaffolded app integration test
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
ci-create-app-e2e-hello-world-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-e2e-hello-world]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
@@ -105,7 +105,7 @@ jobs:
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --api-url http://localhost:3000
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --workspace-url http://localhost:3000
- name: Install scaffolded app dependencies
run: |
@@ -1,199 +0,0 @@
name: CI Postcard App E2E
on:
# Temporarily disabled — will be re-enabled when example apps are published.
# push:
# branches:
# - main
#
# pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/create-twenty-app/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
create-app-e2e-postcard:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Set CI version and prepare packages for publish
run: |
CI_VERSION="0.0.0-ci.$(date +%s)"
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
npx nx run-many -t set-local-version -p $PUBLISHABLE_PACKAGES --releaseVersion=$CI_VERSION
- name: Build packages
run: |
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx build $pkg
done
- name: Install and start Verdaccio
run: |
npx verdaccio --config .github/verdaccio-config.yaml &
for i in $(seq 1 30); do
if curl -s http://localhost:4873 > /dev/null 2>&1; then
echo "Verdaccio is ready"
break
fi
echo "Waiting for Verdaccio... ($i/30)"
sleep 1
done
- name: Publish packages to local registry
run: |
yarn config set npmRegistryServer http://localhost:4873
yarn config set unsafeHttpWhitelist --json '["localhost"]'
yarn config set npmAuthToken ci-auth-token
for pkg in $PUBLISHABLE_PACKAGES; do
cd packages/$pkg
yarn npm publish --tag ci
cd ../..
done
- name: Scaffold app using published create-twenty-app
run: |
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --example postcard --display-name "Test postcard app" --description "E2E test postcard app" --api-url http://localhost:3000
- name: Install scaffolded app dependencies
run: |
cd /tmp/e2e-test-workspace/test-app
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
echo "--- Installing last SDK versions ---"
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
- name: Verify installed app versions
run: |
cd /tmp/e2e-test-workspace/test-app
echo "--- Checking package.json references correct SDK version ---"
node -e "
const pkg = require('./package.json');
const sdkVersion = pkg.dependencies['twenty-sdk'];
if (!sdkVersion.startsWith('0.0.0-ci.')) {
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
process.exit(1);
}
console.log('SDK version in scaffolded app:', sdkVersion);
"
- name: Verify SDK CLI is available
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty --version
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
- name: Deploy scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty deploy
- name: Install scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty install
- name: Execute postcard logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName postcard-logic-function)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
- name: Execute create-postcard-company logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-postcard-company)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
- name: Run scaffolded app integration test
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
ci-create-app-e2e-postcard-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-e2e-postcard]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
files: |
package.json
yarn.lock
packages/twenty-website-new/**
packages/twenty-website/**
packages/twenty-shared/**
website-task:
needs: changed-files-check
+5 -5
View File
@@ -54,7 +54,7 @@ jobs:
# Strict mode fails if there are missing website translations.
- name: Compile website translations
id: compile_translations_strict
run: npx nx run twenty-website-new:lingui:compile --strict
run: npx nx run twenty-website:lingui:compile --strict
continue-on-error: true
- name: Stash any changes before pulling translations
@@ -71,8 +71,8 @@ jobs:
upload_sources: false
upload_translations: false
download_translations: true
source: 'packages/twenty-website-new/src/locales/en.po'
translation: 'packages/twenty-website-new/src/locales/%locale%.po'
source: 'packages/twenty-website/src/locales/en.po'
translation: 'packages/twenty-website/src/locales/%locale%.po'
export_only_approved: false
localization_branch_name: i18n-website
base_url: 'https://twenty.api.crowdin.com'
@@ -101,9 +101,9 @@ jobs:
- name: Compile website translations
id: compile_translations
run: |
npx nx run twenty-website-new:lingui:compile
npx nx run twenty-website:lingui:compile
git status
git add packages/twenty-website-new/src/locales
git add packages/twenty-website/src/locales
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
+5 -5
View File
@@ -10,7 +10,7 @@ on:
push:
branches: ['main']
paths:
- 'packages/twenty-website-new/**'
- 'packages/twenty-website/**'
- '.github/crowdin-website.yml'
- '.github/workflows/website-i18n-push.yaml'
@@ -41,14 +41,14 @@ jobs:
run: npx nx build twenty-shared
- name: Extract website translations
run: npx nx run twenty-website-new:lingui:extract
run: npx nx run twenty-website:lingui:extract
- name: Check and commit extracted files
id: check_extract_changes
run: |
git config --global user.name 'github-actions'
git config --global user.email '[email protected]'
git add packages/twenty-website-new/src/locales
git add packages/twenty-website/src/locales
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: extract website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
@@ -57,14 +57,14 @@ jobs:
fi
- name: Compile website translations
run: npx nx run twenty-website-new:lingui:compile
run: npx nx run twenty-website:lingui:compile
- name: Check and commit compiled files
id: check_compile_changes
run: |
git config --global user.name 'github-actions'
git config --global user.email '[email protected]'
git add packages/twenty-website-new/src/locales/generated
git add packages/twenty-website/src/locales/generated
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
+2 -2
View File
@@ -81,8 +81,8 @@
"path": "../packages/twenty-sdk"
},
{
"name": "packages/twenty-website-new",
"path": "../packages/twenty-website-new"
"name": "packages/twenty-website",
"path": "../packages/twenty-website"
}
],
"settings": {
+1 -1
View File
@@ -110,7 +110,7 @@ packages/
├── twenty-ui/ # Shared UI components library
├── twenty-shared/ # Common types and utilities
├── twenty-emails/ # Email templates with React Email
├── twenty-website-new/ # Next.js marketing website
├── twenty-website/ # Next.js marketing website
├── twenty-docs/ # Documentation website
├── twenty-zapier/ # Zapier integration
└── twenty-e2e-testing/ # Playwright E2E tests
+43 -43
View File
@@ -1,19 +1,19 @@
<p align="center">
<a href="https://www.twenty.com">
<img src="./packages/twenty-website-new/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
<img src="./packages/twenty-website/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
</a>
</p>
<h2 align="center" >The #1 Open-Source CRM</h2>
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website-new/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website-new/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
<p align="center">
<a href="https://www.twenty.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/github-cover-light.webp" alt="Twenty banner" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/github-cover-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/github-cover-light.webp" />
<img src="./packages/twenty-website/public/images/readme/github-cover-light.webp" alt="Twenty banner" />
</picture>
</a>
</p>
@@ -24,17 +24,17 @@
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
<a href="https://twenty.com/resources/why-twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
<a href="https://twenty.com/resources/why-twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
<br />
# Installation
### <img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
### <img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
The fastest way to get started. Sign up at [twenty.com](https://twenty.com) and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.
### <img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
### <img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
Scaffold a new app with the Twenty CLI:
@@ -68,7 +68,7 @@ npx twenty deploy
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
### <img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
### <img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.com/developers/self-host/capabilities/docker-compose), or contribute locally via the [local setup guide](https://docs.twenty.com/developers/contribute/capabilities/local-setup).
@@ -79,61 +79,61 @@ Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.
Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
<table align="center">
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp" alt="Create your apps" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-build-apps-light.webp" alt="Create your apps" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-version-control-light.webp" alt="Stay on top with version control" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-version-control-light.webp" alt="Stay on top with version control" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website-new/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp" alt="All the tools you need to build anything" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-all-tools-light.webp" alt="All the tools you need to build anything" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-tools-light.webp" alt="Customize your layouts" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-tools-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-tools-light.webp" alt="Customize your layouts" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp" alt="AI agents and chats" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-ai-agents-light.webp" alt="AI agents and chats" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp" alt="Plus all the tools of a good CRM" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-crm-tools-light.webp" alt="Plus all the tools of a good CRM" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
</td>
</tr>
</table>
@@ -142,23 +142,23 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
# Stack
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
- <a href="https://nx.dev/"><img src="./packages/twenty-website-new/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
- <a href="https://nestjs.com/"><img src="./packages/twenty-website-new/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website-new/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
- <a href="https://reactjs.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
- <a href="https://nx.dev/"><img src="./packages/twenty-website/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
- <a href="https://nestjs.com/"><img src="./packages/twenty-website/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
- <a href="https://reactjs.org/"><img src="./packages/twenty-website/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
# Thanks
<p align="center">
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website-new/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://greptile.com"><img src="./packages/twenty-website-new/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sentry.io/"><img src="./packages/twenty-website-new/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://crowdin.com/"><img src="./packages/twenty-website-new/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
</p>
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
@@ -166,4 +166,4 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
# Join the Community
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website-new/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website-new/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website-new/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
+1 -1
View File
@@ -53,7 +53,7 @@
"packages/twenty-ui",
"packages/twenty-utils",
"packages/twenty-zapier",
"packages/twenty-website-new",
"packages/twenty-website",
"packages/twenty-docs",
"packages/twenty-e2e-testing",
"packages/twenty-shared",
+2 -11
View File
@@ -1,7 +1,7 @@
<div align="center">
<a href="https://twenty.com">
<picture>
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website-new/public/images/core/logo.svg" height="128">
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website/public/images/core/logo.svg" height="128">
</picture>
</a>
<h1>Create Twenty App</h1>
@@ -32,21 +32,12 @@ The scaffolder will:
| Flag | Description |
| ---------------------------------- | --------------------------------------------------------------------- |
| `--example <name>` | Initialize from an example |
| `--name <name>` | Set the app name |
| `--display-name <displayName>` | Set the display name |
| `--description <description>` | Set the description |
| `--api-url <url>` | Twenty instance URL (default: `http://localhost:2020`) |
| `--workspace-url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
| `--authentication-method <method>` | `oauth` or `apiKey` (default: `apiKey` for local, `oauth` for remote) |
By default (no flags), a minimal app is generated with core files and an integration test. Use `--example` to start from a richer example:
```bash
npx create-twenty-app@latest my-twenty-app --example hello-world
```
Examples are sourced from [twentyhq/twenty/packages/twenty-apps/examples](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples).
## Documentation
Full documentation is available at **[docs.twenty.com/developers/extend/apps](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)**:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.5.0",
"version": "2.5.1",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
+13 -6
View File
@@ -15,14 +15,14 @@ const program = new Command(packageJson.name)
'Output the current version of create-twenty-app.',
)
.argument('[directory]')
.option('--example <name>', 'Initialize from an example')
.option('-n, --name <name>', 'Application name')
.option('-d, --display-name <displayName>', 'Application display name')
.option('--description <description>', 'Application description')
.option(
'--api-url <apiUrl>',
'Twenty instance URL (default: http://localhost:2020)',
'--workspace-url <workspaceUrl>',
'Twenty workspace URL (default: http://localhost:2020)',
)
.option('--api-url <apiUrl>', '[deprecated: use --workspace-url]')
.option(
'--authentication-method <method>',
'Authentication method: oauth or apiKey (default: apiKey for local, oauth for remote)',
@@ -32,10 +32,10 @@ const program = new Command(packageJson.name)
async (
directory?: string,
options?: {
example?: string;
name?: string;
displayName?: string;
description?: string;
workspaceUrl?: string;
apiUrl?: string;
authenticationMethod?: AuthenticationMethod;
},
@@ -66,13 +66,20 @@ const program = new Command(packageJson.name)
process.exit(1);
}
if (options?.apiUrl) {
console.warn(
chalk.yellow(
'Warning: --api-url is deprecated. Use --workspace-url instead.',
),
);
}
await new CreateAppCommand().execute({
directory,
example: options?.example,
name: options?.name,
displayName: options?.displayName,
description: options?.description,
apiUrl: options?.apiUrl,
workspaceUrl: options?.workspaceUrl ?? options?.apiUrl,
authenticationMethod: options?.authenticationMethod,
});
},
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" fill="none">
<rect width="80" height="80" rx="16" fill="#141414"/>
<rect x="20" y="20" width="16" height="16" rx="4" fill="#FAFAFA"/>
<rect x="44" y="20" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.6"/>
<rect x="20" y="44" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.6"/>
<rect x="44" y="44" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 454 B

@@ -2,3 +2,10 @@ export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
export const APP_DESCRIPTION = 'DESCRIPTION-TO-BE-GENERATED';
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'UUID-TO-BE-GENERATED';
@@ -0,0 +1,279 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { useState } from 'react';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
const DOCS_BASE_URL = 'https://docs.twenty.com/developers/extend/apps';
const CATEGORIES = [
{
title: 'Data model',
color: '#73D08D',
items: [
{ label: 'CUSTOM OBJECT', href: `${DOCS_BASE_URL}/data/objects` },
{
label: 'CUSTOM FIELDS',
href: `${DOCS_BASE_URL}/data/extending-objects`,
},
],
rotation: '2.4deg',
},
{
title: 'Logic',
color: '#F4D345',
items: [
{
label: 'TOOLS',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'SERVERLESS FUNCT.',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'SKILLS',
href: `${DOCS_BASE_URL}/logic/skills-and-agents`,
},
],
rotation: '0deg',
},
{
title: 'Layout',
color: '#C4A2E0',
items: [
{ label: 'VIEWS', href: `${DOCS_BASE_URL}/layout/views` },
{ label: 'WIDGETS', href: `${DOCS_BASE_URL}/layout/page-layouts` },
{
label: 'LAYOUT PAGES',
href: `${DOCS_BASE_URL}/layout/page-layouts`,
},
{
label: 'COMMANDS',
href: `${DOCS_BASE_URL}/layout/command-menu-items`,
},
],
rotation: '-2.8deg',
},
] as const;
const ItemIcon = ({ color }: { color: string }) => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2" y="2" width="5" height="5" rx="1" fill={color} />
<rect x="9" y="2" width="5" height="5" rx="1" fill={color} opacity="0.6" />
<rect x="2" y="9" width="5" height="5" rx="1" fill={color} opacity="0.6" />
<rect x="9" y="9" width="5" height="5" rx="1" fill={color} opacity="0.3" />
</svg>
);
const ArrowUpRight = ({ color = '#999' }: { color?: string }) => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M4.5 3.5H10.5V9.5M10.5 3.5L3.5 10.5"
stroke={color}
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const CategoryCard = ({
title,
color,
items,
rotation,
}: {
title: string;
color: string;
items: ReadonlyArray<{ label: string; href: string }>;
rotation: string;
}) => {
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
border: `1px solid ${color}80`,
borderRadius: '12px',
overflow: 'hidden',
width: '240px',
background: '#FFFFFF',
transform: `rotate(${rotation})`,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}
>
<div
style={{
padding: '16px 20px',
background: `${color}22`,
}}
>
<span
style={{
fontSize: '16px',
fontWeight: 600,
color: color,
}}
>
{title}
</span>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
padding: '8px',
gap: '4px',
}}
>
{items.map((item) => {
const isHovered = hoveredItem === item.label;
return (
<a
key={item.label}
href={item.href}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setHoveredItem(item.label)}
onMouseLeave={() => setHoveredItem(null)}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
textDecoration: 'none',
cursor: 'pointer',
padding: '10px 12px',
borderRadius: '8px',
background: isHovered ? '#0000000A' : 'transparent',
transition: 'background 0.15s',
}}
>
<ItemIcon color={color} />
<span
style={{
fontSize: '13px',
fontWeight: 300,
color: '#333',
letterSpacing: '0.5px',
flex: 1,
}}
>
{item.label}
</span>
{isHovered && <ArrowUpRight />}
</a>
);
})}
</div>
</div>
);
};
const MainPage = () => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
gap: '8px',
padding: '40px',
}}
>
{/*<Avatar
avatarUrl={getPublicAssetUrl('logo.svg')}
placeholder={APP_DISPLAY_NAME}
placeholderColorSeed={APP_DISPLAY_NAME}
type="squared"
size="xl"
/>*/}
<span
style={{
fontSize: '24px',
fontWeight: 600,
color: '#333',
marginTop: '8px',
}}
>
{APP_DISPLAY_NAME}
</span>
<span
style={{
fontSize: '13px',
color: '#888',
textAlign: 'center',
lineHeight: '1.5',
}}
>
Was installed successfully.
<br />
You can now add content to your app.
</span>
<a
href="/settings/applications"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
marginTop: '16px',
fontSize: '13px',
color: '#333',
textDecoration: 'none',
padding: '8px 16px',
borderRadius: '8px',
border: '1px solid #e0e0e0',
background: '#fafafa',
transition: 'background 0.15s, border-color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f0f0f0';
e.currentTarget.style.borderColor = '#ccc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#fafafa';
e.currentTarget.style.borderColor = '#e0e0e0';
}}
>
Open app settings
<ArrowUpRight color="#333" />
</a>
<div
style={{
display: 'flex',
gap: '16px',
marginTop: '32px',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'flex-start',
}}
>
{CATEGORIES.map((category) => (
<CategoryCard
key={category.title}
title={category.title}
color={category.color}
items={category.items}
rotation={category.rotation}
/>
))}
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
description: `${APP_DISPLAY_NAME} front component displaying the app logo and name`,
component: MainPage,
});
@@ -0,0 +1,19 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
icon: 'IconFile',
position: -1,
type: NavigationMenuItemType.PAGE_LAYOUT,
pageLayoutUniversalIdentifier: MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,37 @@
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default definePageLayout({
universalIdentifier: MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
title: 'Overview',
position: 0,
icon: 'IconApps',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
title: ' ',
type: 'FRONT_COMPONENT',
gridPosition: { row: 0, column: 0, rowSpan: 12, columnSpan: 12 },
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
@@ -1,5 +1,4 @@
import { copyBaseApplicationProject } from '@/utils/app-template';
import { downloadExample } from '@/utils/download-example';
import { convertToLabel } from '@/utils/convert-to-label';
import { install } from '@/utils/install';
import { tryGitInit } from '@/utils/try-git-init';
@@ -31,11 +30,10 @@ export type AuthenticationMethod = 'oauth' | 'apiKey';
type CreateAppOptions = {
directory?: string;
example?: string;
name?: string;
displayName?: string;
description?: string;
apiUrl?: string;
workspaceUrl?: string;
authenticationMethod?: AuthenticationMethod;
};
@@ -47,9 +45,9 @@ export class CreateAppCommand {
const { appName, appDisplayName, appDirectory, appDescription } =
this.getAppInfos(options);
const apiUrl = options.apiUrl ?? DEV_API_URL;
const workspaceUrl = options.workspaceUrl ?? DEV_API_URL;
const skipLocalInstance = apiUrl !== DEV_API_URL;
const skipLocalInstance = workspaceUrl !== DEV_API_URL;
if (!skipLocalInstance && !isDockerInstalled()) {
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
@@ -92,30 +90,13 @@ export class CreateAppCommand {
this.logNextStep('Scaffolding project files');
if (options.example) {
const exampleSucceeded = await this.tryDownloadExample(
options.example,
appDirectory,
);
if (!exampleSucceeded) {
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress: (message) => this.logDetail(message),
});
}
} else {
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress: (message) => this.logDetail(message),
});
}
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress: (message) => this.logDetail(message),
});
this.logNextStep('Installing dependencies');
@@ -137,7 +118,7 @@ export class CreateAppCommand {
console.log('');
let authSucceeded = false;
let resolvedApiUrl = apiUrl;
let resolvedWorkspaceUrl = workspaceUrl;
let serverReady = skipLocalInstance;
if (!skipLocalInstance) {
@@ -145,20 +126,49 @@ export class CreateAppCommand {
const serverResult = await this.ensureDockerServer(dockerPullPromise);
if (isDefined(serverResult.url)) {
resolvedApiUrl = serverResult.url;
resolvedWorkspaceUrl = serverResult.url;
serverReady = true;
}
}
if (serverReady && authenticationMethod === 'oauth') {
this.logNextStep('Authenticating via OAuth');
authSucceeded = await this.authenticateWithOAuth(resolvedApiUrl);
} else if (serverReady && authenticationMethod === 'apiKey') {
this.logNextStep('Authenticating via API key');
authSucceeded = await this.authenticateWithDevKey(resolvedApiUrl);
if (serverReady) {
this.logNextStep('Authenticating');
authSucceeded = await this.tryExistingAuth(resolvedWorkspaceUrl);
if (authSucceeded) {
this.logDetail('Reusing existing credentials');
} else if (authenticationMethod === 'oauth') {
this.logDetail('Starting OAuth flow');
authSucceeded =
await this.authenticateWithOAuth(resolvedWorkspaceUrl);
} else {
this.logDetail('Using development API key');
authSucceeded =
await this.authenticateWithDevKey(resolvedWorkspaceUrl);
}
}
this.logSuccess(appDirectory, resolvedApiUrl, authSucceeded);
this.logNextStep('Installing application');
let syncSucceeded = false;
if (serverReady && authSucceeded) {
syncSucceeded = await this.syncApplication(appDirectory);
if (!syncSucceeded) {
this.logDetail('Sync failed. Run `yarn twenty dev --once` manually.');
return;
}
} else {
this.logDetail('Skipped (server or authentication not available)');
}
if (syncSucceeded) {
await this.openMainPage(appDirectory, resolvedWorkspaceUrl);
}
this.logSuccess(appDirectory, resolvedWorkspaceUrl, authSucceeded);
} catch (error) {
console.error(
chalk.red('\nCreate application failed:'),
@@ -180,6 +190,7 @@ export class CreateAppCommand {
}
steps += 1; // authenticate (oauth or apiKey)
steps += 1; // sync application
return steps;
}
@@ -193,7 +204,6 @@ export class CreateAppCommand {
const appName = (
options.name ??
options.directory ??
options.example ??
'my-twenty-app'
).trim();
@@ -222,28 +232,6 @@ export class CreateAppCommand {
}
}
private async tryDownloadExample(
example: string,
appDirectory: string,
): Promise<boolean> {
try {
await downloadExample(example, appDirectory);
return true;
} catch (error) {
console.log(
chalk.yellow(
`\n${error instanceof Error ? error.message : 'Failed to download example.'}`,
),
);
this.logDetail('Falling back to default template...');
await fs.emptyDir(appDirectory);
return false;
}
}
private logPlan({
appName,
appDisplayName,
@@ -325,11 +313,253 @@ export class CreateAppCommand {
return {};
}
private async authenticateWithDevKey(apiUrl: string): Promise<boolean> {
private async openMainPage(
appDirectory: string,
workspaceUrl: string,
): Promise<void> {
try {
const configService = new ConfigService();
const config = await configService.getConfig();
const token = config.twentyCLIAccessToken ?? config.apiKey;
if (!token) {
return;
}
const [universalIdentifier, frontUrl] = await Promise.all([
this.readMainPageLayoutUniversalIdentifier(appDirectory),
this.resolveWorkspaceFrontUrl(workspaceUrl, token),
]);
if (!universalIdentifier || !frontUrl) {
return;
}
const pageLayoutId = await this.resolvePageLayoutId(
workspaceUrl,
universalIdentifier,
token,
);
if (!pageLayoutId) {
return;
}
const url = `${frontUrl}/page/${pageLayoutId}`;
this.logDetail(`Opening app welcome page: ${url}`);
this.openInBrowser(url);
} catch {
// Best-effort — don't fail the scaffold if browser open fails
}
}
private async resolveWorkspaceFrontUrl(
workspaceUrl: string,
token: string,
): Promise<string | null> {
const query = `{ currentWorkspace { workspaceUrls { subdomainUrl customUrl } } }`;
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as {
data?: {
currentWorkspace?: {
workspaceUrls?: { subdomainUrl?: string; customUrl?: string };
};
};
};
const urls = body.data?.currentWorkspace?.workspaceUrls;
if (!urls) {
return null;
}
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
return frontUrl?.replace(/\/+$/, '') ?? null;
}
private async readMainPageLayoutUniversalIdentifier(
appDirectory: string,
): Promise<string | null> {
const filePath = path.join(
appDirectory,
'src',
'constants',
'universal-identifiers.ts',
);
const content = await fs.readFile(filePath, 'utf-8');
const match = content.match(
/MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER\s*=\s*'([^']+)'/,
);
return match?.[1] ?? null;
}
private async resolvePageLayoutId(
workspaceUrl: string,
universalIdentifier: string,
token: string,
): Promise<string | null> {
const query = `{ getPageLayouts { id universalIdentifier } }`;
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as {
data?: {
getPageLayouts?: { id: string; universalIdentifier: string }[];
};
};
const matching = body.data?.getPageLayouts?.find(
(layout) => layout.universalIdentifier === universalIdentifier,
);
return matching?.id ?? null;
}
private sanitizeBrowserUrl(url: string): string | null {
if (/[^\u0020-\u007E]/.test(url)) {
return null;
}
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
private openInBrowser(url: string): void {
const safeUrl = this.sanitizeBrowserUrl(url);
if (!safeUrl) {
return;
}
const isWindows = process.platform === 'win32';
const command = isWindows
? 'rundll32'
: process.platform === 'darwin'
? 'open'
: 'xdg-open';
const args = isWindows
? ['url.dll,FileProtocolHandler', safeUrl]
: [safeUrl];
const child = spawn(command, args, {
stdio: 'ignore',
detached: !isWindows,
});
child.on('error', () => undefined);
if (!isWindows) {
child.unref();
}
}
private async syncApplication(appDirectory: string): Promise<boolean> {
this.logDetail('Running `yarn twenty dev --once`...');
return new Promise((resolve) => {
const child = spawn('yarn', ['twenty', 'dev', '--once'], {
cwd: appDirectory,
stdio: ['inherit', 'pipe', 'pipe'],
});
child.stdout?.resume();
child.stderr?.resume();
child.on('close', (code) => resolve(code === 0));
child.on('error', () => resolve(false));
});
}
private async tryExistingAuth(workspaceUrl: string): Promise<boolean> {
try {
const configService = new ConfigService();
const remoteNames = await configService.getRemotes();
for (const remoteName of remoteNames) {
const remoteConfig = await configService.getConfigForRemote(remoteName);
if (remoteConfig.apiUrl !== workspaceUrl) {
continue;
}
const token = remoteConfig.twentyCLIAccessToken ?? remoteConfig.apiKey;
if (!token) {
continue;
}
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: '{ currentWorkspace { id } }',
}),
});
if (!response.ok) {
continue;
}
const body = (await response.json()) as {
data?: { currentWorkspace?: { id: string } };
errors?: unknown[];
};
if (isDefined(body.data?.currentWorkspace) && !body.errors) {
ConfigService.setActiveRemote(remoteName);
await configService.setDefaultRemote(remoteName);
return true;
}
}
return false;
} catch {
return false;
}
}
private async authenticateWithDevKey(workspaceUrl: string): Promise<boolean> {
try {
const result = await authLogin({
apiKey: DEV_API_KEY,
apiUrl,
apiUrl: workspaceUrl,
remote: 'local',
});
@@ -368,21 +598,21 @@ export class CreateAppCommand {
}
}
private async authenticateWithOAuth(apiUrl: string): Promise<boolean> {
private async authenticateWithOAuth(workspaceUrl: string): Promise<boolean> {
try {
const remoteName = this.deriveRemoteName(apiUrl);
const remoteName = this.deriveRemoteName(workspaceUrl);
ConfigService.setActiveRemote(remoteName);
this.logDetail('Opening browser for OAuth...');
const result = await authLoginOAuth({ apiUrl });
const result = await authLoginOAuth({ apiUrl: workspaceUrl });
if (result.success) {
const configService = new ConfigService();
await configService.setDefaultRemote(remoteName);
this.logDetail(`Authenticated via OAuth to ${apiUrl}`);
this.logDetail(`Authenticated via OAuth to ${workspaceUrl}`);
return true;
}
@@ -390,7 +620,7 @@ export class CreateAppCommand {
console.log(
chalk.yellow(
` OAuth failed: ${result.error.message}\n` +
` Run \`yarn twenty remote add --api-url ${apiUrl}\` manually.`,
` Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
),
);
@@ -398,7 +628,7 @@ export class CreateAppCommand {
} catch {
console.log(
chalk.yellow(
` Authentication failed. Run \`yarn twenty remote add --api-url ${apiUrl}\` manually.`,
` Authentication failed. Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
),
);
@@ -408,7 +638,7 @@ export class CreateAppCommand {
private logSuccess(
appDirectory: string,
apiUrl: string,
workspaceUrl: string,
authSucceeded: boolean,
): void {
const dirName = basename(appDirectory);
@@ -438,7 +668,7 @@ export class CreateAppCommand {
stepNumber++;
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
console.log(chalk.cyan(` ${apiUrl}\n`));
console.log(chalk.cyan(` ${workspaceUrl}\n`));
console.log(
chalk.gray(
@@ -28,6 +28,6 @@ export const getDockerInstallInstructions = (): string => {
' Then run this command again.',
'',
' Alternatively, connect to an existing Twenty instance:',
' npx create-twenty-app@latest my-twenty-app --api-url <your-instance-url>',
' npx create-twenty-app@latest my-twenty-app --workspace-url <your-instance-url>',
].join('\n');
};
@@ -1,179 +0,0 @@
import { execSync } from 'node:child_process';
import * as fs from 'fs-extra';
import { join } from 'path';
import { tmpdir } from 'node:os';
import chalk from 'chalk';
const TWENTY_REPO_OWNER = 'twentyhq';
const TWENTY_REPO_NAME = 'twenty';
const TWENTY_FALLBACK_REF = 'main';
const TWENTY_EXAMPLES_PATH = 'packages/twenty-apps/examples';
const TWENTY_EXAMPLES_URL = `https://github.com/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/tree/${TWENTY_FALLBACK_REF}/${TWENTY_EXAMPLES_PATH}`;
// Fetches the latest release tag from the repo, or falls back to main
const resolveRef = async (): Promise<string> => {
const response = await fetch(
`https://api.github.com/repos/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/releases/latest`,
{ headers: { Accept: 'application/vnd.github.v3+json' } },
);
if (response.ok) {
const release = (await response.json()) as { tag_name: string };
return release.tag_name;
}
return TWENTY_FALLBACK_REF;
};
// Uses the GitHub Contents API to list directories — fast and doesn't download the repo
const fetchGitHubDirectoryContents = async (
path: string,
ref: string,
): Promise<{ name: string; type: string }[] | null> => {
const apiUrl = `https://api.github.com/repos/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/contents/${path}?ref=${ref}`;
const response = await fetch(apiUrl, {
headers: { Accept: 'application/vnd.github.v3+json' },
});
if (!response.ok) {
return null;
}
const data = await response.json();
if (!Array.isArray(data)) {
return null;
}
return data as { name: string; type: string }[];
};
const listAvailableExamples = async (ref: string): Promise<string[]> => {
const contents = await fetchGitHubDirectoryContents(
TWENTY_EXAMPLES_PATH,
ref,
);
if (!contents) {
return [];
}
return contents
.filter((entry) => entry.type === 'dir')
.map((entry) => entry.name);
};
const validateExampleExists = async (
exampleName: string,
ref: string,
): Promise<void> => {
const examplePath = `${TWENTY_EXAMPLES_PATH}/${exampleName}`;
const contents = await fetchGitHubDirectoryContents(examplePath, ref);
if (contents !== null) {
return;
}
const availableExamples = await listAvailableExamples(ref);
throw new Error(
`Example "${exampleName}" not found.\n\n` +
(availableExamples.length > 0
? `Available examples:\n${availableExamples.map((name) => ` - ${name}`).join('\n')}\n\n`
: '') +
`Browse all examples: ${TWENTY_EXAMPLES_URL}`,
);
};
export const downloadExample = async (
exampleName: string,
targetDirectory: string,
): Promise<void> => {
if (
exampleName.includes('/') ||
exampleName.includes('\\') ||
exampleName.includes('..')
) {
throw new Error(
`Invalid example name: "${exampleName}". Example names must be simple directory names (e.g., "hello-world").`,
);
}
const ref = await resolveRef();
const examplePath = `${TWENTY_EXAMPLES_PATH}/${exampleName}`;
console.log(chalk.gray(`Resolving examples from ref '${ref}'...`));
await validateExampleExists(exampleName, ref);
console.log(chalk.gray(`Example '${examplePath}' validated successfully.`));
const tarballUrl = `https://codeload.github.com/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/tar.gz/${ref}`;
const tempDir = join(
tmpdir(),
`create-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
try {
await fs.ensureDir(tempDir);
console.log(chalk.gray(`Downloading tarball from ${tarballUrl}...`));
const response = await fetch(tarballUrl);
if (!response.ok) {
if (response.status === 404) {
throw new Error(
`Could not find repository: ${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME} (ref: ${ref})`,
);
}
throw new Error(
`Failed to download from GitHub: ${response.status} ${response.statusText}`,
);
}
console.log(chalk.gray('Tarball downloaded. Writing to disk...'));
const tarballPath = join(tempDir, 'archive.tar.gz');
const buffer = Buffer.from(await response.arrayBuffer());
await fs.writeFile(tarballPath, buffer);
console.log(
chalk.gray(
`Tarball saved (${(buffer.length / 1024 / 1024).toFixed(1)} MB). Extracting...`,
),
);
execSync(`tar xzf "${tarballPath}" -C "${tempDir}"`, {
stdio: 'pipe',
});
// GitHub tarballs extract to a directory named {repo}-{ref}/
const extractedEntries = await fs.readdir(tempDir);
const extractedDir = extractedEntries.find(
(entry) => entry !== 'archive.tar.gz',
);
if (!extractedDir) {
throw new Error('Failed to extract archive: no directory found');
}
const sourcePath = join(tempDir, extractedDir, examplePath);
if (!(await fs.pathExists(sourcePath))) {
throw new Error(
`Example directory not found in archive: "${examplePath}"`,
);
}
await fs.copy(sourcePath, targetDirectory);
} finally {
await fs.remove(tempDir);
}
};
@@ -28,12 +28,6 @@ yarn install
yarn twenty dev
```
Or use it as a template via create-twenty-app:
```bash
npx create-twenty-app@latest my-app --example postcard
```
## Learn more
- [Getting Started](https://docs.twenty.com/developers/extend/apps/getting-started)
@@ -0,0 +1,18 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -0,0 +1,267 @@
# Fireflies for Twenty
Sync [Fireflies](https://fireflies.ai) call transcripts and AI summaries onto
the matching `CalendarEvent` in your Twenty CRM — searchable, in context, and
ready for AI agents and workflows to act on. Plus on-demand workflow tools to
sync, list, and search Fireflies calls from the AI chat or workflow builder.
## What this app does
1. Fireflies records and transcribes your Zoom / Meet / Teams / phone call.
2. When the transcript is ready, Fireflies fires a `meeting.transcribed`
webhook; once Fireflies finishes its AI summary, it fires a separate
`meeting.summarized` webhook.
3. For each event, this app fetches the relevant data via the Fireflies
GraphQL API.
4. It finds the matching `CalendarEvent` in Twenty and writes the content
into either the **Transcript** or **Summary** field on that event.
Alongside the webhook, three [Workflow tools](#workflow-tools) let you
trigger Fireflies actions from the AI chat or as steps inside a workflow,
without waiting for Fireflies to push.
### How a transcript is matched to a CalendarEvent
The matcher tries two provider-ID strategies in priority order and stops at
the first hit:
1. **Provider-native event ID** — Fireflies' `calendar_id` / `cal_id` is
matched against `CalendarChannelEventAssociation.eventExternalId`. Covers
events synced into Twenty from Google Calendar (including individual
instances of recurring events, where Fireflies returns the per-instance
id with timestamp on `cal_id`).
2. **iCalUID** — Fireflies' `calendar_id` is matched against
`CalendarEvent.iCalUid`. Covers events synced from Outlook / CalDAV,
where Fireflies returns the RFC 5545 iCalUID directly.
Both identifiers are populated by Twenty's calendar drivers on every synced
CalendarEvent, so any meeting that's been pulled in via Google / Outlook /
CalDAV calendar sync will match exactly. The matcher does **not** fall back
to fuzzy URL matching — if the transcript can't be tied to a synced calendar
event, the call is treated as an orphan and skipped (see
[Limitations](#limitations) below). This avoids silently writing transcripts
to the wrong event.
## What gets added to your Twenty workspace
Two new fields on the standard **CalendarEvent** object:
- **Transcript** — rich-text field, speaker-attributed (e.g. *"**Sarah:**
Hi there"*, then *"**John:** Doing well, thanks."*).
- **Summary** — rich-text field with the Fireflies AI summary: a bullet-list
overview, action items grouped by speaker, topics discussed, and keywords.
Plus three workflow tools — see [Workflow tools](#workflow-tools) below.
## Workflow tools
Once the API key is configured, three tools become available in the workflow
builder and the AI chat — covering the cases the webhook can't:
- **Sync Fireflies Call** — *"sync the Fireflies call `01HXYZ...` onto its
CalendarEvent now"*. As a workflow step: provide `transcriptId`. Runs the
same pipeline as the webhook (fetch transcript + AI summary, find matching
CalendarEvent, write Transcript + Summary fields) on demand. Use cases:
**backfilling** historical calls that happened before the app was
installed; **recovering** from a missed webhook (e.g. the calendar event
hadn't synced yet when Fireflies pushed); or triggering a sync from a
workflow instead of waiting for Fireflies. Output includes
`calendarEventId`, `updatedFields`, and a per-field outcome breakdown so
partial successes are visible.
- **List Fireflies Calls By Participant** — *"show me my last 5 calls with
john@acme.com"*. As a workflow step: provide `participantEmail` (and
optional `limit`, max 50). Returns recent Fireflies calls — newest first —
where that email was an attendee, with title, date, duration, host, and
transcript URL. The natural first step in workflows triggered on
`Person.created`*"find what we've talked about with this contact"*.
- **Search Fireflies Calls** — *"find any call where we discussed pricing"*.
As a workflow step: provide `keyword` (and optional `limit`, max 50).
Matches the keyword against both meeting titles and the words actually
spoken in meetings. Returns the same call-summary shape as the
participant tool. Best for AI-chat-driven research.
The list-by-participant and search tools return the same compact call shape:
`id`, `title`, `date`, `durationMinutes`, `participants`, `hostEmail`,
`transcriptUrl`, `meetingLink`. To then sync any of those calls onto its
CalendarEvent, pass the `id` from a list result into **Sync Fireflies Call**.
## Installing
1. Open **Settings → Applications** in your Twenty workspace.
2. Find **Fireflies** in the available apps and click **Install**.
3. Follow [Self-hosting setup](#self-hosting-setup-admin-only) below to wire
up the API key and webhook (admin-only, one-time).
> **Heads up:** if you see *"Fireflies is not configured"* on the first
> webhook, your Twenty admin needs to follow the
> [Self-hosting setup](#self-hosting-setup-admin-only) section.
## Limitations
What this connector intentionally does **not** support in v1:
- **Calls without a matching CalendarEvent (orphan calls).** Ad-hoc calls
that were never on anyone's synced calendar are skipped. The webhook logs
the skip reason; the transcript still lives in Fireflies. Synthetic event
creation for orphans is planned for v2.
- **Fireflies sentiment, speaker analytics, transcript chapters.** Only
the raw transcript and the AI summary (overview, action items, topics,
keywords) are synced today.
- **Per-user Fireflies accounts.** All transcripts come through one
workspace-shared API key (set by the admin). Per-user OAuth-style
connections require extending Twenty's connection provider system and are
planned once we have evidence that workspace-shared is too coarse.
- **Editing transcripts or summaries in Twenty.** The fields are writable
but the next Fireflies sync overwrites any manual edits — treat them as
read-only.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Webhook returns `Fireflies is not configured` | `FIREFLIES_API_KEY` not set | Admin: paste the API key in **Settings → Applications → Fireflies → Settings** |
| Webhook returns `Invalid webhook signature` | `FIREFLIES_WEBHOOK_SECRET` mismatch between Fireflies and Twenty | Re-copy the signing secret from the Fireflies webhook configuration and paste it into the Twenty app settings |
| Webhook returns `skipped: No CalendarEvent matched the transcript by external ID or iCalUid` | The meeting was never on a synced calendar in Twenty, or the workspace has no Google/Outlook/CalDAV calendar connection set up | Connect the relevant calendar provider in **Settings → Accounts** so the calendar event lands in Twenty with `eventExternalId` and `iCalUid` populated. Manually-created CalendarEvents are intentionally not matched in v1 |
| Transcript appears empty | Fireflies returned no sentences (call too short, audio failed) | Check the call in the Fireflies dashboard; nothing this app can do |
| Summary appears empty | Fireflies hasn't summarized the call yet, or the call was too short to summarize | Fireflies sends `meeting.summarized` separately from `meeting.transcribed` (typically a minute or two later); ensure that event is subscribed to in your Webhooks V2 config |
| Summary is populated but Transcript isn't (or vice versa) | Only one of the two Fireflies events is subscribed to | Subscribe to both `meeting.transcribed` and `meeting.summarized` in your Fireflies Webhooks V2 configuration |
| Fireflies API errors with `401` | API key wrong, rotated, or revoked | Generate a new key in Fireflies → Integrations → Fireflies API → Regenerate, then update `FIREFLIES_API_KEY` |
| **Sync Fireflies Call** reports `No fields were updated` | The Fireflies call's `calendar_id` / `cal_id` doesn't match any CalendarEvent's `iCalUid` or `eventExternalId` (orphan call), or the per-field outcomes show transient Fireflies API failures | Check the `fieldOutcomes` array in the result — `skipped` means orphan call (same limitation as the webhook); `error` means Fireflies-side failure (retry, or inspect the error message) |
| **List / Search** tools return `count: 0` for a contact you've definitely talked to | Email mismatch — Fireflies stores the address as the participant joined the meeting with, which may differ from the contact's primary address in Twenty (aliases, plus-addressing, work vs. personal) | Try the contact's other known email addresses; cross-check the `participants` list on a known matching call |
---
## Self-hosting setup (admin-only)
This section is for Twenty server admins. If you're on Twenty Cloud, skip
this — the credentials may already be configured.
### 1. Generate a Fireflies API key
1. Visit https://app.fireflies.ai and sign in.
2. Go to **Integrations → Fireflies API**.
3. Click **Generate API key** and copy the value (it's only shown once).
### 2. Configure a Webhooks V2 endpoint in Fireflies
This integration targets [Fireflies Webhooks V2](https://docs.fireflies.ai/graphql-api/webhooks-v2)
(snake_case payload, granular event subscriptions). The legacy V1 webhook
format (`meetingId` / `eventType: "Transcription completed"`) is **not**
supported.
1. Open the Webhooks V2 page: https://app.fireflies.ai/integrations/api/webhook
2. Set the **Webhook URL** to your Twenty deployment's webhook endpoint:
`https://<your-twenty-domain>/webhook/fireflies`. Twenty resolves the
target workspace from the request's `Host` header, so the URL must match
the workspace's public domain — `localhost` is not valid in the
Fireflies UI. For local development, expose your dev server with a
tunnel like `ngrok http 3000` and paste the HTTPS forwarding URL here,
or skip the Fireflies UI entirely and POST a signed payload directly to
your local endpoint (see [Local webhook testing](#local-webhook-testing)
in the developer section below).
3. Set a **Signing Secret** (a long random string — generate one with
`openssl rand -hex 32`). Save it; you'll paste it into Twenty next.
4. Under **Events**, subscribe to **both**:
- **`meeting.transcribed`** — fires when the transcript is ready and
writes it to the **Transcript** field.
- **`meeting.summarized`** — fires once Fireflies finishes its AI summary
and writes it to the **Summary** field.
Subscribing to only one is fine if you don't want the other field
populated; the app dispatches per event.
5. **Save** the configuration.
### 3. Wire the credentials into Twenty
1. In Twenty: **Settings → Applications → Fireflies → Settings tab**.
2. Paste the Fireflies API key into the `FIREFLIES_API_KEY` row.
3. Paste the signing secret into the `FIREFLIES_WEBHOOK_SECRET` row.
After saving, the next time Fireflies finishes processing a recording, the
transcript will land on the matching CalendarEvent within a few seconds;
the summary follows once Fireflies finishes the AI summarization step
(typically a minute or two later — Fireflies sends two separate webhooks).
---
## Why `transcript` / `summary` fields on `CalendarEvent` instead of a new object?
Storing the transcript and AI summary as rich-text fields directly on the
existing `CalendarEvent`:
- Keeps everything about a meeting in one place (no joins)
- Avoids inventing a new object that other call-recording apps would each
need to coordinate on
- Works today without lookup fields
If later integrations (Gong, Otter, Zoom AI, etc.) make one pair of fields
too restrictive — for example, needing to distinguish *which* tool produced
the transcript — we'll promote the fields to a platform-level concept rather
than keep extending this app.
---
## Developers only
If you're working on this app rather than installing the published version:
```bash
cd packages/twenty-apps/internal/twenty-fireflies
# Day-to-day development (publish + install + watch in one):
yarn twenty dev
# Run unit tests:
yarn test
# Lint:
yarn lint
```
`twenty dev` is recommended for iteration — it publishes to your local Twenty
server, installs the app, and watches for changes in one command.
The Fireflies GraphQL API is called directly via `fetch` — no `fireflies` SDK
dependency. See `src/logic-functions/utils/fireflies-api-request.ts` for the
auth + error-handling wrapper that all queries go through.
### Local webhook testing
Fireflies' Webhooks V2 UI only accepts a publicly reachable HTTPS URL, so
pointing it at `http://localhost:*` directly is not possible. Two paths:
**End-to-end via tunnel.** Run a tunnel that fronts your local server with
a public HTTPS URL (`ngrok http 3000`, `cloudflared tunnel`, etc.), paste
the HTTPS forwarding URL into the Fireflies webhook UI as the **Webhook
URL**, and exercise the integration by ending a real Fireflies meeting.
**Backend-only via signed `curl`.** Skip the Fireflies UI entirely and POST
a signed payload straight to the local endpoint. The signature must be
HMAC-SHA256 over the **raw** request body, keyed by your
`FIREFLIES_WEBHOOK_SECRET`, prefixed with `sha256=`:
```bash
export FIREFLIES_WEBHOOK_SECRET='<the secret you set in Twenty app settings>'
BODY='{"event":"meeting.transcribed","meeting_id":"<a-real-fireflies-transcript-id>"}'
SIG=$(printf '%s' "$BODY" \
| openssl dgst -sha256 -hmac "$FIREFLIES_WEBHOOK_SECRET" \
| awk '{print $NF}')
curl -X POST http://localhost:3000/webhook/fireflies \
-H 'Content-Type: application/json' \
-H "x-hub-signature: sha256=$SIG" \
--data-binary "$BODY"
```
`--data-binary` (not `--data`) is important: it preserves the bytes
verbatim so the HMAC the server computes matches the one `openssl`
computed above. Twenty resolves the workspace from the `Host` header, so
the default dev workspace (mapped to `localhost:3000` in a standard
`yarn start` setup) receives the request.
To match a real `CalendarEvent`, the transcript ID you pass must belong to
a Fireflies call whose `calendar_id` / `cal_id` matches an existing
`CalendarEvent.iCalUid` or `CalendarChannelEventAssociation.eventExternalId`
in your local Twenty workspace. The easiest local seed is to manually
insert a row with one of those identifiers and use a Fireflies transcript
whose calendar fields point at it.
@@ -0,0 +1,32 @@
{
"name": "twenty-fireflies",
"version": "0.1.0",
"description": "Fireflies call-transcript connector for Twenty",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [
"twenty-app"
],
"packageManager": "[email protected]",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"twenty-client-sdk": "2.4.0",
"twenty-sdk": "2.4.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"oxlint": "^0.16.0",
"typescript": "^5.9.3",
"vitest": "^3.1.1"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

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

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,43 @@
import { defineApplication } from 'twenty-sdk/define';
import { ABOUT_DESCRIPTION } from 'src/constants/ABOUT_DESCRIPTION.md';
import {
APPLICATION_UNIVERSAL_IDENTIFIER,
FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER,
FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Twenty Fireflies',
description:
'Sync Fireflies call transcripts and AI summaries onto matching CalendarEvent records in Twenty, and trigger sync / list / search of Fireflies calls from workflows and the AI chat.',
logoUrl: 'public/twenty-fireflies.svg',
author: 'Twenty',
category: 'Productivity',
aboutDescription: ABOUT_DESCRIPTION,
screenshots: [
'public/gallery/transcript-on-calendar-event.png',
'public/gallery/summary-on-calendar-event.png',
'public/gallery/workflow-builder-actions.png',
'public/gallery/app-settings.png',
],
websiteUrl: 'https://docs.twenty.com/developers/extend/apps/getting-started',
termsUrl: 'https://www.twenty.com/terms',
emailSupport: '[email protected]',
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
applicationVariables: {
FIREFLIES_API_KEY: {
universalIdentifier: FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER,
description:
'API key from Fireflies (Integrations → Fireflies API → Generate). Used as a Bearer token against https://api.fireflies.ai/graphql to fetch full transcript content after a webhook fires.',
isSecret: true,
},
FIREFLIES_WEBHOOK_SECRET: {
universalIdentifier: FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER,
description:
'Signing secret for verifying Fireflies Webhooks V2 payloads (sent in the X-Hub-Signature header as sha256=<hex-hmac-sha256-of-body>). Configure the same value on the Fireflies V2 webhook setup page.',
isSecret: true,
},
},
});
@@ -0,0 +1,64 @@
export const ABOUT_DESCRIPTION = `Bring your Fireflies meeting recordings into Twenty. When Fireflies finishes processing a call, the transcript and AI summary land on the matching CalendarEvent automatically — no copy-pasting, no extra tabs.
## What gets added to your workspace
Two new fields appear on the standard **CalendarEvent** object:
- **Transcript** — speaker-attributed rich text, e.g. *"**Sarah:** Hi there"* followed by *"**John:** Doing well, thanks"*.
- **Summary** — Fireflies' AI-generated overview of the meeting (key points, action items, decisions).
Both update in real time through a Fireflies Webhooks V2 subscription.
## How it syncs
The connector subscribes to two Fireflies V2 events:
- \`meeting.transcribed\` writes the **Transcript** field.
- \`meeting.summarized\` writes the **Summary** field.
Each webhook delivery is HMAC-SHA256 verified against your signing secret before anything touches your data.
## How calls are matched to CalendarEvents
The matcher uses provider-native identifiers — never fuzzy URL matching — so transcripts always land on the right event:
1. **Provider event ID** — Fireflies' \`calendar_id\` / \`calendar_event_uid\` against \`CalendarChannelEventAssociation.eventExternalId\`. Covers events synced from Google Calendar, including individual instances of recurring meetings.
2. **iCalUID** — Fireflies' \`calendar_id\` against \`CalendarEvent.iCalUid\`. Covers events synced from Outlook / CalDAV.
Both identifiers are populated automatically when calendars are synced into Twenty. If a recording can't be matched (orphan recording, no calendar sync configured), the webhook reports a clear skip reason and writes nothing.
## Tools for workflows and the AI chat
Beyond the automatic sync, three Fireflies tools become available in **workflows** and the **AI chat**:
- **Sync Fireflies Call** — Pull a single Fireflies call onto its CalendarEvent on demand. Useful for backfilling history or recovering from a missed webhook. Same matching rules as the webhook.
- **Search Fireflies Calls** — Keyword search across **both** meeting titles and the words spoken during meetings. Ask the AI chat *"find any call where we discussed pricing"* and it returns matching calls with titles, dates, participants, and transcript links.
- **List Fireflies Calls By Participant** — List every call a given email address attended. Great as the first step of a workflow triggered when a Person record is created, or to answer *"what calls have we had with this contact?"* from the AI chat.
## Installing
1. Open **Settings → Applications** in your Twenty workspace.
2. Find **Twenty Fireflies** in the available apps and click **Install**.
Then your admin completes the one-time wiring (see below).
## One-time setup (admin)
1. Generate an API key at [Fireflies → Integrations → Fireflies API](https://app.fireflies.ai/settings/developer-settings) and paste it into the **FIREFLIES_API_KEY** application variable.
2. Generate a long random string (\`openssl rand -hex 32\`). Paste it into the **FIREFLIES_WEBHOOK_SECRET** application variable.
3. Configure a Webhooks V2 endpoint at [Fireflies → Integrations → Webhooks V2](https://app.fireflies.ai/integrations/api/webhook):
- **Webhook URL**: \`https://<your-twenty-domain>/webhook/fireflies\`
- **Signing Secret**: the same value as \`FIREFLIES_WEBHOOK_SECRET\`
- **Events**: subscribe to \`meeting.transcribed\` (required) and \`meeting.summarized\` (optional, for AI summaries)
That's it — the next call Fireflies processes will start syncing automatically.
## Limitations
What this connector intentionally does **not** support in v1:
- **Orphan calls** (recordings with no matching CalendarEvent in Twenty) are skipped — fuzzy URL matching is avoided so transcripts never land on the wrong event.
- **Per-user Fireflies accounts** — all sync goes through one workspace-shared API key set by the admin.
- **Editing transcripts in Twenty** — the field is writable in principle, but future Fireflies syncs will overwrite manual edits.
- **Speaker analytics, sentiment, action items as structured fields** — only raw transcript and summary text are synced; structured insights stay in the Fireflies dashboard.
`;
@@ -0,0 +1,29 @@
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'97d24431-ebc7-4156-9705-b6900e73edc8';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'ed0a61a4-7638-4fd8-a2c1-982b50aca4ff';
export const FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER =
'876ebb9f-0fb1-48b3-bd6f-f4138f999ba7';
export const FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER =
'a1ef34d9-f0e7-483e-9909-fce2757bdd23';
export const TRANSCRIPT_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'e444c83b-0d28-41e2-8c3f-4ede9eb88a75';
export const SUMMARY_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'c5f8a3e2-7b91-4d2e-b6a4-9f3e1d5c8a02';
export const FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'e0c2d033-0ef4-4b92-9e64-2d8f2e4c497e';
export const FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER =
'c715a476-929e-4e5b-b692-6350fc0adb73';
export const FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER =
'3148f5c7-8bcb-48d3-a7b9-aa811608e256';
export const FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER =
'cb2dd01d-8dca-4222-acce-1d1dbdef9146';
@@ -0,0 +1,53 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesListCallsByParticipantHandler } from 'src/logic-functions/handlers/fireflies-list-calls-by-participant-handler';
import { firefliesListCallsByParticipantInputSchema } from 'src/logic-functions/schemas/fireflies-list-calls-by-participant-input.schema';
const callSummaryProperties = {
id: { type: 'string' },
title: { type: 'string' },
date: { type: 'string' },
durationMinutes: { type: 'number' },
participants: { type: 'array', items: { type: 'string' } },
hostEmail: { type: 'string' },
transcriptUrl: { type: 'string' },
meetingLink: { type: 'string' },
} as const;
export default defineLogicFunction({
universalIdentifier: FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER,
name: 'fireflies-list-calls-by-participant',
description:
'List Fireflies calls that include a given participant email. Returns each call\'s ID, title, date, duration, participants, host, Fireflies transcript URL, and original meeting link. Use this to answer "what calls have we had with this contact?" — for example as the first step of a workflow triggered when a Person record is created.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: firefliesListCallsByParticipantInputSchema,
},
workflowActionTriggerSettings: {
label: 'List Fireflies Calls By Participant',
inputSchema: jsonSchemaToInputSchema(
firefliesListCallsByParticipantInputSchema,
),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
count: { type: 'number' },
calls: {
type: 'array',
items: {
type: 'object',
properties: callSummaryProperties,
},
},
},
},
],
},
handler: firefliesListCallsByParticipantHandler,
});
@@ -0,0 +1,51 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesSearchCallsHandler } from 'src/logic-functions/handlers/fireflies-search-calls-handler';
import { firefliesSearchCallsInputSchema } from 'src/logic-functions/schemas/fireflies-search-calls-input.schema';
const callSummaryProperties = {
id: { type: 'string' },
title: { type: 'string' },
date: { type: 'string' },
durationMinutes: { type: 'number' },
participants: { type: 'array', items: { type: 'string' } },
hostEmail: { type: 'string' },
transcriptUrl: { type: 'string' },
meetingLink: { type: 'string' },
} as const;
export default defineLogicFunction({
universalIdentifier: FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER,
name: 'fireflies-search-calls',
description:
'Search Fireflies calls by keyword. Matches the keyword against both meeting titles and the words spoken during meetings (full transcript content). Returns each match\'s ID, title, date, duration, participants, host, Fireflies transcript URL, and meeting link. Use this for AI-chat questions like "find any call where we discussed pricing".',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: firefliesSearchCallsInputSchema,
},
workflowActionTriggerSettings: {
label: 'Search Fireflies Calls',
inputSchema: jsonSchemaToInputSchema(firefliesSearchCallsInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
count: { type: 'number' },
calls: {
type: 'array',
items: {
type: 'object',
properties: callSummaryProperties,
},
},
},
},
],
},
handler: firefliesSearchCallsHandler,
});
@@ -0,0 +1,47 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesSyncCallHandler } from 'src/logic-functions/handlers/fireflies-sync-call-handler';
import { firefliesSyncCallInputSchema } from 'src/logic-functions/schemas/fireflies-sync-call-input.schema';
export default defineLogicFunction({
universalIdentifier: FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER,
name: 'fireflies-sync-call',
description:
'Sync a single Fireflies call onto its matching CalendarEvent on demand: fetches both transcript and AI summary from Fireflies and writes them to the Transcript and Summary fields. Same matching rules as the webhook (Fireflies calendar_id / cal_id ↔ Twenty eventExternalId or iCalUid). Useful for backfilling history, recovering from a missed webhook, or syncing on a workflow trigger instead of waiting for Fireflies to push.',
timeoutSeconds: 60,
toolTriggerSettings: {
inputSchema: firefliesSyncCallInputSchema,
},
workflowActionTriggerSettings: {
label: 'Sync Fireflies Call',
inputSchema: jsonSchemaToInputSchema(firefliesSyncCallInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
transcriptId: { type: 'string' },
calendarEventId: { type: 'string' },
updatedFields: { type: 'array', items: { type: 'string' } },
fieldOutcomes: {
type: 'array',
items: {
type: 'object',
properties: {
field: { type: 'string' },
status: { type: 'string' },
reason: { type: 'string' },
error: { type: 'string' },
},
},
},
},
},
],
},
handler: firefliesSyncCallHandler,
});
@@ -0,0 +1,68 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { isDefined } from 'twenty-shared/utils';
import { FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesWebhookHandler } from 'src/logic-functions/handlers/fireflies-webhook-handler';
import {
type FirefliesWebhookPayload,
type FirefliesWebhookResult,
} from 'src/logic-functions/types/fireflies-webhook-payload.type';
import { getFirefliesWebhookSecret } from 'src/logic-functions/utils/get-fireflies-webhook-secret';
import { verifyFirefliesWebhookSignature } from 'src/logic-functions/utils/verify-fireflies-webhook-signature';
const firefliesWebhookRouteHandler = async (
routePayload: RoutePayload<FirefliesWebhookPayload>,
): Promise<FirefliesWebhookResult> => {
const secretResult = getFirefliesWebhookSecret();
if (!secretResult.success) {
return { error: secretResult.error };
}
const { rawBody } = routePayload;
if (!isDefined(rawBody)) {
return {
error:
'Invalid webhook signature: raw request body was not forwarded by the server, cannot verify HMAC',
};
}
const signatureHeader = routePayload.headers['x-hub-signature'];
const signatureCheck = verifyFirefliesWebhookSignature({
rawBody,
signatureHeader,
secret: secretResult.secret,
});
if (!signatureCheck.valid) {
return { error: `Invalid webhook signature: ${signatureCheck.error}` };
}
const body = routePayload.body;
if (!isDefined(body)) {
return { error: 'Webhook payload was empty' };
}
return firefliesWebhookHandler({
meetingId: body.meeting_id,
eventType: body.event,
});
};
export default defineLogicFunction({
universalIdentifier: FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'fireflies-webhook',
description:
'Receives Fireflies webhook events when a transcript is ready, then writes the transcript onto the matching CalendarEvent.',
timeoutSeconds: 60,
handler: firefliesWebhookRouteHandler,
httpRouteTriggerSettings: {
path: '/webhook/fireflies',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: ['x-hub-signature'],
},
});
@@ -0,0 +1,62 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type FirefliesCallListResult } from 'src/logic-functions/types/fireflies-call-list-result.type';
import { type FirefliesListCallsByParticipantInput } from 'src/logic-functions/types/fireflies-list-calls-by-participant-input.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import { listFirefliesTranscripts } from 'src/logic-functions/utils/list-fireflies-transcripts';
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
const clampLimit = (limit: number | undefined): number => {
if (!Number.isFinite(limit) || limit === undefined) {
return DEFAULT_LIMIT;
}
return Math.max(1, Math.min(MAX_LIMIT, Math.trunc(limit)));
};
export const firefliesListCallsByParticipantHandler = async (
parameters: FirefliesListCallsByParticipantInput,
): Promise<FirefliesCallListResult> => {
const participantEmail = parameters.participantEmail?.trim();
if (!isNonEmptyString(participantEmail)) {
return {
success: false,
message: 'Failed to list Fireflies calls',
error: '`participantEmail` is required.',
};
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return {
success: false,
message: 'Fireflies is not configured',
error: apiKeyResult.error,
};
}
const result = await listFirefliesTranscripts({
apiKey: apiKeyResult.apiKey,
participants: [participantEmail],
limit: clampLimit(parameters.limit),
});
if (!result.ok) {
return {
success: false,
message: 'Failed to list Fireflies calls',
error: result.errorMessage,
};
}
return {
success: true,
message: `Found ${result.data.length} Fireflies call(s) with ${participantEmail}.`,
calls: result.data,
count: result.data.length,
};
};
@@ -0,0 +1,63 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type FirefliesCallListResult } from 'src/logic-functions/types/fireflies-call-list-result.type';
import { type FirefliesSearchCallsInput } from 'src/logic-functions/types/fireflies-search-calls-input.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import { listFirefliesTranscripts } from 'src/logic-functions/utils/list-fireflies-transcripts';
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
const clampLimit = (limit: number | undefined): number => {
if (!Number.isFinite(limit) || limit === undefined) {
return DEFAULT_LIMIT;
}
return Math.max(1, Math.min(MAX_LIMIT, Math.trunc(limit)));
};
export const firefliesSearchCallsHandler = async (
parameters: FirefliesSearchCallsInput,
): Promise<FirefliesCallListResult> => {
const keyword = parameters.keyword?.trim();
if (!isNonEmptyString(keyword)) {
return {
success: false,
message: 'Failed to search Fireflies calls',
error: '`keyword` is required.',
};
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return {
success: false,
message: 'Fireflies is not configured',
error: apiKeyResult.error,
};
}
const result = await listFirefliesTranscripts({
apiKey: apiKeyResult.apiKey,
keyword,
keywordScope: 'all',
limit: clampLimit(parameters.limit),
});
if (!result.ok) {
return {
success: false,
message: 'Failed to search Fireflies calls',
error: result.errorMessage,
};
}
return {
success: true,
message: `Found ${result.data.length} Fireflies call(s) matching "${keyword}".`,
calls: result.data,
count: result.data.length,
};
};
@@ -0,0 +1,125 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type FirefliesSyncCallInput } from 'src/logic-functions/types/fireflies-sync-call-input.type';
import {
type FirefliesSyncCallFieldOutcome,
type FirefliesSyncCallResult,
} from 'src/logic-functions/types/fireflies-sync-call-result.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import {
type FirefliesSyncableField,
syncFirefliesFieldToCalendarEvent,
} from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event';
const ALL_FIELDS: FirefliesSyncableField[] = ['transcript', 'summary'];
const buildFailure = (
message: string,
error: string,
transcriptId?: string,
): FirefliesSyncCallResult => ({
success: false,
message,
error,
transcriptId,
});
export const firefliesSyncCallHandler = async (
parameters: FirefliesSyncCallInput,
): Promise<FirefliesSyncCallResult> => {
const transcriptId = parameters.transcriptId?.trim();
if (!isNonEmptyString(transcriptId)) {
return buildFailure(
'Failed to sync Fireflies call',
'`transcriptId` is required.',
);
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return buildFailure(
'Fireflies is not configured',
apiKeyResult.error,
transcriptId,
);
}
const client = new CoreApiClient();
const results = await Promise.all(
ALL_FIELDS.map((field) =>
syncFirefliesFieldToCalendarEvent({
apiKey: apiKeyResult.apiKey,
client,
transcriptId,
field,
}),
),
);
const fieldOutcomes: FirefliesSyncCallFieldOutcome[] = results.map(
(result) => {
if (result.status === 'skipped') {
return {
field: result.field,
status: 'skipped',
reason: result.reason,
};
}
if (result.status === 'error') {
return { field: result.field, status: 'error', error: result.error };
}
return { field: result.field, status: 'updated' };
},
);
const updatedFields = fieldOutcomes
.filter((outcome) => outcome.status === 'updated')
.map((outcome) => outcome.field);
const calendarEventId = results.find(
(result) => result.status === 'updated',
)?.calendarEventId;
if (updatedFields.length === 0) {
const skipReasons = fieldOutcomes
.filter((outcome) => outcome.status === 'skipped')
.map((outcome) => `${outcome.field}: ${outcome.reason}`);
const errors = fieldOutcomes
.filter((outcome) => outcome.status === 'error')
.map((outcome) => `${outcome.field}: ${outcome.error}`);
return {
success: false,
message: `No fields were updated on the matching CalendarEvent for Fireflies transcript ${transcriptId}.`,
error: [...errors, ...skipReasons].join(' | ') || 'No fields updated.',
transcriptId,
fieldOutcomes,
};
}
const partialFailures = fieldOutcomes.filter(
(outcome) => outcome.status === 'error',
);
return {
success: true,
message:
partialFailures.length > 0
? `Synced ${updatedFields.join(
' + ',
)} for Fireflies transcript ${transcriptId} (with errors on ${partialFailures
.map((outcome) => outcome.field)
.join(', ')}).`
: `Synced ${updatedFields.join(
' + ',
)} for Fireflies transcript ${transcriptId}.`,
transcriptId,
calendarEventId,
updatedFields,
fieldOutcomes,
};
};
@@ -0,0 +1,71 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type FirefliesWebhookResult } from 'src/logic-functions/types/fireflies-webhook-payload.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import {
type FirefliesSyncableField,
syncFirefliesFieldToCalendarEvent,
} from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event';
const TRANSCRIPT_READY_EVENT = 'meeting.transcribed';
const SUMMARY_READY_EVENT = 'meeting.summarized';
const FIELD_BY_EVENT: Record<string, FirefliesSyncableField> = {
[TRANSCRIPT_READY_EVENT]: 'transcript',
[SUMMARY_READY_EVENT]: 'summary',
};
export const firefliesWebhookHandler = async ({
meetingId,
eventType,
}: {
meetingId?: string | null;
eventType?: string | null;
}): Promise<FirefliesWebhookResult> => {
if (!isNonEmptyString(meetingId)) {
return { error: 'Webhook payload is missing meetingId' };
}
const field = isNonEmptyString(eventType)
? FIELD_BY_EVENT[eventType]
: undefined;
if (field === undefined) {
return {
skipped: true,
reason: `Unsupported Fireflies Webhooks V2 event "${
eventType ?? '<missing>'
}"; expected "${TRANSCRIPT_READY_EVENT}" or "${SUMMARY_READY_EVENT}"`,
meetingId,
};
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return { error: apiKeyResult.error, meetingId };
}
const syncResult = await syncFirefliesFieldToCalendarEvent({
apiKey: apiKeyResult.apiKey,
client: new CoreApiClient(),
transcriptId: meetingId,
field,
});
if (syncResult.status === 'error') {
return { error: syncResult.error, meetingId };
}
if (syncResult.status === 'skipped') {
return { skipped: true, reason: syncResult.reason, meetingId };
}
return {
action: 'updated',
field: syncResult.field,
calendarEventId: syncResult.calendarEventId,
meetingId,
};
};
@@ -0,0 +1,23 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const firefliesListCallsByParticipantInputSchema: InputJsonSchema = {
type: 'object',
properties: {
participantEmail: {
type: 'string',
label: 'Participant email',
description:
'Email address of a meeting attendee. Returns Fireflies calls where this email appears in the participants list (case-insensitive match performed by Fireflies). Useful to answer "what calls have we had with this contact?" before reaching out to them.',
},
limit: {
type: 'integer',
label: 'Maximum number of calls',
description:
'Optional. Maximum number of calls to return. Defaults to 20. Fireflies caps the limit at 50 per query.',
minimum: 1,
maximum: 50,
},
},
required: ['participantEmail'],
additionalProperties: false,
};
@@ -0,0 +1,23 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const firefliesSearchCallsInputSchema: InputJsonSchema = {
type: 'object',
properties: {
keyword: {
type: 'string',
label: 'Keyword to search for',
description:
'Keyword or phrase to search across Fireflies meetings. Matches against both meeting titles and the words spoken during meetings. Useful for finding "the call where we discussed pricing" or "any meeting that mentioned the new integration".',
},
limit: {
type: 'integer',
label: 'Maximum number of calls',
description:
'Optional. Maximum number of calls to return. Defaults to 20. Fireflies caps the limit at 50 per query.',
minimum: 1,
maximum: 50,
},
},
required: ['keyword'],
additionalProperties: false,
};
@@ -0,0 +1,15 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const firefliesSyncCallInputSchema: InputJsonSchema = {
type: 'object',
properties: {
transcriptId: {
type: 'string',
label: 'Fireflies call ID',
description:
'The ID of the Fireflies call to sync (also referred to as the "transcript ID" in Fireflies\' API and docs). Found at the end of the Fireflies meeting URL (`https://app.fireflies.ai/view/<id>`) or in the `meeting_id` field of a Fireflies webhook payload. Runs the same pipeline as the webhook: fetches transcript + AI summary from Fireflies and writes both onto the matching CalendarEvent.',
},
},
required: ['transcriptId'],
additionalProperties: false,
};
@@ -0,0 +1,18 @@
export type FirefliesCallSummary = {
id: string;
title: string | null;
date: string | null;
durationMinutes: number | null;
participants: string[];
hostEmail: string | null;
transcriptUrl: string | null;
meetingLink: string | null;
};
export type FirefliesCallListResult = {
success: boolean;
message: string;
error?: string;
calls?: FirefliesCallSummary[];
count?: number;
};
@@ -0,0 +1,4 @@
export type FirefliesListCallsByParticipantInput = {
participantEmail: string;
limit?: number;
};
@@ -0,0 +1,4 @@
export type FirefliesSearchCallsInput = {
keyword: string;
limit?: number;
};
@@ -0,0 +1,3 @@
export type FirefliesSyncCallInput = {
transcriptId: string;
};
@@ -0,0 +1,16 @@
import { type FirefliesSyncableField } from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event';
export type FirefliesSyncCallFieldOutcome =
| { field: FirefliesSyncableField; status: 'updated' }
| { field: FirefliesSyncableField; status: 'skipped'; reason: string }
| { field: FirefliesSyncableField; status: 'error'; error: string };
export type FirefliesSyncCallResult = {
success: boolean;
message: string;
error?: string;
transcriptId?: string;
calendarEventId?: string;
updatedFields?: FirefliesSyncableField[];
fieldOutcomes?: FirefliesSyncCallFieldOutcome[];
};
@@ -0,0 +1,30 @@
export type FirefliesTranscriptSentence = {
speaker_name: string | null;
text: string;
start_time: number | null;
};
export type FirefliesSummary = {
overview: string | null;
action_items: string | null;
keywords: string[] | null;
topics_discussed: string[] | null;
short_summary: string | null;
};
export type FirefliesTranscript = {
id: string;
title: string | null;
duration: number | null;
meeting_link: string | null;
participants: string[];
organizer_email: string | null;
host_email?: string | null;
date?: number | null;
transcript_url?: string | null;
sentences?: FirefliesTranscriptSentence[] | null;
summary?: FirefliesSummary | null;
calendar_id?: string | null;
cal_id?: string | null;
calendar_type?: string | null;
};
@@ -0,0 +1,18 @@
export type FirefliesWebhookPayload = {
event: string;
meeting_id: string;
timestamp?: number;
client_reference_id?: string | null;
};
export type FirefliesSyncedField = 'transcript' | 'summary';
export type FirefliesWebhookResult =
| {
action: 'updated';
field: FirefliesSyncedField;
calendarEventId: string;
meetingId: string;
}
| { skipped: true; reason: string; meetingId?: string }
| { error: string; meetingId?: string };
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import {
type FirefliesSummary,
type FirefliesTranscript,
} from 'src/logic-functions/types/fireflies-transcript.type';
import { formatSummaryAsMarkdown } from 'src/logic-functions/utils/format-summary-as-markdown';
const buildTranscript = (
summary: FirefliesSummary | null | undefined,
): FirefliesTranscript => ({
id: 'abc',
title: 'Test call',
duration: 30,
meeting_link: 'https://zoom.us/j/1234',
participants: ['[email protected]'],
organizer_email: '[email protected]',
summary,
});
describe('formatSummaryAsMarkdown', () => {
it('returns a placeholder when summary is undefined', () => {
const result = formatSummaryAsMarkdown(buildTranscript(undefined));
expect(result).toContain('Fireflies returned no summary content');
});
it('returns a placeholder when summary is null', () => {
const result = formatSummaryAsMarkdown(buildTranscript(null));
expect(result).toContain('Fireflies returned no summary content');
});
it('returns a placeholder when every summary section is empty', () => {
const result = formatSummaryAsMarkdown(
buildTranscript({
overview: '',
action_items: ' ',
keywords: null,
topics_discussed: null,
short_summary: null,
}),
);
expect(result).toContain('Fireflies returned no summary content');
});
it('renders overview, action items, topics and keywords in order with headers', () => {
const result = formatSummaryAsMarkdown(
buildTranscript({
overview: '- **Item 1:** First point',
action_items: '**Abdul**\nDo something (00:10)',
keywords: ['Twenty', 'Fireflies'],
topics_discussed: ['Integration', 'Roadmap'],
short_summary: 'irrelevant',
}),
);
expect(result).toBe(
[
'## Overview',
'',
'- **Item 1:** First point',
'',
'## Action items',
'',
'**Abdul**\nDo something (00:10)',
'',
'## Topics discussed',
'',
'Integration, Roadmap',
'',
'## Keywords',
'',
'Twenty, Fireflies',
].join('\n'),
);
});
it('omits sections that are empty', () => {
const result = formatSummaryAsMarkdown(
buildTranscript({
overview: 'Some overview',
action_items: null,
keywords: [],
topics_discussed: null,
short_summary: null,
}),
);
expect(result).toBe('## Overview\n\nSome overview');
});
it('trims overview and action_items whitespace', () => {
const result = formatSummaryAsMarkdown(
buildTranscript({
overview: ' \n Overview body \n ',
action_items: '\n**A**\nDo it\n',
keywords: null,
topics_discussed: null,
short_summary: null,
}),
);
expect(result).toBe(
'## Overview\n\nOverview body\n\n## Action items\n\n**A**\nDo it',
);
});
});
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
import { formatTranscriptAsMarkdown } from 'src/logic-functions/utils/format-transcript-as-markdown';
const buildTranscript = (
sentences: FirefliesTranscript['sentences'],
): FirefliesTranscript => ({
id: 'abc',
title: 'Test call',
date: 1700000000000,
duration: 30,
meeting_link: 'https://zoom.us/j/1234',
participants: ['[email protected]'],
organizer_email: '[email protected]',
sentences,
});
describe('formatTranscriptAsMarkdown', () => {
it('returns a placeholder when there are no sentences', () => {
const result = formatTranscriptAsMarkdown(buildTranscript([]));
expect(result).toContain('Fireflies returned no transcript content');
});
it('returns a placeholder when sentences is null', () => {
const result = formatTranscriptAsMarkdown(buildTranscript(null));
expect(result).toContain('Fireflies returned no transcript content');
});
it('groups consecutive sentences from the same speaker into one paragraph', () => {
const result = formatTranscriptAsMarkdown(
buildTranscript([
{ speaker_name: 'Sarah', text: 'Hi there', start_time: 0 },
{ speaker_name: 'Sarah', text: 'How are you?', start_time: 1 },
{ speaker_name: 'John', text: 'Doing well, thanks.', start_time: 2 },
]),
);
expect(result).toBe(
'**Sarah:** Hi there How are you?\n\n**John:** Doing well, thanks.',
);
});
it('falls back to "Speaker" when the speaker name is missing', () => {
const result = formatTranscriptAsMarkdown(
buildTranscript([{ speaker_name: null, text: 'Hello', start_time: 0 }]),
);
expect(result).toBe('**Speaker:** Hello');
});
it('falls back to "Speaker" when the speaker name is whitespace-only', () => {
const result = formatTranscriptAsMarkdown(
buildTranscript([
{ speaker_name: ' ', text: 'Hello', start_time: 0 },
{ speaker_name: '\n\t', text: 'World', start_time: 1 },
]),
);
expect(result).toBe('**Speaker:** Hello World');
});
it('skips empty sentence text', () => {
const result = formatTranscriptAsMarkdown(
buildTranscript([
{ speaker_name: 'Sarah', text: ' ', start_time: 0 },
{ speaker_name: 'Sarah', text: 'Hello', start_time: 1 },
]),
);
expect(result).toBe('**Sarah:** Hello');
});
it('returns the placeholder when every sentence is whitespace-only', () => {
const result = formatTranscriptAsMarkdown(
buildTranscript([
{ speaker_name: 'Sarah', text: ' ', start_time: 0 },
{ speaker_name: 'John', text: '\n\t', start_time: 1 },
{ speaker_name: null, text: '', start_time: 2 },
]),
);
expect(result).toContain('Fireflies returned no transcript content');
});
});
@@ -0,0 +1,101 @@
import { createHmac } from 'crypto';
import { describe, expect, it } from 'vitest';
import { verifyFirefliesWebhookSignature } from 'src/logic-functions/utils/verify-fireflies-webhook-signature';
const SECRET = 'test-secret-abc123';
const sign = (body: string): string =>
createHmac('sha256', SECRET).update(body, 'utf8').digest('hex');
describe('verifyFirefliesWebhookSignature', () => {
it('accepts a valid bare-hex signature', () => {
const body = JSON.stringify({ meetingId: 'm1' });
const signature = sign(body);
const result = verifyFirefliesWebhookSignature({
rawBody: body,
signatureHeader: signature,
secret: SECRET,
});
expect(result).toEqual({ valid: true });
});
it('accepts a valid signature with sha256= prefix', () => {
const body = JSON.stringify({ meetingId: 'm1' });
const signature = `sha256=${sign(body)}`;
const result = verifyFirefliesWebhookSignature({
rawBody: body,
signatureHeader: signature,
secret: SECRET,
});
expect(result).toEqual({ valid: true });
});
it('rejects when signature header is missing', () => {
const result = verifyFirefliesWebhookSignature({
rawBody: '{}',
signatureHeader: undefined,
secret: SECRET,
});
expect(result).toEqual({
valid: false,
error: 'Missing x-hub-signature header',
});
});
it('rejects when signature header is empty', () => {
const result = verifyFirefliesWebhookSignature({
rawBody: '{}',
signatureHeader: '',
secret: SECRET,
});
expect(result).toEqual({
valid: false,
error: 'Missing x-hub-signature header',
});
});
it('rejects when the signature was computed from a different body', () => {
const signature = sign(JSON.stringify({ meetingId: 'm1' }));
const result = verifyFirefliesWebhookSignature({
rawBody: JSON.stringify({ meetingId: 'tampered' }),
signatureHeader: signature,
secret: SECRET,
});
expect(result.valid).toBe(false);
});
it('rejects when the signature was computed with a different secret', () => {
const body = JSON.stringify({ meetingId: 'm1' });
const signature = createHmac('sha256', 'other-secret')
.update(body, 'utf8')
.digest('hex');
const result = verifyFirefliesWebhookSignature({
rawBody: body,
signatureHeader: signature,
secret: SECRET,
});
expect(result.valid).toBe(false);
});
it('rejects malformed signature strings', () => {
const result = verifyFirefliesWebhookSignature({
rawBody: '{}',
signatureHeader: 'not-a-real-signature',
secret: SECRET,
});
expect(result.valid).toBe(false);
});
});
@@ -0,0 +1,60 @@
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
import {
firefliesApiRequest,
type FirefliesApiResult,
} from 'src/logic-functions/utils/fireflies-api-request';
const SUMMARY_QUERY = `
query Transcript($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
duration
meeting_link
participants
organizer_email
calendar_id
cal_id
calendar_type
summary {
overview
action_items
keywords
topics_discussed
short_summary
}
}
}
`;
type FirefliesSummaryResponse = {
transcript: FirefliesTranscript | null;
};
export const fetchFirefliesSummary = async ({
apiKey,
transcriptId,
}: {
apiKey: string;
transcriptId: string;
}): Promise<FirefliesApiResult<FirefliesTranscript>> => {
const result = await firefliesApiRequest<FirefliesSummaryResponse>({
apiKey,
query: SUMMARY_QUERY,
variables: { transcriptId },
});
if (!result.ok) {
return result;
}
if (result.data.transcript === null) {
return {
ok: false,
status: 404,
errorMessage: `Fireflies transcript ${transcriptId} not found (may have been deleted or access was revoked)`,
};
}
return { ok: true, status: result.status, data: result.data.transcript };
};
@@ -0,0 +1,58 @@
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
import {
firefliesApiRequest,
type FirefliesApiResult,
} from 'src/logic-functions/utils/fireflies-api-request';
const TRANSCRIPT_QUERY = `
query Transcript($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
duration
meeting_link
participants
organizer_email
calendar_id
cal_id
calendar_type
sentences {
speaker_name
text
start_time
}
}
}
`;
type FirefliesTranscriptResponse = {
transcript: FirefliesTranscript | null;
};
export const fetchFirefliesTranscript = async ({
apiKey,
transcriptId,
}: {
apiKey: string;
transcriptId: string;
}): Promise<FirefliesApiResult<FirefliesTranscript>> => {
const result = await firefliesApiRequest<FirefliesTranscriptResponse>({
apiKey,
query: TRANSCRIPT_QUERY,
variables: { transcriptId },
});
if (!result.ok) {
return result;
}
if (result.data.transcript === null) {
return {
ok: false,
status: 404,
errorMessage: `Fireflies transcript ${transcriptId} not found (may have been deleted or access was revoked)`,
};
}
return { ok: true, status: result.status, data: result.data.transcript };
};
@@ -0,0 +1,117 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
type CalendarChannelEventAssociationsConnection = {
edges: Array<{ node: { calendarEventId: string } }>;
};
type CalendarEventsConnection = {
edges: Array<{ node: { id: string } }>;
};
const findByEventExternalId = async (
client: CoreApiClient,
externalIds: string[],
): Promise<string | null> => {
if (externalIds.length === 0) {
return null;
}
const { calendarChannelEventAssociations } = await client.query({
calendarChannelEventAssociations: {
__args: {
filter: {
eventExternalId: { in: externalIds },
},
first: 1,
},
edges: {
node: {
calendarEventId: true,
},
},
},
});
const node = (
calendarChannelEventAssociations as
| CalendarChannelEventAssociationsConnection
| undefined
)?.edges?.[0]?.node;
return node?.calendarEventId ?? null;
};
const findByICalUid = async (
client: CoreApiClient,
iCalUid: string,
): Promise<string | null> => {
const { calendarEvents } = await client.query({
calendarEvents: {
__args: {
filter: {
iCalUid: { eq: iCalUid },
},
first: 1,
},
edges: {
node: {
id: true,
},
},
},
});
const node = (calendarEvents as CalendarEventsConnection | undefined)
?.edges?.[0]?.node;
return node?.id ?? null;
};
export const findMatchingCalendarEvent = async ({
client,
transcript,
}: {
client: CoreApiClient;
transcript: FirefliesTranscript;
}): Promise<
| {
matched: true;
calendarEventId: string;
matchedBy: 'externalId' | 'iCalUid';
}
| { matched: false; reason: string }
> => {
const externalIdCandidates = [
transcript.calendar_id,
transcript.cal_id,
].filter(isNonEmptyString);
if (externalIdCandidates.length > 0) {
const calendarEventId = await findByEventExternalId(
client,
externalIdCandidates,
);
if (isDefined(calendarEventId)) {
return { matched: true, calendarEventId, matchedBy: 'externalId' };
}
}
if (isNonEmptyString(transcript.calendar_id)) {
const calendarEventId = await findByICalUid(client, transcript.calendar_id);
if (isDefined(calendarEventId)) {
return { matched: true, calendarEventId, matchedBy: 'iCalUid' };
}
}
return {
matched: false,
reason:
'No CalendarEvent matched the transcript by external ID or iCalUid. Either the meeting was never on a synced calendar, or its calendar sync (Google/Outlook/CalDAV) is not configured in Twenty. Orphan calls are skipped in v1.',
};
};
@@ -0,0 +1,122 @@
import { isDefined } from 'twenty-shared/utils';
const FIREFLIES_API_URL = 'https://api.fireflies.ai/graphql';
type FirefliesApiSuccess<TData> = {
ok: true;
status: number;
data: TData;
};
type FirefliesApiFailure = {
ok: false;
status: number;
errorMessage: string;
};
export type FirefliesApiResult<TData> =
| FirefliesApiSuccess<TData>
| FirefliesApiFailure;
type FirefliesGraphqlError = {
message?: string;
extensions?: {
code?: string;
};
};
type FirefliesGraphqlEnvelope<TData> = {
data?: TData;
errors?: FirefliesGraphqlError[];
};
type FirefliesApiRequestParams = {
apiKey: string;
query: string;
variables?: Record<string, unknown>;
};
export const firefliesApiRequest = async <TData = unknown>({
apiKey,
query,
variables,
}: FirefliesApiRequestParams): Promise<FirefliesApiResult<TData>> => {
let response: Response;
try {
response = await fetch(FIREFLIES_API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables }),
});
} catch (error) {
return {
ok: false,
status: 0,
errorMessage: `Fireflies API request failed: ${(error as Error).message}`,
};
}
let envelope: FirefliesGraphqlEnvelope<TData> | null = null;
let parseError: Error | null = null;
try {
envelope = (await response.json()) as FirefliesGraphqlEnvelope<TData>;
} catch (error) {
parseError = error as Error;
}
if (
envelope !== null &&
isDefined(envelope.errors) &&
envelope.errors.length > 0
) {
return {
ok: false,
status: response.status,
errorMessage: `Fireflies GraphQL error: ${formatFirefliesGraphqlError(
envelope.errors[0],
)}`,
};
}
if (!response.ok) {
return {
ok: false,
status: response.status,
errorMessage: `Fireflies API responded with HTTP ${response.status}`,
};
}
if (parseError !== null) {
return {
ok: false,
status: response.status,
errorMessage: `Fireflies API returned a non-JSON response: ${parseError.message}`,
};
}
if (envelope === null || !isDefined(envelope.data)) {
return {
ok: false,
status: response.status,
errorMessage: 'Fireflies GraphQL response was missing a `data` field',
};
}
return { ok: true, status: response.status, data: envelope.data };
};
const formatFirefliesGraphqlError = (
error: FirefliesGraphqlError | undefined,
): string => {
const message = error?.message ?? 'Unknown Fireflies GraphQL error';
const code = error?.extensions?.code;
return isDefined(code)
? `${message} (Fireflies error code ${code})`
: message;
};
@@ -0,0 +1,43 @@
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
const EMPTY_SUMMARY_MESSAGE =
'_Fireflies returned no summary content for this meeting._';
export const formatSummaryAsMarkdown = (
transcript: FirefliesTranscript,
): string => {
const summary = transcript.summary;
if (!isDefined(summary)) {
return EMPTY_SUMMARY_MESSAGE;
}
const sections: string[] = [];
if (isNonEmptyString(summary.overview?.trim())) {
sections.push(`## Overview\n\n${summary.overview.trim()}`);
}
if (isNonEmptyString(summary.action_items?.trim())) {
sections.push(`## Action items\n\n${summary.action_items.trim()}`);
}
if (isNonEmptyArray(summary.topics_discussed)) {
sections.push(
`## Topics discussed\n\n${summary.topics_discussed.join(', ')}`,
);
}
if (isNonEmptyArray(summary.keywords)) {
sections.push(`## Keywords\n\n${summary.keywords.join(', ')}`);
}
if (sections.length === 0) {
return EMPTY_SUMMARY_MESSAGE;
}
return sections.join('\n\n');
};
@@ -0,0 +1,57 @@
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
const UNKNOWN_SPEAKER_LABEL = 'Speaker';
const EMPTY_TRANSCRIPT_MESSAGE =
'_Fireflies returned no transcript content for this meeting._';
export const formatTranscriptAsMarkdown = (
transcript: FirefliesTranscript,
): string => {
const sentences = transcript.sentences ?? [];
const lines: string[] = [];
let currentSpeaker: string | null = null;
let currentLines: string[] = [];
const flush = () => {
if (!isNonEmptyArray(currentLines)) {
return;
}
const speakerLabel = isDefined(currentSpeaker)
? currentSpeaker
: UNKNOWN_SPEAKER_LABEL;
lines.push(`**${speakerLabel}:** ${currentLines.join(' ')}`);
currentLines = [];
};
for (const sentence of sentences) {
const text = sentence.text.trim();
if (!isNonEmptyString(text)) {
continue;
}
const trimmedSpeaker = sentence.speaker_name?.trim();
const speaker = isNonEmptyString(trimmedSpeaker) ? trimmedSpeaker : null;
if (speaker !== currentSpeaker) {
flush();
currentSpeaker = speaker;
}
currentLines.push(text);
}
flush();
if (!isNonEmptyArray(lines)) {
return EMPTY_TRANSCRIPT_MESSAGE;
}
return lines.join('\n\n');
};
@@ -0,0 +1,19 @@
import { isNonEmptyString } from '@sniptt/guards';
export const FIREFLIES_API_KEY_ENV_VAR = 'FIREFLIES_API_KEY';
export const getFirefliesApiKey = ():
| { success: true; apiKey: string }
| { success: false; error: string } => {
const apiKey = process.env[FIREFLIES_API_KEY_ENV_VAR];
if (!isNonEmptyString(apiKey)) {
return {
success: false,
error:
'Fireflies is not configured. Open the Twenty Fireflies app settings and set the FIREFLIES_API_KEY application variable (Fireflies → Integrations → Fireflies API → Generate API key).',
};
}
return { success: true, apiKey };
};
@@ -0,0 +1,19 @@
import { isNonEmptyString } from '@sniptt/guards';
export const FIREFLIES_WEBHOOK_SECRET_ENV_VAR = 'FIREFLIES_WEBHOOK_SECRET';
export const getFirefliesWebhookSecret = ():
| { success: true; secret: string }
| { success: false; error: string } => {
const secret = process.env[FIREFLIES_WEBHOOK_SECRET_ENV_VAR];
if (!isNonEmptyString(secret)) {
return {
success: false,
error:
'FIREFLIES_WEBHOOK_SECRET application variable is not set. Set it in Twenty Fireflies app settings, then configure the same value on the Fireflies side when registering the webhook URL.',
};
}
return { success: true, secret };
};
@@ -0,0 +1,101 @@
import { isDefined } from 'twenty-shared/utils';
import { type FirefliesCallSummary } from 'src/logic-functions/types/fireflies-call-list-result.type';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
import {
firefliesApiRequest,
type FirefliesApiResult,
} from 'src/logic-functions/utils/fireflies-api-request';
const TRANSCRIPTS_QUERY = `
query Transcripts(
$keyword: String
$scope: String
$participants: [String!]
$fromDate: DateTime
$toDate: DateTime
$limit: Int
) {
transcripts(
keyword: $keyword
scope: $scope
participants: $participants
fromDate: $fromDate
toDate: $toDate
limit: $limit
) {
id
title
date
duration
participants
host_email
transcript_url
meeting_link
}
}
`;
type TranscriptsResponse = {
transcripts: FirefliesTranscript[] | null;
};
export type FirefliesKeywordScope = 'title' | 'sentences' | 'all';
export type ListFirefliesTranscriptsArgs = {
apiKey: string;
keyword?: string;
keywordScope?: FirefliesKeywordScope;
participants?: string[];
fromDate?: string;
toDate?: string;
limit?: number;
};
const toCallSummary = (transcript: FirefliesTranscript): FirefliesCallSummary => ({
id: transcript.id,
title: transcript.title,
date: isDefined(transcript.date) ? new Date(transcript.date).toISOString() : null,
durationMinutes: transcript.duration,
participants: transcript.participants,
hostEmail: transcript.host_email ?? transcript.organizer_email ?? null,
transcriptUrl: transcript.transcript_url ?? null,
meetingLink: transcript.meeting_link,
});
export const listFirefliesTranscripts = async ({
apiKey,
keyword,
keywordScope,
participants,
fromDate,
toDate,
limit,
}: ListFirefliesTranscriptsArgs): Promise<
FirefliesApiResult<FirefliesCallSummary[]>
> => {
const result = await firefliesApiRequest<TranscriptsResponse>({
apiKey,
query: TRANSCRIPTS_QUERY,
variables: {
keyword,
scope: keywordScope,
participants,
fromDate,
toDate,
limit,
},
});
if (!result.ok) {
return result;
}
const transcripts = result.data.transcripts ?? [];
return {
ok: true,
status: result.status,
data: transcripts.map(toCallSummary),
};
};
@@ -0,0 +1,102 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
import { fetchFirefliesSummary } from 'src/logic-functions/utils/fetch-fireflies-summary';
import { fetchFirefliesTranscript } from 'src/logic-functions/utils/fetch-fireflies-transcript';
import { type FirefliesApiResult } from 'src/logic-functions/utils/fireflies-api-request';
import { findMatchingCalendarEvent } from 'src/logic-functions/utils/find-matching-calendar-event';
import { formatSummaryAsMarkdown } from 'src/logic-functions/utils/format-summary-as-markdown';
import { formatTranscriptAsMarkdown } from 'src/logic-functions/utils/format-transcript-as-markdown';
import { updateCalendarEventSummary } from 'src/logic-functions/utils/update-calendar-event-summary';
import { updateCalendarEventTranscript } from 'src/logic-functions/utils/update-calendar-event-transcript';
export type FirefliesSyncableField = 'transcript' | 'summary';
export type SyncFirefliesFieldResult =
| {
status: 'updated';
field: FirefliesSyncableField;
calendarEventId: string;
}
| { status: 'skipped'; field: FirefliesSyncableField; reason: string }
| { status: 'error'; field: FirefliesSyncableField; error: string };
type FieldSyncStrategy = {
fetch: (args: {
apiKey: string;
transcriptId: string;
}) => Promise<FirefliesApiResult<FirefliesTranscript>>;
format: (transcript: FirefliesTranscript) => string;
update: (args: {
client: CoreApiClient;
calendarEventId: string;
markdown: string;
}) => Promise<void>;
};
const FIELD_SYNC_STRATEGIES: Record<FirefliesSyncableField, FieldSyncStrategy> =
{
transcript: {
fetch: fetchFirefliesTranscript,
format: formatTranscriptAsMarkdown,
update: updateCalendarEventTranscript,
},
summary: {
fetch: fetchFirefliesSummary,
format: formatSummaryAsMarkdown,
update: updateCalendarEventSummary,
},
};
export const syncFirefliesFieldToCalendarEvent = async ({
apiKey,
client,
transcriptId,
field,
}: {
apiKey: string;
client: CoreApiClient;
transcriptId: string;
field: FirefliesSyncableField;
}): Promise<SyncFirefliesFieldResult> => {
const strategy = FIELD_SYNC_STRATEGIES[field];
const fetchResult = await strategy.fetch({ apiKey, transcriptId });
if (!fetchResult.ok) {
return { status: 'error', field, error: fetchResult.errorMessage };
}
const match = await findMatchingCalendarEvent({
client,
transcript: fetchResult.data,
});
if (!match.matched) {
return { status: 'skipped', field, reason: match.reason };
}
const markdown = strategy.format(fetchResult.data);
try {
await strategy.update({
client,
calendarEventId: match.calendarEventId,
markdown,
});
} catch (error) {
return {
status: 'error',
field,
error: `Failed to update CalendarEvent ${match.calendarEventId} ${field}: ${
(error as Error).message
}`,
};
}
return {
status: 'updated',
field,
calendarEventId: match.calendarEventId,
};
};
@@ -0,0 +1,26 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
export const updateCalendarEventSummary = async ({
client,
calendarEventId,
markdown,
}: {
client: CoreApiClient;
calendarEventId: string;
markdown: string;
}): Promise<void> => {
await client.mutation({
updateCalendarEvent: {
__args: {
id: calendarEventId,
data: {
summary: {
markdown,
blocknote: null,
},
},
},
id: true,
},
});
};
@@ -0,0 +1,26 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
export const updateCalendarEventTranscript = async ({
client,
calendarEventId,
markdown,
}: {
client: CoreApiClient;
calendarEventId: string;
markdown: string;
}): Promise<void> => {
await client.mutation({
updateCalendarEvent: {
__args: {
id: calendarEventId,
data: {
transcript: {
markdown,
blocknote: null,
},
},
},
id: true,
},
});
};
@@ -0,0 +1,49 @@
import { createHmac, timingSafeEqual } from 'crypto';
import { isNonEmptyString } from '@sniptt/guards';
const SIGNATURE_PREFIX = 'sha256=';
const stripPrefix = (signature: string): string =>
signature.startsWith(SIGNATURE_PREFIX)
? signature.slice(SIGNATURE_PREFIX.length)
: signature;
export const verifyFirefliesWebhookSignature = ({
rawBody,
signatureHeader,
secret,
}: {
rawBody: string;
signatureHeader: string | undefined;
secret: string;
}): { valid: true } | { valid: false; error: string } => {
if (!isNonEmptyString(signatureHeader)) {
return { valid: false, error: 'Missing x-hub-signature header' };
}
const provided = stripPrefix(signatureHeader.trim()).toLowerCase();
const expected = createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');
if (provided.length !== expected.length) {
return { valid: false, error: 'Signature length mismatch' };
}
const providedBuffer = Buffer.from(provided, 'hex');
const expectedBuffer = Buffer.from(expected, 'hex');
if (
providedBuffer.length === 0 ||
providedBuffer.length !== expectedBuffer.length
) {
return { valid: false, error: 'Malformed signature' };
}
if (!timingSafeEqual(providedBuffer, expectedBuffer)) {
return { valid: false, error: 'Signature verification failed' };
}
return { valid: true };
};
@@ -0,0 +1,42 @@
import {
defineApplicationRole,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Twenty Fireflies sync role',
description:
'Reads CalendarEvent and CalendarChannelEventAssociation to locate the meeting matching a Fireflies call (from an incoming webhook or from the on-demand Sync Fireflies Call workflow tool), and updates that CalendarEvent to write the synced transcript and summary fields. The list / search workflow tools only call the Fireflies API and do not require any Twenty object permissions.',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarChannelEventAssociation
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [],
permissionFlags: [],
});
@@ -0,0 +1,20 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { SUMMARY_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: SUMMARY_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
type: FieldType.RICH_TEXT,
name: 'summary',
label: 'Summary',
description:
'AI-generated meeting summary synced from Fireflies. Includes overview, action items, and keywords. Populated automatically when Fireflies finishes summarizing a recording that matches this calendar event.',
icon: 'IconNotes',
isNullable: true,
});
@@ -0,0 +1,20 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { TRANSCRIPT_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineField({
universalIdentifier: TRANSCRIPT_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
type: FieldType.RICH_TEXT,
name: 'transcript',
label: 'Transcript',
description:
'Call transcript synced from Fireflies. Populated automatically when a Fireflies recording finishes processing and matches this calendar event.',
icon: 'IconMicrophone',
isNullable: true,
});
@@ -0,0 +1,31 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2020",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
"version": "2.5.0",
"version": "2.5.1",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
@@ -1395,6 +1395,7 @@ type PageLayout {
objectMetadataId: UUID
tabs: [PageLayoutTab!]
defaultTabToFocusOnMobileAndSidePanelId: UUID
universalIdentifier: UUID!
createdAt: DateTime!
updatedAt: DateTime!
deletedAt: DateTime
@@ -1421,6 +1422,22 @@ type ApplicationConnectionProvider {
oauth: ApplicationConnectionProviderOAuthConfig
}
type EnterpriseLicenseInfoDTO {
isValid: Boolean!
licensee: String
expiresAt: DateTime
subscriptionId: String
}
type EnterpriseSubscriptionStatusDTO {
status: String!
licensee: String
expiresAt: DateTime
cancelAt: DateTime
currentPeriodEnd: DateTime
isCancellationScheduled: Boolean!
}
type Analytics {
"""Boolean that confirms query was dispatched"""
success: Boolean!
@@ -1441,22 +1458,6 @@ type FileWithSignedUrl {
url: String!
}
type EnterpriseLicenseInfoDTO {
isValid: Boolean!
licensee: String
expiresAt: DateTime
subscriptionId: String
}
type EnterpriseSubscriptionStatusDTO {
status: String!
licensee: String
expiresAt: DateTime
cancelAt: DateTime
currentPeriodEnd: DateTime
isCancellationScheduled: Boolean!
}
type BillingSubscriptionSchedulePhaseItem {
price: String!
quantity: Float
@@ -2417,18 +2418,49 @@ type PlaceDetailsResult {
location: Location
}
type ConnectionParametersOutput {
type PublicConnectionParametersOutput {
host: String!
port: Float!
username: String
password: String!
secure: Boolean
}
type ImapSmtpCaldavConnectionParameters {
IMAP: ConnectionParametersOutput
SMTP: ConnectionParametersOutput
CALDAV: ConnectionParametersOutput
type PublicImapSmtpCaldavConnectionParameters {
IMAP: PublicConnectionParametersOutput
SMTP: PublicConnectionParametersOutput
CALDAV: PublicConnectionParametersOutput
}
type ConnectedAccountPublicDTO {
id: UUID!
handle: String!
provider: String!
lastCredentialsRefreshedAt: DateTime
authFailedAt: DateTime
handleAliases: [String!]
scopes: [String!]
lastSignedInAt: DateTime
userWorkspaceId: UUID!
connectionProviderId: UUID
applicationId: UUID
name: String
visibility: String!
createdAt: DateTime!
updatedAt: DateTime!
connectionParameters: PublicImapSmtpCaldavConnectionParameters
}
type ImapSmtpCaldavPublicConnectionParams {
host: String!
port: Float!
username: String
secure: Boolean
}
type ImapSmtpCaldavPublicConnectionParameters {
IMAP: ImapSmtpCaldavPublicConnectionParams
SMTP: ImapSmtpCaldavPublicConnectionParams
CALDAV: ImapSmtpCaldavPublicConnectionParams
}
type ConnectedImapSmtpCaldavAccount {
@@ -2436,7 +2468,7 @@ type ConnectedImapSmtpCaldavAccount {
handle: String!
provider: String!
userWorkspaceId: UUID!
connectionParameters: ImapSmtpCaldavConnectionParameters
connectionParameters: ImapSmtpCaldavPublicConnectionParameters
}
type ImapSmtpCaldavConnectionSuccess {
@@ -2551,57 +2583,6 @@ type DuplicatedDashboard {
updatedAt: String!
}
type ConnectedAccountDTO {
id: UUID!
handle: String!
provider: String!
lastCredentialsRefreshedAt: DateTime
authFailedAt: DateTime
handleAliases: [String!]
scopes: [String!]
connectionParameters: ImapSmtpCaldavConnectionParameters
lastSignedInAt: DateTime
userWorkspaceId: UUID!
connectionProviderId: UUID
applicationId: UUID
name: String
visibility: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type PublicConnectionParametersOutput {
host: String!
port: Float!
username: String
secure: Boolean
}
type PublicImapSmtpCaldavConnectionParameters {
IMAP: PublicConnectionParametersOutput
SMTP: PublicConnectionParametersOutput
CALDAV: PublicConnectionParametersOutput
}
type ConnectedAccountPublicDTO {
id: UUID!
handle: String!
provider: String!
lastCredentialsRefreshedAt: DateTime
authFailedAt: DateTime
handleAliases: [String!]
scopes: [String!]
lastSignedInAt: DateTime
userWorkspaceId: UUID!
connectionProviderId: UUID
applicationId: UUID
name: String
visibility: String!
createdAt: DateTime!
updatedAt: DateTime!
connectionParameters: PublicImapSmtpCaldavConnectionParameters
}
type SendEmailOutput {
success: Boolean!
error: String
@@ -2936,6 +2917,9 @@ type Webhook {
type Query {
navigationMenuItems: [NavigationMenuItem!]!
navigationMenuItem(id: UUID!): NavigationMenuItem
enterprisePortalSession(returnUrlPath: String): String
enterpriseCheckoutSession(billingInterval: String): String
enterpriseSubscriptionStatus: EnterpriseSubscriptionStatusDTO
getViewFilterGroups(viewId: String): [ViewFilterGroup!]!
getViewFilterGroup(id: String!): ViewFilterGroup
getViewFilters(viewId: String): [ViewFilter!]!
@@ -2950,9 +2934,6 @@ type Query {
getViewFieldGroup(id: String!): ViewFieldGroup
apiKeys: [ApiKey!]!
apiKey(input: GetApiKeyInput!): ApiKey
enterprisePortalSession(returnUrlPath: String): String
enterpriseCheckoutSession(billingInterval: String): String
enterpriseSubscriptionStatus: EnterpriseSubscriptionStatusDTO
billingPortalSession(returnUrlPath: String): BillingSession!
listPlans: [BillingPlan!]!
getResourceCreditUsage: [BillingResourceCreditUsage!]!
@@ -3016,9 +2997,9 @@ type Query {
getViewGroup(id: String!): ViewGroup
myMessageFolders(messageChannelId: UUID): [MessageFolder!]!
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
myConnectedAccounts: [ConnectedAccountDTO!]!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
connectedAccountById(id: UUID!): ConnectedAccountPublicDTO
connectedAccounts: [ConnectedAccountDTO!]!
connectedAccounts: [ConnectedAccountPublicDTO!]!
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
webhooks: [Webhook!]!
webhook(id: UUID!): Webhook
@@ -3145,6 +3126,8 @@ type Mutation {
deleteManyNavigationMenuItems(ids: [UUID!]!): [NavigationMenuItem!]!
deleteNavigationMenuItem(id: UUID!): NavigationMenuItem!
uploadEmailAttachmentFile(file: Upload!): FileWithSignedUrl!
refreshEnterpriseValidityToken: Boolean!
setEnterpriseKey(enterpriseKey: String!): EnterpriseLicenseInfoDTO!
uploadAiChatFile(file: Upload!): FileWithSignedUrl!
uploadWorkflowFile(file: Upload!): FileWithSignedUrl!
uploadWorkspaceLogo(file: Upload!): FileWithSignedUrl!
@@ -3185,8 +3168,6 @@ type Mutation {
assignRoleToApiKey(apiKeyId: UUID!, roleId: UUID!): Boolean!
createObjectEvent(event: String!, recordId: UUID!, objectMetadataId: UUID!, properties: JSON): Analytics!
trackAnalytics(type: AnalyticsType!, name: String, event: String, properties: JSON): Analytics!
refreshEnterpriseValidityToken: Boolean!
setEnterpriseKey(enterpriseKey: String!): EnterpriseLicenseInfoDTO!
skipSyncEmailOnboardingStep: OnboardingStepSuccess!
skipBookOnboardingStep: OnboardingStepSuccess!
checkoutSession(recurringInterval: SubscriptionInterval!, plan: BillingPlanKey! = PRO, requirePaymentMethod: Boolean! = true, successUrlPath: String): BillingSession!
@@ -3257,13 +3238,13 @@ type Mutation {
updateMessageChannel(input: UpdateMessageChannelInput!): MessageChannel!
createEmailGroupChannel(input: CreateEmailGroupChannelInput!): CreateEmailGroupChannelOutput!
deleteEmailGroupChannel(id: UUID!): MessageChannel!
deleteConnectedAccount(id: UUID!): ConnectedAccountDTO!
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
createWebhook(input: CreateWebhookInput!): Webhook!
updateWebhook(input: UpdateWebhookInput!): Webhook!
deleteWebhook(id: UUID!): Webhook!
createChatThread: AgentChatThread!
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileIds: [UUID!]): SendChatMessageResult!
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
stopAgentChatStream(threadId: UUID!): Boolean!
renameChatThread(id: UUID!, title: String!): AgentChatThread!
archiveChatThread(id: UUID!): AgentChatThread!
@@ -3326,7 +3307,7 @@ type Mutation {
impersonate(userId: UUID!, workspaceId: UUID!): Impersonate!
sendEmail(input: SendEmailInput!): SendEmailOutput!
startChannelSync(connectedAccountId: UUID!): ChannelSyncSuccess!
saveImapSmtpCaldavAccount(accountOwnerId: UUID!, handle: String!, connectionParameters: EmailAccountConnectionParameters!, id: UUID): ImapSmtpCaldavConnectionSuccess!
saveImapSmtpCaldavAccount(handle: String!, connectionParameters: EmailAccountConnectionParameters!, id: UUID): ImapSmtpCaldavConnectionSuccess!
updateLabPublicFeatureFlag(input: UpdateLabPublicFeatureFlagInput!): FeatureFlag!
createPublicDomain(domain: String!, applicationId: String): PublicDomain!
updatePublicDomain(domain: String!, applicationId: String): PublicDomain!
@@ -4228,6 +4209,11 @@ input UpdateWebhookInputUpdates {
secret: String
}
input FileAttachmentInput {
id: UUID!
filename: String!
}
input CreateSkillInput {
id: UUID
name: String!
@@ -4384,16 +4370,16 @@ input SendEmailAttachmentInput {
}
input EmailAccountConnectionParameters {
IMAP: ConnectionParameters
SMTP: ConnectionParameters
CALDAV: ConnectionParameters
IMAP: ConnectionParametersInput
SMTP: ConnectionParametersInput
CALDAV: ConnectionParametersInput
}
input ConnectionParameters {
input ConnectionParametersInput {
host: String!
port: Float!
username: String
password: String!
password: String
secure: Boolean
}
@@ -1056,6 +1056,7 @@ export interface PageLayout {
objectMetadataId?: Scalars['UUID']
tabs?: PageLayoutTab[]
defaultTabToFocusOnMobileAndSidePanelId?: Scalars['UUID']
universalIdentifier: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
deletedAt?: Scalars['DateTime']
@@ -1080,6 +1081,24 @@ export interface ApplicationConnectionProvider {
__typename: 'ApplicationConnectionProvider'
}
export interface EnterpriseLicenseInfoDTO {
isValid: Scalars['Boolean']
licensee?: Scalars['String']
expiresAt?: Scalars['DateTime']
subscriptionId?: Scalars['String']
__typename: 'EnterpriseLicenseInfoDTO'
}
export interface EnterpriseSubscriptionStatusDTO {
status: Scalars['String']
licensee?: Scalars['String']
expiresAt?: Scalars['DateTime']
cancelAt?: Scalars['DateTime']
currentPeriodEnd?: Scalars['DateTime']
isCancellationScheduled: Scalars['Boolean']
__typename: 'EnterpriseSubscriptionStatusDTO'
}
export interface Analytics {
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']
@@ -1103,24 +1122,6 @@ export interface FileWithSignedUrl {
__typename: 'FileWithSignedUrl'
}
export interface EnterpriseLicenseInfoDTO {
isValid: Scalars['Boolean']
licensee?: Scalars['String']
expiresAt?: Scalars['DateTime']
subscriptionId?: Scalars['String']
__typename: 'EnterpriseLicenseInfoDTO'
}
export interface EnterpriseSubscriptionStatusDTO {
status: Scalars['String']
licensee?: Scalars['String']
expiresAt?: Scalars['DateTime']
cancelAt?: Scalars['DateTime']
currentPeriodEnd?: Scalars['DateTime']
isCancellationScheduled: Scalars['Boolean']
__typename: 'EnterpriseSubscriptionStatusDTO'
}
export interface BillingSubscriptionSchedulePhaseItem {
price: Scalars['String']
quantity?: Scalars['Float']
@@ -2094,20 +2095,54 @@ export interface PlaceDetailsResult {
__typename: 'PlaceDetailsResult'
}
export interface ConnectionParametersOutput {
export interface PublicConnectionParametersOutput {
host: Scalars['String']
port: Scalars['Float']
username?: Scalars['String']
password: Scalars['String']
secure?: Scalars['Boolean']
__typename: 'ConnectionParametersOutput'
__typename: 'PublicConnectionParametersOutput'
}
export interface ImapSmtpCaldavConnectionParameters {
IMAP?: ConnectionParametersOutput
SMTP?: ConnectionParametersOutput
CALDAV?: ConnectionParametersOutput
__typename: 'ImapSmtpCaldavConnectionParameters'
export interface PublicImapSmtpCaldavConnectionParameters {
IMAP?: PublicConnectionParametersOutput
SMTP?: PublicConnectionParametersOutput
CALDAV?: PublicConnectionParametersOutput
__typename: 'PublicImapSmtpCaldavConnectionParameters'
}
export interface ConnectedAccountPublicDTO {
id: Scalars['UUID']
handle: Scalars['String']
provider: Scalars['String']
lastCredentialsRefreshedAt?: Scalars['DateTime']
authFailedAt?: Scalars['DateTime']
handleAliases?: Scalars['String'][]
scopes?: Scalars['String'][]
lastSignedInAt?: Scalars['DateTime']
userWorkspaceId: Scalars['UUID']
connectionProviderId?: Scalars['UUID']
applicationId?: Scalars['UUID']
name?: Scalars['String']
visibility: Scalars['String']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
connectionParameters?: PublicImapSmtpCaldavConnectionParameters
__typename: 'ConnectedAccountPublicDTO'
}
export interface ImapSmtpCaldavPublicConnectionParams {
host: Scalars['String']
port: Scalars['Float']
username?: Scalars['String']
secure?: Scalars['Boolean']
__typename: 'ImapSmtpCaldavPublicConnectionParams'
}
export interface ImapSmtpCaldavPublicConnectionParameters {
IMAP?: ImapSmtpCaldavPublicConnectionParams
SMTP?: ImapSmtpCaldavPublicConnectionParams
CALDAV?: ImapSmtpCaldavPublicConnectionParams
__typename: 'ImapSmtpCaldavPublicConnectionParameters'
}
export interface ConnectedImapSmtpCaldavAccount {
@@ -2115,7 +2150,7 @@ export interface ConnectedImapSmtpCaldavAccount {
handle: Scalars['String']
provider: Scalars['String']
userWorkspaceId: Scalars['UUID']
connectionParameters?: ImapSmtpCaldavConnectionParameters
connectionParameters?: ImapSmtpCaldavPublicConnectionParameters
__typename: 'ConnectedImapSmtpCaldavAccount'
}
@@ -2243,61 +2278,6 @@ export interface DuplicatedDashboard {
__typename: 'DuplicatedDashboard'
}
export interface ConnectedAccountDTO {
id: Scalars['UUID']
handle: Scalars['String']
provider: Scalars['String']
lastCredentialsRefreshedAt?: Scalars['DateTime']
authFailedAt?: Scalars['DateTime']
handleAliases?: Scalars['String'][]
scopes?: Scalars['String'][]
connectionParameters?: ImapSmtpCaldavConnectionParameters
lastSignedInAt?: Scalars['DateTime']
userWorkspaceId: Scalars['UUID']
connectionProviderId?: Scalars['UUID']
applicationId?: Scalars['UUID']
name?: Scalars['String']
visibility: Scalars['String']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'ConnectedAccountDTO'
}
export interface PublicConnectionParametersOutput {
host: Scalars['String']
port: Scalars['Float']
username?: Scalars['String']
secure?: Scalars['Boolean']
__typename: 'PublicConnectionParametersOutput'
}
export interface PublicImapSmtpCaldavConnectionParameters {
IMAP?: PublicConnectionParametersOutput
SMTP?: PublicConnectionParametersOutput
CALDAV?: PublicConnectionParametersOutput
__typename: 'PublicImapSmtpCaldavConnectionParameters'
}
export interface ConnectedAccountPublicDTO {
id: Scalars['UUID']
handle: Scalars['String']
provider: Scalars['String']
lastCredentialsRefreshedAt?: Scalars['DateTime']
authFailedAt?: Scalars['DateTime']
handleAliases?: Scalars['String'][]
scopes?: Scalars['String'][]
lastSignedInAt?: Scalars['DateTime']
userWorkspaceId: Scalars['UUID']
connectionProviderId?: Scalars['UUID']
applicationId?: Scalars['UUID']
name?: Scalars['String']
visibility: Scalars['String']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
connectionParameters?: PublicImapSmtpCaldavConnectionParameters
__typename: 'ConnectedAccountPublicDTO'
}
export interface SendEmailOutput {
success: Scalars['Boolean']
error?: Scalars['String']
@@ -2564,6 +2544,9 @@ export interface Webhook {
export interface Query {
navigationMenuItems: NavigationMenuItem[]
navigationMenuItem?: NavigationMenuItem
enterprisePortalSession?: Scalars['String']
enterpriseCheckoutSession?: Scalars['String']
enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTO
getViewFilterGroups: ViewFilterGroup[]
getViewFilterGroup?: ViewFilterGroup
getViewFilters: ViewFilter[]
@@ -2578,9 +2561,6 @@ export interface Query {
getViewFieldGroup?: ViewFieldGroup
apiKeys: ApiKey[]
apiKey?: ApiKey
enterprisePortalSession?: Scalars['String']
enterpriseCheckoutSession?: Scalars['String']
enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTO
billingPortalSession: BillingSession
listPlans: BillingPlan[]
getResourceCreditUsage: BillingResourceCreditUsage[]
@@ -2617,9 +2597,9 @@ export interface Query {
getViewGroup?: ViewGroup
myMessageFolders: MessageFolder[]
myMessageChannels: MessageChannel[]
myConnectedAccounts: ConnectedAccountDTO[]
myConnectedAccounts: ConnectedAccountPublicDTO[]
connectedAccountById?: ConnectedAccountPublicDTO
connectedAccounts: ConnectedAccountDTO[]
connectedAccounts: ConnectedAccountPublicDTO[]
myCalendarChannels: CalendarChannel[]
webhooks: Webhook[]
webhook?: Webhook
@@ -2679,6 +2659,8 @@ export interface Mutation {
deleteManyNavigationMenuItems: NavigationMenuItem[]
deleteNavigationMenuItem: NavigationMenuItem
uploadEmailAttachmentFile: FileWithSignedUrl
refreshEnterpriseValidityToken: Scalars['Boolean']
setEnterpriseKey: EnterpriseLicenseInfoDTO
uploadAiChatFile: FileWithSignedUrl
uploadWorkflowFile: FileWithSignedUrl
uploadWorkspaceLogo: FileWithSignedUrl
@@ -2719,8 +2701,6 @@ export interface Mutation {
assignRoleToApiKey: Scalars['Boolean']
createObjectEvent: Analytics
trackAnalytics: Analytics
refreshEnterpriseValidityToken: Scalars['Boolean']
setEnterpriseKey: EnterpriseLicenseInfoDTO
skipSyncEmailOnboardingStep: OnboardingStepSuccess
skipBookOnboardingStep: OnboardingStepSuccess
checkoutSession: BillingSession
@@ -2791,7 +2771,7 @@ export interface Mutation {
updateMessageChannel: MessageChannel
createEmailGroupChannel: CreateEmailGroupChannelOutput
deleteEmailGroupChannel: MessageChannel
deleteConnectedAccount: ConnectedAccountDTO
deleteConnectedAccount: ConnectedAccountPublicDTO
updateCalendarChannel: CalendarChannel
createWebhook: Webhook
updateWebhook: Webhook
@@ -4004,6 +3984,7 @@ export interface PageLayoutGenqlSelection{
objectMetadataId?: boolean | number
tabs?: PageLayoutTabGenqlSelection
defaultTabToFocusOnMobileAndSidePanelId?: boolean | number
universalIdentifier?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
deletedAt?: boolean | number
@@ -4029,6 +4010,26 @@ export interface ApplicationConnectionProviderGenqlSelection{
__scalar?: boolean | number
}
export interface EnterpriseLicenseInfoDTOGenqlSelection{
isValid?: boolean | number
licensee?: boolean | number
expiresAt?: boolean | number
subscriptionId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EnterpriseSubscriptionStatusDTOGenqlSelection{
status?: boolean | number
licensee?: boolean | number
expiresAt?: boolean | number
cancelAt?: boolean | number
currentPeriodEnd?: boolean | number
isCancellationScheduled?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface AnalyticsGenqlSelection{
/** Boolean that confirms query was dispatched */
success?: boolean | number
@@ -4055,26 +4056,6 @@ export interface FileWithSignedUrlGenqlSelection{
__scalar?: boolean | number
}
export interface EnterpriseLicenseInfoDTOGenqlSelection{
isValid?: boolean | number
licensee?: boolean | number
expiresAt?: boolean | number
subscriptionId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EnterpriseSubscriptionStatusDTOGenqlSelection{
status?: boolean | number
licensee?: boolean | number
expiresAt?: boolean | number
cancelAt?: boolean | number
currentPeriodEnd?: boolean | number
isCancellationScheduled?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface BillingSubscriptionSchedulePhaseItemGenqlSelection{
price?: boolean | number
quantity?: boolean | number
@@ -5114,20 +5095,57 @@ export interface PlaceDetailsResultGenqlSelection{
__scalar?: boolean | number
}
export interface ConnectionParametersOutputGenqlSelection{
export interface PublicConnectionParametersOutputGenqlSelection{
host?: boolean | number
port?: boolean | number
username?: boolean | number
password?: boolean | number
secure?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ImapSmtpCaldavConnectionParametersGenqlSelection{
IMAP?: ConnectionParametersOutputGenqlSelection
SMTP?: ConnectionParametersOutputGenqlSelection
CALDAV?: ConnectionParametersOutputGenqlSelection
export interface PublicImapSmtpCaldavConnectionParametersGenqlSelection{
IMAP?: PublicConnectionParametersOutputGenqlSelection
SMTP?: PublicConnectionParametersOutputGenqlSelection
CALDAV?: PublicConnectionParametersOutputGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ConnectedAccountPublicDTOGenqlSelection{
id?: boolean | number
handle?: boolean | number
provider?: boolean | number
lastCredentialsRefreshedAt?: boolean | number
authFailedAt?: boolean | number
handleAliases?: boolean | number
scopes?: boolean | number
lastSignedInAt?: boolean | number
userWorkspaceId?: boolean | number
connectionProviderId?: boolean | number
applicationId?: boolean | number
name?: boolean | number
visibility?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
connectionParameters?: PublicImapSmtpCaldavConnectionParametersGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ImapSmtpCaldavPublicConnectionParamsGenqlSelection{
host?: boolean | number
port?: boolean | number
username?: boolean | number
secure?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ImapSmtpCaldavPublicConnectionParametersGenqlSelection{
IMAP?: ImapSmtpCaldavPublicConnectionParamsGenqlSelection
SMTP?: ImapSmtpCaldavPublicConnectionParamsGenqlSelection
CALDAV?: ImapSmtpCaldavPublicConnectionParamsGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -5137,7 +5155,7 @@ export interface ConnectedImapSmtpCaldavAccountGenqlSelection{
handle?: boolean | number
provider?: boolean | number
userWorkspaceId?: boolean | number
connectionParameters?: ImapSmtpCaldavConnectionParametersGenqlSelection
connectionParameters?: ImapSmtpCaldavPublicConnectionParametersGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -5278,65 +5296,6 @@ export interface DuplicatedDashboardGenqlSelection{
__scalar?: boolean | number
}
export interface ConnectedAccountDTOGenqlSelection{
id?: boolean | number
handle?: boolean | number
provider?: boolean | number
lastCredentialsRefreshedAt?: boolean | number
authFailedAt?: boolean | number
handleAliases?: boolean | number
scopes?: boolean | number
connectionParameters?: ImapSmtpCaldavConnectionParametersGenqlSelection
lastSignedInAt?: boolean | number
userWorkspaceId?: boolean | number
connectionProviderId?: boolean | number
applicationId?: boolean | number
name?: boolean | number
visibility?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface PublicConnectionParametersOutputGenqlSelection{
host?: boolean | number
port?: boolean | number
username?: boolean | number
secure?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface PublicImapSmtpCaldavConnectionParametersGenqlSelection{
IMAP?: PublicConnectionParametersOutputGenqlSelection
SMTP?: PublicConnectionParametersOutputGenqlSelection
CALDAV?: PublicConnectionParametersOutputGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ConnectedAccountPublicDTOGenqlSelection{
id?: boolean | number
handle?: boolean | number
provider?: boolean | number
lastCredentialsRefreshedAt?: boolean | number
authFailedAt?: boolean | number
handleAliases?: boolean | number
scopes?: boolean | number
lastSignedInAt?: boolean | number
userWorkspaceId?: boolean | number
connectionProviderId?: boolean | number
applicationId?: boolean | number
name?: boolean | number
visibility?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
connectionParameters?: PublicImapSmtpCaldavConnectionParametersGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface SendEmailOutputGenqlSelection{
success?: boolean | number
error?: boolean | number
@@ -5600,6 +5559,9 @@ export interface WebhookGenqlSelection{
export interface QueryGenqlSelection{
navigationMenuItems?: NavigationMenuItemGenqlSelection
navigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
enterprisePortalSession?: { __args: {returnUrlPath?: (Scalars['String'] | null)} } | boolean | number
enterpriseCheckoutSession?: { __args: {billingInterval?: (Scalars['String'] | null)} } | boolean | number
enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTOGenqlSelection
getViewFilterGroups?: (ViewFilterGroupGenqlSelection & { __args?: {viewId?: (Scalars['String'] | null)} })
getViewFilterGroup?: (ViewFilterGroupGenqlSelection & { __args: {id: Scalars['String']} })
getViewFilters?: (ViewFilterGenqlSelection & { __args?: {viewId?: (Scalars['String'] | null)} })
@@ -5614,9 +5576,6 @@ export interface QueryGenqlSelection{
getViewFieldGroup?: (ViewFieldGroupGenqlSelection & { __args: {id: Scalars['String']} })
apiKeys?: ApiKeyGenqlSelection
apiKey?: (ApiKeyGenqlSelection & { __args: {input: GetApiKeyInput} })
enterprisePortalSession?: { __args: {returnUrlPath?: (Scalars['String'] | null)} } | boolean | number
enterpriseCheckoutSession?: { __args: {billingInterval?: (Scalars['String'] | null)} } | boolean | number
enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTOGenqlSelection
billingPortalSession?: (BillingSessionGenqlSelection & { __args?: {returnUrlPath?: (Scalars['String'] | null)} })
listPlans?: BillingPlanGenqlSelection
getResourceCreditUsage?: BillingResourceCreditUsageGenqlSelection
@@ -5671,9 +5630,9 @@ export interface QueryGenqlSelection{
getViewGroup?: (ViewGroupGenqlSelection & { __args: {id: Scalars['String']} })
myMessageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
myConnectedAccounts?: ConnectedAccountDTOGenqlSelection
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
connectedAccountById?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
connectedAccounts?: ConnectedAccountDTOGenqlSelection
connectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
webhooks?: WebhookGenqlSelection
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
@@ -5754,6 +5713,8 @@ export interface MutationGenqlSelection{
deleteManyNavigationMenuItems?: (NavigationMenuItemGenqlSelection & { __args: {ids: Scalars['UUID'][]} })
deleteNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
uploadEmailAttachmentFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
refreshEnterpriseValidityToken?: boolean | number
setEnterpriseKey?: (EnterpriseLicenseInfoDTOGenqlSelection & { __args: {enterpriseKey: Scalars['String']} })
uploadAiChatFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
uploadWorkflowFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
uploadWorkspaceLogo?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
@@ -5794,8 +5755,6 @@ export interface MutationGenqlSelection{
assignRoleToApiKey?: { __args: {apiKeyId: Scalars['UUID'], roleId: Scalars['UUID']} }
createObjectEvent?: (AnalyticsGenqlSelection & { __args: {event: Scalars['String'], recordId: Scalars['UUID'], objectMetadataId: Scalars['UUID'], properties?: (Scalars['JSON'] | null)} })
trackAnalytics?: (AnalyticsGenqlSelection & { __args: {type: AnalyticsType, name?: (Scalars['String'] | null), event?: (Scalars['String'] | null), properties?: (Scalars['JSON'] | null)} })
refreshEnterpriseValidityToken?: boolean | number
setEnterpriseKey?: (EnterpriseLicenseInfoDTOGenqlSelection & { __args: {enterpriseKey: Scalars['String']} })
skipSyncEmailOnboardingStep?: OnboardingStepSuccessGenqlSelection
skipBookOnboardingStep?: OnboardingStepSuccessGenqlSelection
checkoutSession?: (BillingSessionGenqlSelection & { __args: {recurringInterval: SubscriptionInterval, plan: BillingPlanKey, requirePaymentMethod: Scalars['Boolean'], successUrlPath?: (Scalars['String'] | null)} })
@@ -5866,13 +5825,13 @@ export interface MutationGenqlSelection{
updateMessageChannel?: (MessageChannelGenqlSelection & { __args: {input: UpdateMessageChannelInput} })
createEmailGroupChannel?: (CreateEmailGroupChannelOutputGenqlSelection & { __args: {input: CreateEmailGroupChannelInput} })
deleteEmailGroupChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
deleteConnectedAccount?: (ConnectedAccountDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} })
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
deleteWebhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
createChatThread?: AgentChatThreadGenqlSelection
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileIds?: (Scalars['UUID'][] | null)} })
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} })
stopAgentChatStream?: { __args: {threadId: Scalars['UUID']} }
renameChatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID'], title: Scalars['String']} })
archiveChatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} })
@@ -5935,7 +5894,7 @@ export interface MutationGenqlSelection{
impersonate?: (ImpersonateGenqlSelection & { __args: {userId: Scalars['UUID'], workspaceId: Scalars['UUID']} })
sendEmail?: (SendEmailOutputGenqlSelection & { __args: {input: SendEmailInput} })
startChannelSync?: (ChannelSyncSuccessGenqlSelection & { __args: {connectedAccountId: Scalars['UUID']} })
saveImapSmtpCaldavAccount?: (ImapSmtpCaldavConnectionSuccessGenqlSelection & { __args: {accountOwnerId: Scalars['UUID'], handle: Scalars['String'], connectionParameters: EmailAccountConnectionParameters, id?: (Scalars['UUID'] | null)} })
saveImapSmtpCaldavAccount?: (ImapSmtpCaldavConnectionSuccessGenqlSelection & { __args: {handle: Scalars['String'], connectionParameters: EmailAccountConnectionParameters, id?: (Scalars['UUID'] | null)} })
updateLabPublicFeatureFlag?: (FeatureFlagGenqlSelection & { __args: {input: UpdateLabPublicFeatureFlagInput} })
createPublicDomain?: (PublicDomainGenqlSelection & { __args: {domain: Scalars['String'], applicationId?: (Scalars['String'] | null)} })
updatePublicDomain?: (PublicDomainGenqlSelection & { __args: {domain: Scalars['String'], applicationId?: (Scalars['String'] | null)} })
@@ -6259,6 +6218,8 @@ update: UpdateWebhookInputUpdates}
export interface UpdateWebhookInputUpdates {targetUrl?: (Scalars['String'] | null),operations?: (Scalars['String'][] | null),description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
export interface FileAttachmentInput {id: Scalars['UUID'],filename: Scalars['String']}
export interface CreateSkillInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content: Scalars['String']}
export interface UpdateSkillInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null)}
@@ -6299,9 +6260,9 @@ export interface SendEmailInput {connectedAccountId: Scalars['String'],to: Scala
export interface SendEmailAttachmentInput {id: Scalars['String'],name: Scalars['String']}
export interface EmailAccountConnectionParameters {IMAP?: (ConnectionParameters | null),SMTP?: (ConnectionParameters | null),CALDAV?: (ConnectionParameters | null)}
export interface EmailAccountConnectionParameters {IMAP?: (ConnectionParametersInput | null),SMTP?: (ConnectionParametersInput | null),CALDAV?: (ConnectionParametersInput | null)}
export interface ConnectionParameters {host: Scalars['String'],port: Scalars['Float'],username?: (Scalars['String'] | null),password: Scalars['String'],secure?: (Scalars['Boolean'] | null)}
export interface ConnectionParametersInput {host: Scalars['String'],port: Scalars['Float'],username?: (Scalars['String'] | null),password?: (Scalars['String'] | null),secure?: (Scalars['Boolean'] | null)}
export interface UpdateLabPublicFeatureFlagInput {publicFeatureFlag: Scalars['String'],value: Scalars['Boolean']}
@@ -6954,6 +6915,22 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const EnterpriseLicenseInfoDTO_possibleTypes: string[] = ['EnterpriseLicenseInfoDTO']
export const isEnterpriseLicenseInfoDTO = (obj?: { __typename?: any } | null): obj is EnterpriseLicenseInfoDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseLicenseInfoDTO"')
return EnterpriseLicenseInfoDTO_possibleTypes.includes(obj.__typename)
}
const EnterpriseSubscriptionStatusDTO_possibleTypes: string[] = ['EnterpriseSubscriptionStatusDTO']
export const isEnterpriseSubscriptionStatusDTO = (obj?: { __typename?: any } | null): obj is EnterpriseSubscriptionStatusDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseSubscriptionStatusDTO"')
return EnterpriseSubscriptionStatusDTO_possibleTypes.includes(obj.__typename)
}
const Analytics_possibleTypes: string[] = ['Analytics']
export const isAnalytics = (obj?: { __typename?: any } | null): obj is Analytics => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAnalytics"')
@@ -6978,22 +6955,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const EnterpriseLicenseInfoDTO_possibleTypes: string[] = ['EnterpriseLicenseInfoDTO']
export const isEnterpriseLicenseInfoDTO = (obj?: { __typename?: any } | null): obj is EnterpriseLicenseInfoDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseLicenseInfoDTO"')
return EnterpriseLicenseInfoDTO_possibleTypes.includes(obj.__typename)
}
const EnterpriseSubscriptionStatusDTO_possibleTypes: string[] = ['EnterpriseSubscriptionStatusDTO']
export const isEnterpriseSubscriptionStatusDTO = (obj?: { __typename?: any } | null): obj is EnterpriseSubscriptionStatusDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseSubscriptionStatusDTO"')
return EnterpriseSubscriptionStatusDTO_possibleTypes.includes(obj.__typename)
}
const BillingSubscriptionSchedulePhaseItem_possibleTypes: string[] = ['BillingSubscriptionSchedulePhaseItem']
export const isBillingSubscriptionSchedulePhaseItem = (obj?: { __typename?: any } | null): obj is BillingSubscriptionSchedulePhaseItem => {
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingSubscriptionSchedulePhaseItem"')
@@ -7922,18 +7883,42 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const ConnectionParametersOutput_possibleTypes: string[] = ['ConnectionParametersOutput']
export const isConnectionParametersOutput = (obj?: { __typename?: any } | null): obj is ConnectionParametersOutput => {
if (!obj?.__typename) throw new Error('__typename is missing in "isConnectionParametersOutput"')
return ConnectionParametersOutput_possibleTypes.includes(obj.__typename)
const PublicConnectionParametersOutput_possibleTypes: string[] = ['PublicConnectionParametersOutput']
export const isPublicConnectionParametersOutput = (obj?: { __typename?: any } | null): obj is PublicConnectionParametersOutput => {
if (!obj?.__typename) throw new Error('__typename is missing in "isPublicConnectionParametersOutput"')
return PublicConnectionParametersOutput_possibleTypes.includes(obj.__typename)
}
const ImapSmtpCaldavConnectionParameters_possibleTypes: string[] = ['ImapSmtpCaldavConnectionParameters']
export const isImapSmtpCaldavConnectionParameters = (obj?: { __typename?: any } | null): obj is ImapSmtpCaldavConnectionParameters => {
if (!obj?.__typename) throw new Error('__typename is missing in "isImapSmtpCaldavConnectionParameters"')
return ImapSmtpCaldavConnectionParameters_possibleTypes.includes(obj.__typename)
const PublicImapSmtpCaldavConnectionParameters_possibleTypes: string[] = ['PublicImapSmtpCaldavConnectionParameters']
export const isPublicImapSmtpCaldavConnectionParameters = (obj?: { __typename?: any } | null): obj is PublicImapSmtpCaldavConnectionParameters => {
if (!obj?.__typename) throw new Error('__typename is missing in "isPublicImapSmtpCaldavConnectionParameters"')
return PublicImapSmtpCaldavConnectionParameters_possibleTypes.includes(obj.__typename)
}
const ConnectedAccountPublicDTO_possibleTypes: string[] = ['ConnectedAccountPublicDTO']
export const isConnectedAccountPublicDTO = (obj?: { __typename?: any } | null): obj is ConnectedAccountPublicDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isConnectedAccountPublicDTO"')
return ConnectedAccountPublicDTO_possibleTypes.includes(obj.__typename)
}
const ImapSmtpCaldavPublicConnectionParams_possibleTypes: string[] = ['ImapSmtpCaldavPublicConnectionParams']
export const isImapSmtpCaldavPublicConnectionParams = (obj?: { __typename?: any } | null): obj is ImapSmtpCaldavPublicConnectionParams => {
if (!obj?.__typename) throw new Error('__typename is missing in "isImapSmtpCaldavPublicConnectionParams"')
return ImapSmtpCaldavPublicConnectionParams_possibleTypes.includes(obj.__typename)
}
const ImapSmtpCaldavPublicConnectionParameters_possibleTypes: string[] = ['ImapSmtpCaldavPublicConnectionParameters']
export const isImapSmtpCaldavPublicConnectionParameters = (obj?: { __typename?: any } | null): obj is ImapSmtpCaldavPublicConnectionParameters => {
if (!obj?.__typename) throw new Error('__typename is missing in "isImapSmtpCaldavPublicConnectionParameters"')
return ImapSmtpCaldavPublicConnectionParameters_possibleTypes.includes(obj.__typename)
}
@@ -8042,38 +8027,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const ConnectedAccountDTO_possibleTypes: string[] = ['ConnectedAccountDTO']
export const isConnectedAccountDTO = (obj?: { __typename?: any } | null): obj is ConnectedAccountDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isConnectedAccountDTO"')
return ConnectedAccountDTO_possibleTypes.includes(obj.__typename)
}
const PublicConnectionParametersOutput_possibleTypes: string[] = ['PublicConnectionParametersOutput']
export const isPublicConnectionParametersOutput = (obj?: { __typename?: any } | null): obj is PublicConnectionParametersOutput => {
if (!obj?.__typename) throw new Error('__typename is missing in "isPublicConnectionParametersOutput"')
return PublicConnectionParametersOutput_possibleTypes.includes(obj.__typename)
}
const PublicImapSmtpCaldavConnectionParameters_possibleTypes: string[] = ['PublicImapSmtpCaldavConnectionParameters']
export const isPublicImapSmtpCaldavConnectionParameters = (obj?: { __typename?: any } | null): obj is PublicImapSmtpCaldavConnectionParameters => {
if (!obj?.__typename) throw new Error('__typename is missing in "isPublicImapSmtpCaldavConnectionParameters"')
return PublicImapSmtpCaldavConnectionParameters_possibleTypes.includes(obj.__typename)
}
const ConnectedAccountPublicDTO_possibleTypes: string[] = ['ConnectedAccountPublicDTO']
export const isConnectedAccountPublicDTO = (obj?: { __typename?: any } | null): obj is ConnectedAccountPublicDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isConnectedAccountPublicDTO"')
return ConnectedAccountPublicDTO_possibleTypes.includes(obj.__typename)
}
const SendEmailOutput_possibleTypes: string[] = ['SendEmailOutput']
export const isSendEmailOutput = (obj?: { __typename?: any } | null): obj is SendEmailOutput => {
if (!obj?.__typename) throw new Error('__typename is missing in "isSendEmailOutput"')
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -9,7 +9,9 @@ TAG=latest
SERVER_URL=http://localhost:3000
# Use openssl rand -base64 32 for each secret
# APP_SECRET=replace_me_with_a_random_string
# ENCRYPTION_KEY=replace_me_with_a_random_string
# FALLBACK_ENCRYPTION_KEY= # set to the previous ENCRYPTION_KEY during a rotation
# APP_SECRET= # legacy: only required for instances that pre-date ENCRYPTION_KEY
STORAGE_TYPE=local
-6
View File
@@ -26,12 +26,6 @@ prod-run:
prod-postgres-run:
@docker run -d -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres --name twenty-postgres twenty-postgres:$(TAG)
prod-website-new-build:
@cd ../.. && docker build -f ./packages/twenty-docker/twenty-website-new/Dockerfile --platform $(PLATFORM) --tag twenty-website-new:$(TAG) . && cd -
prod-website-new-run:
@docker run -d -p 3000:3000 --name twenty-website-new twenty-website-new:$(TAG)
# =============================================================================
# Local Development Services
# Run these from the repository root: make -C packages/twenty-docker <target>
+6 -2
View File
@@ -20,7 +20,9 @@ services:
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
APP_SECRET: ${APP_SECRET:-replace_me_with_a_random_string}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
FALLBACK_ENCRYPTION_KEY: ${FALLBACK_ENCRYPTION_KEY}
APP_SECRET: ${APP_SECRET:-}
# MESSAGING_PROVIDER_GMAIL_ENABLED: ${MESSAGING_PROVIDER_GMAIL_ENABLED}
# CALENDAR_PROVIDER_GOOGLE_ENABLED: ${CALENDAR_PROVIDER_GOOGLE_ENABLED}
# AUTH_GOOGLE_CLIENT_ID: ${AUTH_GOOGLE_CLIENT_ID}
@@ -73,7 +75,9 @@ services:
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
APP_SECRET: ${APP_SECRET:-replace_me_with_a_random_string}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
FALLBACK_ENCRYPTION_KEY: ${FALLBACK_ENCRYPTION_KEY}
APP_SECRET: ${APP_SECRET:-}
# MESSAGING_PROVIDER_GMAIL_ENABLED: ${MESSAGING_PROVIDER_GMAIL_ENABLED}
# CALENDAR_PROVIDER_GOOGLE_ENABLED: ${CALENDAR_PROVIDER_GOOGLE_ENABLED}
# AUTH_GOOGLE_CLIENT_ID: ${AUTH_GOOGLE_CLIENT_ID}
+1 -1
View File
@@ -91,7 +91,7 @@ fi
# Generate random strings for secrets
echo "# === Randomly generated secret ===" >> .env
echo "APP_SECRET=$(openssl rand -base64 32)" >> .env
echo "ENCRYPTION_KEY=$(openssl rand -base64 32)" >> .env
echo "" >> .env
echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 32)" >> .env
@@ -1,39 +0,0 @@
FROM node:24.15.0-alpine3.23@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS twenty-website-new-build
WORKDIR /app
COPY ./package.json .
COPY ./yarn.lock .
COPY ./.yarnrc.yml .
COPY ./tsconfig.base.json .
COPY ./nx.json .
COPY ./.yarn/releases /app/.yarn/releases
COPY ./.yarn/patches /app/.yarn/patches
COPY ./packages/twenty-oxlint-rules /app/packages/twenty-oxlint-rules
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/package.json
COPY ./packages/twenty-website-new/package.json /app/packages/twenty-website-new/package.json
RUN yarn
COPY ./packages/twenty-shared /app/packages/twenty-shared
COPY ./packages/twenty-website-new /app/packages/twenty-website-new
RUN npx nx build twenty-website-new
FROM node:24.15.0-alpine3.23@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS twenty-website-new
WORKDIR /app/packages/twenty-website-new
COPY --from=twenty-website-new-build /app /app
WORKDIR /app/packages/twenty-website-new
LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty
LABEL org.opencontainers.image.description="This image provides a consistent and reproducible environment for the new marketing website."
RUN chown -R 1000 /app
# Use non root user with uid 1000
USER 1000
CMD ["/bin/sh", "-c", "npx nx start twenty-website-new"]
@@ -22,7 +22,8 @@ Use the `getPublicAssetUrl` helper from `twenty-sdk` to get the full URL of a fi
**In a logic function:**
```ts src/logic-functions/send-invoice.ts
import { defineLogicFunction, getPublicAssetUrl } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const handler = async (): Promise<any> => {
const logoUrl = getPublicAssetUrl('logo.png');
@@ -47,12 +48,19 @@ export default defineLogicFunction({
**In a front component:**
```tsx src/front-components/company-card.tsx
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk/define';
import { defineFrontComponent } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
export default defineFrontComponent(() => {
const CompanyCard = () => {
const logoUrl = getPublicAssetUrl('logo.png');
return <img src={logoUrl} alt="App logo" />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'company-card',
component: CompanyCard,
});
```
@@ -138,20 +138,6 @@ Both modes need an authenticated remote.
| `--debounceMs <ms>` | Set the file-change debounce delay in milliseconds (default: `2000`). |
| `--verbose` / `--debug` | Show detailed build logs, sync requests, and error traces. |
---
## Starting from an example
Use `--example` to start with a more complete project (custom objects, fields, logic functions, front components):
```bash filename="Terminal"
npx create-twenty-app@latest my-twenty-app --example postcard
```
Examples live in [twenty-apps/examples](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples). You can also scaffold individual entities into an existing project with `yarn twenty add` — see [Scaffolding](/developers/extend/apps/getting-started/scaffolding).
---
## What you can build
Apps are composed of **entities** — each defined as a TypeScript file with a single `export default`:
@@ -390,7 +390,8 @@ export default defineFrontComponent({
Front components can access files from the app's `public/` directory using `getPublicAssetUrl`:
```tsx
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk/define';
import { defineFrontComponent } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const Logo = () => <img src={getPublicAssetUrl('logo.png')} alt="Logo" />;
@@ -47,22 +47,25 @@ Follow these steps for a manual setup.
curl -o .env https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-docker/.env.example
```
2. **Generate Secret Tokens**
2. **Generate an Encryption Key**
Run the following command to generate a unique random string:
```bash
openssl rand -base64 32
```
**Important:** Keep this value secret / do not share it.
**Important:** Keep this value secret / do not share it. Losing `ENCRYPTION_KEY` means losing access to every secret stored in the database (OAuth tokens, application variables, TOTP secrets, etc.).
3. **Update the `.env`**
Replace the placeholder value in your .env file with the generated token:
```ini
APP_SECRET=first_random_string
ENCRYPTION_KEY=random_string
```
See the [Key rotation guide](/developers/self-host/capabilities/key-rotation) for instructions on rotating it without downtime.
4. **Set the Postgres Password**
Update the `PG_DATABASE_PASSWORD` value in the .env file with a strong password without special characters.
@@ -0,0 +1,57 @@
---
title: Key rotation
icon: "rotate"
---
Twenty has two independent key families:
- **JWT signing keys** — asymmetric ES256 keypairs (`kid`-tagged) stored in `core."signingKey"`, used to sign and verify access / refresh tokens.
- **At-rest encryption key** — `ENCRYPTION_KEY`, used to encrypt OAuth tokens, application variables, signing-key private keys, sensitive config values and TOTP secrets inside an `enc:v2:` envelope.
`APP_SECRET` is a legacy secret kept for backward compatibility: when `ENCRYPTION_KEY` is unset it acts as the at-rest encryption / session cookie fallback, and it still verifies pre-existing HS256 access tokens. It will be deprecated.
## JWT signing keys
Each key carries a `publicKey` (kept indefinitely so it can verify previously issued tokens), an encrypted `privateKey` (used only while the key is current), an `isCurrent` flag (exactly one row at a time) and an optional `revokedAt`.
### Rotate the current key
- **Manual** — **Settings → Admin Panel → Signing keys → Revoke** on the current row. Revoking wipes its encrypted private material and demotes it; the next sign call automatically mints a fresh ES256 keypair as the new current. Tokens signed under any other (non-revoked) `kid` keep verifying until they expire.
- **Enterprise (automatic)** — a daily cron (`'15 3 * * *'` UTC) issues a new current key once the existing one has been current for `SIGNING_KEY_ROTATION_DAYS` (default `90`). The previous key is *not* revoked, so tokens signed under it keep verifying. Register it once with `yarn command:prod cron:register:all`.
<Note>The Enterprise cron and `SIGNING_KEY_ROTATION_DAYS` ship in v2.6+.</Note>
### Revoke a key (leak / emergency only)
**Settings → Admin Panel → Signing keys → Revoke** on a non-current row. Wipes the encrypted private material, sets `revokedAt`, and rejects every existing token signed under that `kid`.
## Rotate `ENCRYPTION_KEY`
<Note>The `secret-encryption:rotate` command described below ships in v2.6+.</Note>
Every encrypted value is wrapped as `enc:v2:<keyId>:<payload>`, where `<keyId>` is an 8-hex prefix derived from the raw key. Rotation is online and resumable.
1. **Generate a new key**: `openssl rand -base64 32`.
2. **Configure both keys side-by-side** in `.env`, then restart:
```ini
ENCRYPTION_KEY=NEW_VALUE
FALLBACK_ENCRYPTION_KEY=OLD_VALUE
```
New writes use the new key, existing rows still decrypt via the fallback.
3. **Re-encrypt existing rows**:
```bash
docker exec -it {server_container} yarn command:prod secret-encryption:rotate
```
The command walks six sites (`connected-account-tokens`, `application-variable`, `application-registration-variable`, `signing-key-private-keys`, `sensitive-config-storage`, `totp-secrets`). A SQL filter skips rows already on the new `<keyId>`, so the command is idempotent: interrupt and re-run as needed. Exits non-zero if any row fails — re-run to retry.
| Flag | Description |
| --- | --- |
| `-s, --site <site>` | Limit to a single site. |
| `-b, --batch-size <n>` | Rows per batch (default `200`, max `5000`). |
| `-d, --dry-run` | Decrypt + re-encrypt in memory, skip the `UPDATE`. |
4. **Drop the fallback** once `--dry-run` shows zero remaining rows: remove `FALLBACK_ENCRYPTION_KEY` and restart.
## Legacy `APP_SECRET` support
Older instances that never set `ENCRYPTION_KEY` use `APP_SECRET` as the at-rest encryption key (and as the session-cookie secret, derived from it). This path is preserved for backward compatibility but is **deprecated** — set a dedicated `ENCRYPTION_KEY` and follow the rotation procedure above to migrate off it. `APP_SECRET` itself stays in use to verify legacy HS256 access tokens.
@@ -42,11 +42,26 @@ IS_CONFIG_VARIABLES_IN_DB_ENABLED=true # default
<Warning>
Each variable is documented with descriptions in your admin panel at **Settings → Admin Panel → Configuration Variables**.
Some infrastructure settings like database connections (`PG_DATABASE_URL`), server URLs (`SERVER_URL`), and app secrets (`APP_SECRET`) can only be configured via `.env` file.
Some infrastructure settings like database connections (`PG_DATABASE_URL`), server URLs (`SERVER_URL`), and secrets (`ENCRYPTION_KEY`, `FALLBACK_ENCRYPTION_KEY`) can only be configured via `.env` file.
[Complete technical reference →](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts)
</Warning>
## Encryption keys
Twenty uses two env-only encryption keys:
| Variable | Purpose | Required |
| --- | --- | --- |
| `ENCRYPTION_KEY` | Primary key used to encrypt secrets at rest (OAuth tokens, application variables, signing-key private keys, TOTP secrets, sensitive config values). | Yes for new installs (legacy installs may instead rely on `APP_SECRET` — see below) |
| `FALLBACK_ENCRYPTION_KEY` | Verification-only key. Set during a rotation to the *previous* `ENCRYPTION_KEY` so existing rows remain decryptable. | Only during rotation |
For backward compatibility, if `ENCRYPTION_KEY` is unset, Twenty falls back to `APP_SECRET` for at-rest encryption — matching the legacy behaviour for older deployments. New installs should always set a dedicated `ENCRYPTION_KEY`.
Generate values with `openssl rand -base64 32` and store them somewhere safe (a secrets manager, sealed config, etc.). Losing `ENCRYPTION_KEY` means losing access to every secret stored in the database.
To rotate `ENCRYPTION_KEY` without downtime, see the [Key rotation guide](/developers/self-host/capabilities/key-rotation).
## 2. Environment-Only Configuration
```bash
@@ -31,6 +31,18 @@ Starting from **v1.22**, Twenty supports cross-version upgrades. You can jump di
For example, upgrading from v1.22 straight to v2.0 is fully supported.
## Upgrading to v2.5+ — at-rest encryption envelope
Starting in **v2.5**, Twenty stores at-rest secrets (OAuth tokens, application variables, signing-key private keys, sensitive config values, TOTP secrets) inside a versioned `enc:v2:` envelope encrypted with `ENCRYPTION_KEY` (or `APP_SECRET` if `ENCRYPTION_KEY` is unset).
The first boot on v2.5 runs slow upgrade commands that **backfill** existing rows into the new envelope. They are idempotent — interrupting and restarting the server resumes from where it left off — but they can take a while on large databases. You can monitor progress with `upgrade:status`.
You should set a dedicated `ENCRYPTION_KEY` **before** the v2.5 upgrade so the backfill writes rows under it from the start. Switching keys after the backfill requires a [rotation](/developers/self-host/capabilities/key-rotation).
## Rotating secrets and signing keys
For day-to-day operational tasks like rotating `ENCRYPTION_KEY`, rotating the JWT signing key, or revoking a leaked signing key, see the dedicated [Key rotation guide](/developers/self-host/capabilities/key-rotation).
## Checking upgrade status
The `upgrade:status` command lets you inspect the current state of your instance and workspace migrations. It is useful for debugging upgrade issues or when filing a support request.
@@ -22,7 +22,8 @@ Verwenden Sie den Helper `getPublicAssetUrl` aus `twenty-sdk`, um die vollständ
**In einer Logikfunktion:**
```ts src/logic-functions/send-invoice.ts
import { defineLogicFunction, getPublicAssetUrl } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const handler = async (): Promise<any> => {
const logoUrl = getPublicAssetUrl('logo.png');
@@ -47,12 +48,19 @@ export default defineLogicFunction({
**In einer Frontend-Komponente:**
```tsx src/front-components/company-card.tsx
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk/define';
import { defineFrontComponent } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
export default defineFrontComponent(() => {
const CompanyCard = () => {
const logoUrl = getPublicAssetUrl('logo.png');
return <img src={logoUrl} alt="App logo" />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'company-card',
component: CompanyCard,
});
```
@@ -40,7 +40,8 @@ export default defineRole({
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name
.universalIdentifier,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
@@ -138,20 +138,6 @@ Beide Modi benötigen ein authentifiziertes Remote-Repository.
| `--debounceMs \<ms>` | Legt die Entprellzeit für Dateiänderungen in Millisekunden fest (Standard: `2000`). |
| `--verbose` / `--debug` | Zeigt ausführliche Build-Protokolle, Sync-Anfragen und Fehler-Traces an. |
---
## Mit einem Beispiel beginnen
Verwenden Sie `--example`, um mit einem vollständigeren Projekt zu starten (benutzerdefinierte Objekte, Felder, Logikfunktionen, Frontend-Komponenten):
```bash filename="Terminal"
npx create-twenty-app@latest my-twenty-app --example postcard
```
Die Beispiele befinden sich unter [twenty-apps/examples](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples). Sie können auch einzelne Entitäten in einem bestehenden Projekt mit `yarn twenty add` erzeugen — siehe [Scaffolding](/l/de/developers/extend/apps/getting-started/scaffolding).
---
## Was Sie erstellen können
Apps bestehen aus **Entitäten** — jede ist als TypeScript-Datei mit einem einzigen `export default` definiert:
@@ -390,7 +390,8 @@ export default defineFrontComponent({
Front-Komponenten können mit `getPublicAssetUrl` auf Dateien aus dem `public/`-Verzeichnis der App zugreifen:
```tsx
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk/define';
import { defineFrontComponent } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const Logo = () => <img src={getPublicAssetUrl('logo.png')} alt="Logo" />;
@@ -51,7 +51,7 @@ Befolgen Sie diese Schritte für eine manuelle Einrichtung.
curl -o .env https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-docker/.env.example
```
2. **Erstellen Sie geheime Tokens**
2. **Einen Verschlüsselungsschlüssel generieren**
Führen Sie den folgenden Befehl aus, um eine eindeutige Zufallszeichenfolge zu generieren:
@@ -59,16 +59,18 @@ Befolgen Sie diese Schritte für eine manuelle Einrichtung.
openssl rand -base64 32
```
**Wichtig:** Bewahren Sie diesen Wert geheim auf / teilen Sie ihn nicht.
**Wichtig:** Bewahren Sie diesen Wert geheim auf / teilen Sie ihn nicht. Der Verlust von `ENCRYPTION_KEY` bedeutet den Verlust des Zugriffs auf alle in der Datenbank gespeicherten Secrets (OAuth-Tokens, Anwendungsvariablen, TOTP-Secrets usw.).
3. **Aktualisieren Sie die `.env`**
Ersetzen Sie den Platzhalterwert in Ihrer .env-Datei durch das generierte Token:
```ini
APP_SECRET=erster_zufälliger_string
ENCRYPTION_KEY=random_string
```
Weitere Informationen findest du im [Leitfaden zur Schlüsselrotation](/l/de/developers/self-host/capabilities/key-rotation) mit Anweisungen zur Rotation ohne Ausfallzeit.
4. **Setzen Sie das Postgres-Passwort**
Aktualisieren Sie den Wert `PG_DATABASE_PASSWORD` in der .env-Datei mit einem starken Passwort ohne Sonderzeichen.
@@ -0,0 +1,61 @@
---
title: Schlüsselrotation
icon: rotate
---
Twenty hat zwei unabhängige Schlüsselfamilien:
* **JWT-Signaturschlüssel** — asymmetrische ES256-Schlüsselpaare (`kid`-markiert), die in `core."signingKey"` gespeichert sind und zum Signieren und Verifizieren von Access-/Refresh-Tokens verwendet werden.
* **Verschlüsselungsschlüssel im Ruhezustand** — `ENCRYPTION_KEY`, wird verwendet, um OAuth-Tokens, Anwendungsvariablen, die privaten Schlüssel der Signierschlüssel, sensible Konfigurationswerte und TOTP-Geheimnisse innerhalb eines `enc:v2:`-Umschlags zu verschlüsseln.
`APP_SECRET` ist ein veraltetes Secret, das aus Gründen der Abwärtskompatibilität beibehalten wird: Wenn `ENCRYPTION_KEY` nicht gesetzt ist, fungiert es als Fallback für At-Rest-Verschlüsselung / Session-Cookie und verifiziert weiterhin bereits bestehende HS256-Access-Tokens. Es wird als veraltet markiert werden.
## JWT-Signierschlüssel
Jeder Schlüssel enthält einen `publicKey` (unbefristet aufbewahrt, damit er zuvor ausgestellte Tokens verifizieren kann), einen verschlüsselten `privateKey` (nur verwendet, solange der Schlüssel aktuell ist), ein `isCurrent`-Flag (zu jedem Zeitpunkt genau eine Zeile) und ein optionales `revokedAt`.
### Aktuellen Schlüssel rotieren
* **Manuell** — **Settings → Admin Panel → Signing keys → Revoke** in der aktuellen Zeile. Das Widerrufen löscht das verschlüsselte private Material und stuft es herab; der nächste Signieraufruf erstellt automatisch ein neues ES256-Schlüsselpaar als neuen aktuellen Schlüssel. Tokens, die unter einem anderen (nicht widerrufenen) `kid` signiert wurden, werden weiterhin verifiziert, bis sie ablaufen.
* **Enterprise (automatisch)** — ein täglicher Cron-Job (`'15 3 * * *'` UTC) erstellt einen neuen aktuellen Schlüssel, sobald der vorhandene Schlüssel seit `SIGNING_KEY_ROTATION_DAYS` (Standard `90`) aktuell ist. Der vorherige Schlüssel wird *nicht* widerrufen, daher werden Tokens, die darunter signiert wurden, weiterhin verifiziert. Registriere ihn einmalig mit `yarn command:prod cron:register:all`.
<Note>Der Enterprise-Cron-Job und `SIGNING_KEY_ROTATION_DAYS` sind ab v2.6+ enthalten.</Note>
### Einen Schlüssel widerrufen (nur bei Leak / Notfall)
**Settings → Admin Panel → Signing keys → Revoke** in einer nicht aktuellen Zeile. Löscht das verschlüsselte private Material, setzt `revokedAt` und weist jedes vorhandene Token zurück, das unter diesem `kid` signiert wurde.
## `ENCRYPTION_KEY` rotieren
<Note>Der unten beschriebene Befehl `secret-encryption:rotate` ist ab v2.6+ enthalten.</Note>
Jeder verschlüsselte Wert wird als `enc:v2:\<keyId>:\<payload>` verpackt, wobei `\<keyId>` ein 8-stelliges Hex-Präfix ist, das aus dem Rohschlüssel abgeleitet wird. Die Rotation erfolgt online und ist fortsetzbar.
1. **Einen neuen Schlüssel generieren**: `openssl rand -base64 32`.
2. **Beide Schlüssel nebeneinander konfigurieren** in `.env` und dann neu starten:
```ini
ENCRYPTION_KEY=NEW_VALUE
FALLBACK_ENCRYPTION_KEY=OLD_VALUE
```
Neue Schreibvorgänge verwenden den neuen Schlüssel, vorhandene Zeilen werden weiterhin über den Fallback entschlüsselt.
3. **Vorhandene Zeilen erneut verschlüsseln**:
```bash
docker exec -it {server_container} yarn command:prod secret-encryption:rotate
```
Der Befehl durchläuft sechs Sites (`connected-account-tokens`, `application-variable`, `application-registration-variable`, `signing-key-private-keys`, `sensitive-config-storage`, `totp-secrets`). Ein SQL-Filter überspringt Zeilen, die bereits auf dem neuen `\<keyId>` sind, sodass der Befehl idempotent ist: Unterbrechen und bei Bedarf erneut ausführen. Beendet sich mit einem von Null verschiedenen Exit-Code, wenn irgendeine Zeile fehlschlägt — zum Wiederholen erneut ausführen.
| Flag | Beschreibung |
| ---------------------------------------- | ---------------------------------------------------------------------------- |
| `-s, --site \<site>` | Auf eine einzelne Site beschränken. |
| `-b, --batch-size \<n>` | Zeilen pro Batch (Standard `200`, Maximum `5000`). |
| `-d, --dry-run` | Entschlüsseln + erneut im Speicher verschlüsseln, das `UPDATE` überspringen. |
4. **Den Fallback entfernen**, sobald `--dry-run` null verbleibende Zeilen anzeigt: `FALLBACK_ENCRYPTION_KEY` entfernen und neu starten.
## Legacy-Unterstützung für `APP_SECRET`
Ältere Instanzen, die niemals `ENCRYPTION_KEY` gesetzt haben, verwenden `APP_SECRET` als At-Rest-Verschlüsselungsschlüssel (und als Session-Cookie-Secret, das daraus abgeleitet wird). Dieser Pfad wird aus Gründen der Abwärtskompatibilität beibehalten, ist aber **veraltet** — setze einen dedizierten `ENCRYPTION_KEY` und folge dem oben beschriebenen Rotationsverfahren, um davon zu migrieren. `APP_SECRET` selbst bleibt in Verwendung, um Legacy-HS256-Access-Tokens zu verifizieren.
@@ -43,11 +43,26 @@ IS_CONFIG_VARIABLES_IN_DB_ENABLED=true # Standard
<Warning>
Jede Variable ist mit Beschreibungen in Ihrem Admin-Panel dokumentiert unter **Einstellungen → Admin-Panel → Konfigurationsvariablen**.
Einige Infrastruktureinstellungen wie Datenbankverbindungen (`PG_DATABASE_URL`), Server-URLs (`SERVER_URL`) und Anwendungsgeheimnisse (`APP_SECRET`) können nur über die `.env`-Datei konfiguriert werden.
Einige Infrastruktureinstellungen wie Datenbankverbindungen (`PG_DATABASE_URL`), Server-URLs (`SERVER_URL`) und Secrets (`ENCRYPTION_KEY`, `FALLBACK_ENCRYPTION_KEY`) können nur über die `.env`-Datei konfiguriert werden.
[Vollständige technische Referenz →](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts)
</Warning>
## Verschlüsselungsschlüssel
Twenty verwendet zwei ausschließlich über Umgebungsvariablen konfigurierbare Verschlüsselungsschlüssel:
| Variable | Zweck | Erforderlich |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `ENCRYPTION_KEY` | Primärer Schlüssel, der für die Verschlüsselung von Secrets im Ruhezustand verwendet wird (OAuth-Tokens, Anwendungsvariablen, private Schlüssel von Signierschlüsseln, TOTP-Secrets, sensible Konfigurationswerte). | Ja, für neue Installationen (Legacy-Installationen können stattdessen auf `APP_SECRET` zurückgreifen siehe unten). |
| `FALLBACK_ENCRYPTION_KEY` | Nur zur Verifizierung verwendeter Schlüssel. Während einer Rotation auf den *vorherigen* `ENCRYPTION_KEY` gesetzt, damit vorhandene Zeilen weiterhin entschlüsselt werden können. | Nur während der Rotation |
Aus Gründen der Abwärtskompatibilität greift Twenty, falls `ENCRYPTION_KEY` nicht gesetzt ist, für die Verschlüsselung im Ruhezustand auf `APP_SECRET` zurück entsprechend dem Legacy-Verhalten älterer Deployments. Neue Installationen sollten immer einen eigenen `ENCRYPTION_KEY` setzen.
Werte mit `openssl rand -base64 32` generieren und sie an einem sicheren Ort aufbewahren (einen Secrets-Manager, eine versiegelte Konfiguration usw.). Der Verlust des `ENCRYPTION_KEY` bedeutet den Verlust des Zugriffs auf jedes in der Datenbank gespeicherte Secret.
Um `ENCRYPTION_KEY` ohne Downtime zu rotieren, siehe die [Anleitung zur Schlüsselrotation](/l/de/developers/self-host/capabilities/key-rotation).
## 2. Nur-Umgebungs-Konfiguration
```bash
@@ -31,6 +31,18 @@ Ab **v1.22** unterstützt Twenty versionsübergreifende Upgrades. Sie können di
Zum Beispiel wird ein Upgrade direkt von v1.22 auf v2.0 vollständig unterstützt.
## Upgrade auf v2.5+ Verschlüsselungsumschlag für ruhende Daten
Ab **v2.5** speichert Twenty Secrets im Ruhezustand (OAuth-Tokens, Anwendungsvariablen, private Signaturschlüssel, sensible Konfigurationswerte, TOTP-Secrets) in einem versionierten Umschlag `enc:v2:`, der mit `ENCRYPTION_KEY` (oder `APP_SECRET`, falls `ENCRYPTION_KEY` nicht gesetzt ist) verschlüsselt wird.
Beim ersten Start mit v2.5 werden langsame Upgrade-Befehle ausgeführt, die den neuen Umschlag mit bestehenden Zeilen befüllen. Sie sind idempotent ein Unterbrechen und Neustarten des Servers setzt an der Stelle fort, an der er aufgehört hat , aber sie können bei großen Datenbanken eine Weile dauern. Sie können den Fortschritt mit `upgrade:status` überwachen.
Sie sollten **vor** dem Upgrade auf v2.5 einen dedizierten `ENCRYPTION_KEY` setzen, damit beim Nachbefüllen die Zeilen von Anfang an unter diesem Schlüssel geschrieben werden. Ein Schlüsselwechsel nach der Nachbefüllung erfordert eine [Rotation](/l/de/developers/self-host/capabilities/key-rotation).
## Rotation von Secrets und Signaturschlüsseln
Für alltägliche operative Aufgaben wie das Rotieren von `ENCRYPTION_KEY`, das Rotieren des JWT-Signaturschlüssels oder das Widerrufen eines kompromittierten Signaturschlüssels siehe den dedizierten [Leitfaden zur Schlüsselrotation](/l/de/developers/self-host/capabilities/key-rotation).
## Upgrade-Status prüfen
Mit dem Befehl `upgrade:status` können Sie den aktuellen Status Ihrer Instanz und der Workspace-Migrationen prüfen. Er ist nützlich zum Debuggen von Upgrade-Problemen oder beim Erstellen einer Support-Anfrage.
@@ -22,7 +22,8 @@ Use o helper `getPublicAssetUrl` de `twenty-sdk` para obter a URL completa de um
**Em uma função lógica:**
```ts src/logic-functions/send-invoice.ts
import { defineLogicFunction, getPublicAssetUrl } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const handler = async (): Promise<any> => {
const logoUrl = getPublicAssetUrl('logo.png');
@@ -47,12 +48,19 @@ export default defineLogicFunction({
**Em um componente de front-end:**
```tsx src/front-components/company-card.tsx
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk/define';
import { defineFrontComponent } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
export default defineFrontComponent(() => {
const CompanyCard = () => {
const logoUrl = getPublicAssetUrl('logo.png');
return <img src={logoUrl} alt="App logo" />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'company-card',
component: CompanyCard,
});
```

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