Compare commits

...
Author SHA1 Message Date
Charles Bochet b5b9efe451 chore: remove redundant undefined from backgroundColor type
Made-with: Cursor
2026-03-24 01:13:19 +01:00
Charles BochetandGitHub 69fe6fcadd chore: add explicit return type to getBackgroundColor (#18878)
Trivial typing improvement to trigger CI Front + CI UI visual regression
pipelines.
2026-03-24 01:10:22 +01:00
Charles BochetandGitHub 0ded15b363 Add visual regression CI for twenty-ui (#18877)
## Summary

- New workflow `ci-visual-regression.yaml` that runs on PRs touching
`twenty-ui` or `twenty-shared`
- Builds `twenty-ui` storybook and uploads the tarball as a GitHub
Actions artifact
- Dispatches to `twentyhq/ci-privileged` which handles the pixel-diff
comparison and posts a PR comment

### Flow

```
twenty CI (this PR)          ci-privileged                  pixel-perfect
─────────────────          ──────────────                  ──────────────
Build storybook
Upload artifact
Dispatch ──────────────►  Download artifact
                          Upload to S3 (OIDC)
                          POST /import-from-storage ────►  Import build
                          POST /diffs/run ──────────────►  Screenshots + diff
                          ◄──────────────────────────────  Diff report JSON
                          Post PR comment
```

Companion PRs:
- https://github.com/twentyhq/ci-privileged/pull/1 (ci-privileged
workflow)
- https://github.com/twentyhq/twenty-infra/pull/497 (OIDC trust + Helm
cleanup)
- https://github.com/twentyhq/pixel-perfect/pull/5 (API simplification)

## Test plan

- [ ] Merge companion PRs first and configure secrets/environments
- [ ] Open a test PR touching twenty-ui, verify storybook builds and
dispatch fires
- [ ] Verify visual regression comment appears on the PR
2026-03-24 00:15:45 +01:00
Charles BochetandGitHub 708e53d829 Fix multi-select option removal crashing when records contain removed values (#18871)
## Summary

- Fixes a bug where removing an option from a MULTI_SELECT field fails
with `invalid input value for enum` when existing records contain the
removed value alongside surviving values.
- The root cause was the `ELSE` branch in `updateArrayEnum` which tried
to cast removed enum values (e.g. `DISTRIBUTOR`) to the new enum type
that no longer includes them.
- The fix replaces the `ELSE` cast with a NULL-producing implicit CASE
default and uses `array_agg(...) FILTER (WHERE mapped_value IS NOT
NULL)` to silently strip removed values from existing arrays.

### Before (bug)
```sql
-- ELSE branch tries to cast removed value to new enum → crash
CASE unnest_value::text
  WHEN 'IMPL' THEN 'IMPL'::new_enum
  WHEN 'APP' THEN 'APP'::new_enum
  ELSE unnest_value::text::new_enum  -- 'DISTRIBUTOR' fails here
END
```

### After (fix)
```sql
-- No ELSE: removed values produce NULL, filtered out by array_agg
SELECT array_agg(mapped_value) FILTER (WHERE mapped_value IS NOT NULL)
FROM (
  SELECT CASE unnest_value::text
    WHEN 'IMPL' THEN 'IMPL'::new_enum
    WHEN 'APP' THEN 'APP'::new_enum
  END AS mapped_value
  FROM unnest(old_column) AS unnest_value
) enum_mapping
```
2026-03-23 20:06:34 +00:00
e2c85b5af0 Fix workspace dropdown truncation and center auth titles (#18869)
# Before

<img width="543" height="315" alt="CleanShot 2026-03-23 at 18 37 17"
src="https://github.com/user-attachments/assets/3a8233f8-507d-40e3-b4f0-0aae325bc4a8"
/>


# After

<img width="230" height="167" alt="CleanShot 2026-03-23 at 18 38 06"
src="https://github.com/user-attachments/assets/dda637d8-766a-4dc4-9909-78edef866c88"
/>

+ video: (long & short title)


https://github.com/user-attachments/assets/5373d168-6ffc-495b-909c-27fc1e68e712


also fixed text not centered in onboarding

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-23 21:06:14 +01:00
Thomas TrompetteandGitHub c6d4162b73 Fix: Background color selected row + border bottom settings tab (#18870)
<img width="1285" height="73" alt="Capture d’écran 2026-03-23 à 18 58
09"
src="https://github.com/user-attachments/assets/a01cfc8e-6492-4ebe-a47a-b504a73e616c"
/>
<img width="574" height="59" alt="Capture d’écran 2026-03-23 à 18 58
39"
src="https://github.com/user-attachments/assets/4947ec02-e151-48fb-87e9-86dbc0ed4fe9"
/>
2026-03-23 19:22:28 +00:00
93de331428 i18n - translations (#18873)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-23 19:36:47 +01:00
Abdul RahmanandGitHub 24055527c8 Rename "New sidebar item" to "New menu item" (#18859) 2026-03-23 18:21:41 +00:00
EtienneandGitHub c58bf9cf06 Fix e2e test (#18866)
Three feature flags changed the UI:
IS_NAVIGATION_MENU_ITEM_ENABLED — Sidebar no longer has a default
"People" link → navigate via URL instead
IS_COMMAND_MENU_ITEM_ENABLED — Button label is now "Create new Person"
(interpolated from object name) instead of static "Create new record"
IS_JUNCTION_RELATIONS_ENABLED — Company is now a junction relation
("Previous Companies") displayed inline, no longer a boxed
dynamic-relation-widget on the record page
2026-03-23 16:56:15 +00:00
Baptiste DevessierandGitHub 07a4cf2d26 Ensure command backfills all relations as Field widget (#18858)
\+ add missing Field widget for workflow relations

Copies the logic of the injectRelationWidgetsIntoLayout front-end
function
2026-03-23 17:49:05 +01:00
Raphaël BosiandGitHub 1cfdd2e8ed Update BackfillCommandMenuItemsCommand with workflow backfill (#18848)
Updated upgrade command to also backfill command menu items for
workflows.
This has been done in the same command because we need to enable the
feature flag once both operations are complete: the creation of the
standard command menu items and the creation of workflow command menu
items.
2026-03-23 17:48:50 +01:00
Félix MalfaitGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>EtienneEtienne
6f4f7a1198 fix: add security headers to file serving endpoints to prevent stored XSS (#18857)
## Summary

- File serving endpoints (`GET file/:fileFolder/:id` and `GET
public-assets/...`) were piping S3/local file streams directly to the
response without any HTTP headers, allowing a stored XSS attack via
uploaded HTML files rendered inline on the CRM origin.
- Adds `Content-Type`, `Content-Disposition`, and
`X-Content-Type-Options: nosniff` headers to all file serving responses.
Only known-safe MIME types (images, PDF, plain text, audio, video) are
served inline; everything else (HTML, SVG, XML, etc.) forces
`Content-Disposition: attachment` to trigger download instead of
rendering.
- New `setFileResponseHeaders` utility with an explicit allowlist of
inline-safe MIME types.

## Test plan

- [x] Unit tests pass (9 tests including 2 new ones: header assertions
and attachment-disposition for HTML)
- [x] Lint clean (`lint:diff-with-main`)
- [x] Typecheck clean (`nx typecheck twenty-server`)
- [ ] Manual: upload an HTML file via `uploadWorkflowFile`, access the
returned URL — should download instead of rendering
- [ ] Manual: upload a PNG image, access the URL — should render inline
with correct `Content-Type: image/png`
- [ ] Manual: verify `X-Content-Type-Options: nosniff` header is present
on all file responses


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Etienne <etiennejouan@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-03-23 16:19:16 +01:00
Charles BochetandGitHub 0626f3e469 Fix deploy: remove stale @ai-sdk/groq from yarn.lock (#18863)
## Summary

- The front deploy to S3 is failing because `@ai-sdk/groq` was removed
from `packages/twenty-server/package.json` but the `yarn.lock` was never
updated
2026-03-23 15:39:35 +01:00
Félix MalfaitandGitHub 630f3a0fd7 chore: trigger automerge for AI catalog sync PRs (#18855)
## Summary
- Adds a `repository-dispatch` step to the AI catalog sync workflow
(`ci-ai-catalog-sync.yaml`) that notifies `twenty-infra` when a sync PR
is created
- This allows the automerge workflow in `twenty-infra` to reactively
merge the PR instead of waiting for the next cron run

## Test plan
- [ ] Verify `TWENTY_INFRA_TOKEN` secret is available (same one used by
i18n workflows)
- [ ] Trigger the AI catalog sync workflow manually and confirm the
dispatch fires

Made with [Cursor](https://cursor.com)
2026-03-23 13:33:19 +01:00
b813d64324 chore: sync AI model catalog from models.dev (#18854)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-03-23 13:14:09 +01:00
Félix MalfaitandGitHub c9ca4dfa14 fix: run AI catalog sync as standalone script to avoid DB dependency (#18853)
## Summary
- The `ci-ai-catalog-sync` cron workflow was failing because the
`ai:sync-models-dev` NestJS command bootstraps the full app, which tries
to connect to PostgreSQL — unavailable in CI.
- Converted the sync logic to a standalone `ts-node` script
(`scripts/ai-sync-models-dev.ts`) that runs without NestJS, eliminating
the database dependency.
- Removed the `Build twenty-server` step from the workflow since it's no
longer needed, making the job faster.

## Test plan
- [x] Verified the standalone script runs successfully locally via `npx
nx run twenty-server:ts-node-no-deps-transpile-only --
./scripts/ai-sync-models-dev.ts`
- [x] Verified `--dry-run` flag works correctly
- [x] Verified the output `ai-providers.json` is correctly written with
valid JSON (135 models across 5 providers)
- [x] Verified the script passes linting with zero errors
- [ ] CI should pass without requiring a database service

Fixes:
https://github.com/twentyhq/twenty/actions/runs/23424202182/job/68135439740

Made with [Cursor](https://cursor.com)
2026-03-23 13:07:53 +01:00
Félix MalfaitandGitHub 2dfa742543 chore: improve i18n workflow to prevent stale compiled translations (#18850)
## Summary

- **Add `lingui:compile` to Dockerfile** before both the server and
frontend build stages, ensuring compiled translation catalogs are always
fresh regardless of git state
- **Add `repository-dispatch` to i18n workflows** (`i18n-push.yaml` and
`i18n-pull.yaml`) to trigger reactive automerge in `twenty-infra` when
the i18n PR is ready, replacing the 15-minute polling approach

## Context

Users sometimes see "Uncompiled message detected" errors because
releases can be cut from `main` before the i18n PR (with freshly
compiled translation catalogs) has been merged. This creates a race
condition between new translatable strings landing on `main` and their
compiled catalogs being available.

These changes fix this in two ways:
1. **Safety net in builds**: Every Docker build now compiles
translations before building, so even if compiled catalogs in git are
stale, the build artifact is always correct
2. **Faster i18n PR merges**: Instead of a 15-minute cron polling for
i18n PRs, the workflows now notify `twenty-infra` immediately when
translations are ready, reducing merge latency from ~15 minutes to ~1
minute

Companion PR in twenty-infra: twentyhq/twenty-infra
(feat/i18n-reactive-automerge)

## Test plan

- [ ] Verify `TWENTY_INFRA_TOKEN` secret is available to i18n workflows
- [ ] Docker build still succeeds with the added `lingui:compile` steps
- [ ] i18n-push triggers automerge in twenty-infra after pushing changes
- [ ] i18n-pull triggers automerge in twenty-infra after pulling
translations


Made with [Cursor](https://cursor.com)
2026-03-23 12:53:31 +01:00
e618cc6cf9 i18n - translations (#18846)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-23 10:36:08 +01:00
Félix MalfaitandGitHub bb9e3c44a1 Show AI provider sections regardless of billing status (#18845)
## Summary

- Removes the `isBillingEnabled` guard that was hiding the Providers and
Custom Providers sections in the Admin AI settings
- The `GET_AI_PROVIDERS` query was being skipped when billing was
enabled, and the provider UI sections were conditionally hidden —
there's no reason to gate provider configuration behind billing status
- Cleans up the now-unused `billingState` and `useAtomStateValue`
imports

## Test plan

- [ ] Verify Providers and Custom Providers sections are visible in
Admin > AI settings on cloud (billing enabled)
- [ ] Verify they remain visible on self-hosted (billing disabled)


Made with [Cursor](https://cursor.com)
2026-03-23 10:29:32 +01:00
77d4bd9158 Add billing usage analytics dashboard with ClickHouse integration (#18592)
## Summary
This PR adds a comprehensive billing usage analytics feature that
provides detailed breakdowns of credit consumption across execution
types, users, resources, and time periods. The implementation includes a
new ClickHouse-backed analytics service, GraphQL API endpoint, and a
frontend dashboard component.

## Key Changes

### Backend
- **New BillingAnalyticsService**: Queries ClickHouse for usage
breakdowns by user, resource, execution type, and time series data
- **BillingEventWriterService**: Writes billing events to ClickHouse for
analytics while maintaining best-effort semantics (never blocks Stripe
billing)
- **ClickHouse Schema**: Added `billingEvent` table with 3-year TTL for
storing detailed billing event data
- **GraphQL Resolver**: New `getBillingAnalytics` query that aggregates
usage data for the current billing period, protected by feature flag and
billing permissions
- **Enhanced BillingUsageEvent**: Added `userWorkspaceId` field to track
per-user credit consumption
- **AI Billing Integration**: Updated AI billing service to pass
`userWorkspaceId` when recording usage events

### Frontend
- **SettingsBillingAnalyticsSection**: New component displaying:
  - Usage breakdown by execution type with progress bars
  - Daily usage time series chart (28-day view)
  - Per-user credit consumption breakdown
  - Per-resource (agent/workflow) credit consumption breakdown
- **SettingsUsage Page**: Dedicated page for viewing usage analytics
- **GraphQL Query**: `GetBillingAnalytics` query with generated hooks
- **Navigation**: Added Usage menu item in settings (feature-flagged)
- **Mock Data**: Included screenshot mock data for preview/testing

### Feature Flag
- Added `IS_USAGE_ANALYTICS_ENABLED` feature flag to control visibility
and access to analytics features

## Implementation Details
- Analytics data is queried in parallel for performance
- ClickHouse writes are non-blocking to ensure billing operations never
fail
- Progress bars use dynamic coloring from a predefined palette
- Time series visualization normalizes bar heights relative to max value
- Empty state handling when no analytics data is available
- Responsive UI with proper text truncation for long names

https://claude.ai/code/session_01Y1EqrX6PFq3EJxJq89h7DF

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-23 10:28:23 +01:00
Félix MalfaitandGitHub 49af539032 Fix migration crash: agent.modelId NOT NULL violation (#18844)
## Summary

- The `MigrateModelIdsToCompositeFormat` migration was setting
`agent.modelId = NULL`, but the column has a `NOT NULL` constraint —
causing the migration to fail on deploy.
- Fixed by setting `modelId` to the `'default-smart-model'` sentinel
value instead of NULL. The runtime already resolves this sentinel
dynamically via `getEffectiveModelConfig` / `isDefaultModelSentinel`, so
agents correctly fall back to the admin-configured smart model.

## Test plan

- [ ] Run `npx nx run twenty-server:database:migrate:prod` — migration
should complete without errors
- [ ] Verify agents still resolve to the correct model at runtime
(sentinel → `getDefaultPerformanceModel()`)


Made with [Cursor](https://cursor.com)
2026-03-23 09:44:41 +01:00
68a508a353 i18n - translations (#18839)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-23 01:40:20 +01:00
be89ef30cd Migrate field widgets to backend (#18808)
- Renamed FieldConfiguration's layout field to fieldDisplayMode as it
caused issues with the layout field of BarChartConfiguration
- Create relation Field widgets for standard objects

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-23 01:26:00 +01:00
d90d2e3151 i18n - translations (#18838)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-23 00:51:03 +01:00
e9aa6f47e5 Allow users to create Fields and Field widget (#18801)
- Allow users to create a Fields widget or a Field widget; **this PR is
focused on Fields widgets as Field widget can't be configured yet**
- Automatically create a filled view when the user creates a draft
Fields widget in edit mode


https://github.com/user-attachments/assets/b2dbba52-c614-44cd-bf6c-095ce9d4ec26

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-23 00:44:49 +01:00
dc00701448 Fix: TypeError: Cannot convert undefined or null to object (#18333)
## Automated fix for [bug 6848](https://sonarly.com/issue/6848?type=bug)

**Severity:** `critical`

### Summary
WorkflowEditActionCodeFields.tsx calls Object.entries(functionInput)
without a null guard on line 40, crashing when
action.settings.input.logicFunctionInput is undefined. This happens
because workflow version step data in the database may contain the old
field name serverlessFunctionInput (from before the rename in commit
da6f1bbef3), and no data migration was created to update JSON keys
inside the steps JSONB column.

### User Impact
Users viewing or editing a workflow version containing a CODE step that
was created before the serverlessFunction-to-logicFunction rename see a
crash. The page fails to render the workflow step detail panel,
preventing them from viewing or modifying their workflow.

### Root Cause
Proximate cause: Object.entries(functionInput) on line 40 of
WorkflowEditActionCodeFields.tsx throws TypeError because functionInput
is undefined. The stack trace confirms this: the crash occurs during
React rendering (through scheduler -> react-dom render pipeline ->
WorkflowEditActionCodeFields:40:15 -> Object.entries).

1. Why did Object.entries throw? Because the functionInput prop is
undefined. It is initialized from
action.settings.input.logicFunctionInput via useState on line 142 of
WorkflowEditActionCode.tsx, with no fallback value.

2. Why is action.settings.input.logicFunctionInput undefined? Because
the workflow version step data stored in the database JSONB steps column
contains the OLD field name serverlessFunctionInput instead of
logicFunctionInput. When the frontend accesses logicFunctionInput on the
deserialized JSON object, it returns undefined.

3. Why does the database still have the old field name? Because commit
da6f1bbef3 (Rename serverlessFunction to logicFunction, Jan 28 2026)
renamed the Zod schema field from serverlessFunctionInput to
logicFunctionInput, and the database migration
1769556947746-renameServerless.ts only renames SQL tables and columns
(serverlessFunction table to logicFunction, etc.) but does NOT update
the JSON keys inside the steps JSONB column of the workflowVersion
table.

4. Why was no JSON data migration created? The rename was a large-scale
refactoring across the entire codebase. The steps column stores workflow
action data as opaque JSON, and the migration only addressed relational
schema changes (table/column renames) without updating the embedded JSON
document structure. There is no upgrade command in 1-17, 1-18, or 1-19
directories that transforms the JSON keys from serverlessFunctionInput
to logicFunctionInput.

5. Why did the component not handle this gracefully? The
WorkflowEditActionCodeFields component has no null guard on the
functionInput prop before calling Object.entries. Notably, the analogous
WorkflowEditActionLogicFunction component DOES have a null guard on line
52 (action.settings.input.logicFunctionInput ?? {}), showing the team
was aware of this possibility in one component but missed it in the
other.

**Introduced by:** martmull on 2026-02-16 in commit
[`da064d5`](https://github.com/twentyhq/twenty/commit/da064d5e88a62939e0545a37c68381822e6932ef)

### Suggested Fix
Two minimal null guards are added, matching the pattern already used in
the analogous WorkflowEditActionLogicFunction component. First, in
WorkflowEditActionCode.tsx the useState initializer is changed from
action.settings.input.logicFunctionInput to
action.settings.input.logicFunctionInput ?? {} so that functionInput is
always an empty object rather than undefined when the DB record still
uses the old serverlessFunctionInput key. Second, in
WorkflowEditActionCodeFields.tsx the Object.entries call is changed from
Object.entries(functionInput) to Object.entries(functionInput ?? {}) as
a defensive guard at the render layer, ensuring the component renders
safely even if an undefined value is passed from any caller.

### Evidence
- **Code:**
[packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCode.tsx:142
- const [functionInput, setFunctionInput]
=](https://github.com/twentyhq/twenty/blob/main/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCode.tsx#L142)
- **Code:**
[packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields.tsx:40
- {Object.entries(functionInput ?? {}).map(([inputKey, inputValue]) =>
{](https://github.com/twentyhq/twenty/blob/main/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields.tsx#L40)

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

Co-authored-by: Sonarly Claude Code <claude-code@sonarly.com>
2026-03-23 00:44:19 +01:00
a1b1622d94 i18n - translations (#18837)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-23 00:03:35 +01:00
c107d804d2 Create command menu items for workflows with manual trigger (#18746)
- Automatically create and sync command menu items for all workflows
with a manual trigger
- Refactor `useCommandMenuItemsFromBackend`
- Prefill a _Quick Lead_ workflow command menu item during workspace
setup and dev seeding
- Add a ready prop to `HeadlessEngineCommandWrapperEffect` to prevent
premature execution when async data hasn't loaded yet

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-22 23:52:43 +01:00
d16b94bde6 fix: reset form defaultValues after save to fix dirty detection (#18835)
fixes #18833 
I have put some log video and explained the details in pr #18630.
<img width="929" height="454" alt="image"
src="https://github.com/user-attachments/assets/894d0b89-fb0c-478e-ba42-a4c004a98550"
/>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-22 21:11:02 +00:00
Charles BochetandGitHub e95adcc757 fix(helm): add unit tests, extraEnv schema validation, and minor fixes (#18836)
## Summary

Follow-up to #18157. Cherry-picks the useful parts from #18481 (by
@dnplkndll), adapted to align with the current state of `main`:

- **Helm unit tests** for Redis external authentication (secret-based +
plaintext password, both server and worker)
- **Helm unit tests** for `extraEnv` injection (plain values and
`valueFrom` on both server and worker)
- **JSON schema validation** for `server.extraEnv` and `worker.extraEnv`
in `values.schema.json`
- **Fix** `server_url_test.yaml` to use JSONPath filters
(`@.name=="SERVER_URL"`) instead of brittle `env[0]` index selectors
- **Fix** worker `storageEnv` whitespace (missing `-` in `{{-
$storageEnv | nindent 12 }}`)

Stale tests from #18481 (for `disableDbMigrations`, `server.env`
pass-through, and `run-migrations` init container) were dropped since
those features were removed during the #18157 cleanup.

## Test plan

- [ ] `helm lint` passes
- [ ] `helm template` renders cleanly
- [ ] Unit tests pass with `helm unittest` (requires the plugin)


Made with [Cursor](https://cursor.com)
2026-03-22 21:45:31 +01:00
9d613dc19d Improve helm chart // Fix linting issues & introduce Redis externalSecret for redis password // Add additional ENVs // Improve migrations (#18157)
This pull request enhances the Helm chart for the Twenty application by
improving how environment variables and Redis credentials are handled
for both server and worker deployments. The main changes include support
for injecting additional environment variables, improved Redis password
management (including external secrets), and a more robust database
migration workflow.

**Environment Variable Injection:**
- Added support for specifying additional environment variables for both
the server and worker deployments via the `additionalEnv` field in
`values.yaml`. These variables are automatically injected into the
respective pods.
[[1]](diffhunk://#diff-b5d958eae48fd1919e5623bcf0144aac7abb323ae8743e6f31367e383c63c296R55)
[[2]](diffhunk://#diff-b5d958eae48fd1919e5623bcf0144aac7abb323ae8743e6f31367e383c63c296R109-R110)
[[3]](diffhunk://#diff-20bb91909627a12b50b3c165a2a027b663479c0104ed8dbf91d2b9ad8ea8a931R74-R77)
[[4]](diffhunk://#diff-20bb91909627a12b50b3c165a2a027b663479c0104ed8dbf91d2b9ad8ea8a931R157-R172)
[[5]](diffhunk://#diff-20bb91909627a12b50b3c165a2a027b663479c0104ed8dbf91d2b9ad8ea8a931R225-R229)
[[6]](diffhunk://#diff-fb612a3b7a13156aaa607b27d23025e2c6831f111b6a582fd313fad26d2fdb5bR89-R92)

**Redis Credential Management:**
- Introduced support for using external secrets for Redis passwords by
adding `secretName` and `passwordKey` fields under `redis.external` in
`values.yaml`, and logic to inject `REDIS_PASSWORD` from a Kubernetes
secret if configured.
[[1]](diffhunk://#diff-b5d958eae48fd1919e5623bcf0144aac7abb323ae8743e6f31367e383c63c296R180-R182)
[[2]](diffhunk://#diff-5c4fa358b10abd7581188995feb9b4d6be0bc4f06a95bf27bb31b5595d6693d8R92-R100)
[[3]](diffhunk://#diff-20bb91909627a12b50b3c165a2a027b663479c0104ed8dbf91d2b9ad8ea8a931R157-R172)
[[4]](diffhunk://#diff-20bb91909627a12b50b3c165a2a027b663479c0104ed8dbf91d2b9ad8ea8a931R196-R205)
[[5]](diffhunk://#diff-fb612a3b7a13156aaa607b27d23025e2c6831f111b6a582fd313fad26d2fdb5bR70-R79)
- Updated the logic for constructing the `REDIS_URL` to include
authentication information if a password is set or an external secret is
used.

**Database Migration Workflow:**
- Improved the startup command for the server deployment to optionally
skip database migrations (using `DISABLE_DB_MIGRATIONS`), check for an
existing schema before running migrations, and ensure setup scripts are
only run on empty databases.

These changes make the chart more flexible and secure, especially for
production deployments requiring externalized secrets and custom
environment configurations.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-22 21:36:10 +01:00
23fb2a0d58 i18n - docs translations (#18832)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-22 21:22:28 +01:00
fa04e7077e i18n - translations (#18831)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-22 17:02:40 +01:00
1a0588d233 fix: auto-retry Microsoft OAuth on AADSTS650051 race condition (#18405)
## Summary

Fixes #13008

Azure AD has a [known
bug](https://learn.microsoft.com/en-us/answers/questions/5570899/our-adminconsent-url-always-fails-with-aadsts65005)
where the first consent attempt for a multi-tenant app fails with
`AADSTS650051` because the service principal is mid-provisioning in the
target tenant. Microsoft's own engineers have acknowledged the issue
with no fix ETA.

This PR adds a transparent retry to the `MicrosoftOAuthGuard`:

1. When the OAuth redirect returns `AADSTS650051`, the guard parses the
`state` parameter to recover the original query params (workspaceId,
invite hash, locale, etc.)
2. Redirects the user back to `/auth/microsoft` with a `msRetry=1`
counter
3. The full OAuth flow restarts — by this point the service principal
has finished provisioning, so consent succeeds
4. Capped at 1 retry (`MAX_MICROSOFT_AUTH_RETRIES`) to prevent infinite
loops. If it still fails, falls through to the normal error page

From the user's perspective, they just see a brief extra redirect
instead of a cryptic error page.

### Context

- `AADSTS650051` is a race condition in Azure AD's service principal
provisioning during multi-tenant app consent
- It's distinct from missing consent (`AADSTS65001`) or admin consent
required (`AADSTS90094`)
- The error is transient — retrying the same flow immediately succeeds
- Microsoft Q&A threads confirm this affects many multi-tenant apps, not
just Twenty

### References

- [Microsoft Q&A: adminconsent always fails with AADSTS650051 on first
attempt](https://learn.microsoft.com/en-us/answers/questions/5570899/our-adminconsent-url-always-fails-with-aadsts65005)
- [Microsoft Q&A: AADSTS650051 for multiple
applications](https://learn.microsoft.com/en-us/answers/questions/5571098/when-customers-attempt-to-sign-in-and-grant-consen)

## Test plan

- [ ] Deploy to a staging environment with Microsoft OAuth enabled
- [ ] Have a user from a new Azure AD tenant attempt Microsoft sign-in
for the first time
- [ ] If AADSTS650051 occurs, verify the user is transparently retried
and signs in successfully
- [ ] Verify `msRetry` counter prevents infinite redirect loops (check
server logs for the warn message)
- [ ] Verify normal Microsoft sign-in flow (no error) is unaffected

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: neo773 <neo773@protonmail.com>
2026-03-22 16:51:10 +01:00
8ef32c4781 Add In-Reply-To to Email Workflow Node (#18641)
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-22 16:49:08 +01:00
7bde8a4dfa [Chore]: Migration to dynamic view names for standard views (#18701)
Follow up for #18700

---------

Co-authored-by: Arun kumar <arunkumar@Aruns-MacBook-Air.local>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-22 15:18:17 +00:00
Charles BochetandGitHub d5a7dec117 refactor: rename ObjectMetadataItem to EnrichedObjectMetadataItem and clean up metadata flows (#18830)
## Summary

- Renames `ObjectMetadataItem` to `EnrichedObjectMetadataItem` across
the entire frontend (~440 files) to clarify that this type includes
derived fields (`readableFields`, `updatableFields`, nested `fields[]`,
`indexMetadatas[]`) computed at read time from the metadata store
- Creates `splitObjectMetadataGqlResponse` that goes directly from a
GraphQL `ObjectMetadataItemsQuery` response to flat store items
(combining the old
`mapPaginatedObjectMetadataItemsToObjectMetadataItems` +
`splitObjectMetadataItemWithRelated` two-step flow into one call)
- Removes `ObjectMetadataItemWithRelated` type and all "WithRelated"
naming
- Renames `generatedMockObjectMetadataItems` to
`generateTestEnrichedObjectMetadataItemsMock` to make it clear this is
test-only enriched data
- Deletes `useLoadMockedObjectMetadataItems` hook (consolidated into
`useLoadMockedMinimalMetadata`)
- Ensures nothing destined for the metadata store computes
`readableFields`/`updatableFields` (preventing the localStorage bloat
from #18809)

## Type hierarchy (before → after)

**Before:**
```
ObjectMetadataItemsQuery → mapPaginated → ObjectMetadataItemWithRelated → enrich → ObjectMetadataItem
                                        → split → FlatObjectMetadataItem (store)
```

**After:**
```
ObjectMetadataItemsQuery → splitObjectMetadataGqlResponse → FlatObjectMetadataItem (store)
                         → mapPaginated + enrich (tests only) → EnrichedObjectMetadataItem
```

## Test plan

- [x] `npx nx typecheck twenty-front` passes
- [x] `npx nx test twenty-front` passes (767 suites, 4505 tests)
- [x] `npx nx lint twenty-front` passes
- [ ] CI checks pass


Made with [Cursor](https://cursor.com)
2026-03-22 15:53:47 +01:00
96c5728ed0 fix: prevent localStorage bloat from derived fields on mock metadata (#18809)
## Summary

- On unauthenticated pages (login), mocked object metadata was being
hydrated into the store with `readableFields` and `updatableFields`
attached. These derived arrays duplicate every field per object,
inflating `objectMetadataItems` in localStorage from ~70 KB to ~2 MB.
Combined with other metadata keys, this exceeded Safari's 5 MB quota and
caused `QuotaExceededError`, preventing real data from replacing mock
data on login.
- Extracted flat mock data into a dedicated file
(`generatedMockObjectMetadataItemsWithRelated.ts`) and use it for store
hydration, keeping the enriched version
(`generatedMockObjectMetadataItems`) only for tests.
- Completed `splitObjectMetadataItemWithRelated`'s contract by stripping
`readableFields` and `updatableFields` at runtime (not just via
TypeScript's `Omit`), matching the `FlatObjectMetadataItem` return type
that omits all four relational properties.

## Root cause

1. `MinimalMetadataLoadEffect` loads mocked metadata on unauthenticated
pages.
2. `generatedMockObjectMetadataItems` was produced by
`enrichObjectMetadataItemsWithPermissions`, which attaches
`readableFields` and `updatableFields` (full copies of the `fields`
array).
3. `splitObjectMetadataItemWithRelated` only destructured `fields` and
`indexMetadatas` — `readableFields`/`updatableFields` leaked into
`...objectProperties` at runtime because `Omit` is a compile-time-only
guard.
4. These bloated objects were written to localStorage, consuming ~2 MB
instead of ~70 KB.
5. On login, the intermediate state (old composite mock `current` + new
flat real `draft`) exceeded the 5 MB quota, causing a
`QuotaExceededError` deadlock.

## Test plan

- [ ] Open the app in a fresh Safari private window (empty localStorage)
- [ ] Verify the login page loads without errors
- [ ] Log in and verify metadata loads correctly
- [ ] Check `localStorage` size —
`metadataStoreState__objectMetadataItems` should be ~70 KB, not ~2 MB
- [ ] Run existing tests: `npx nx test twenty-front` — no regressions
from the mock data split


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-22 13:54:44 +01:00
bdaff0b7e2 Fetch load more on group by (#18811)
Fixes https://github.com/twentyhq/twenty/issues/18587 "Load More" in
grouped record table view (aggregated view) which was broken — clicking
it loaded no records and made the button disappear

Root cause: the Load More button called fetchMore on a useLazyQuery that
was never executed, causing Apollo to throw Invariant Violation:
'fetchMore' cannot be called before executing the query. The component
now uses fetchMoreRecords from the same useQuery that powers the table
data.

Before

https://github.com/user-attachments/assets/5266424b-7f35-4261-b7b7-9f7bc3eb8ad6

After

https://github.com/user-attachments/assets/866fe7d7-720a-40c7-bb28-267b85bd98d5

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-22 12:59:09 +01:00
Charles BochetandGitHub 9a306ddb9a feat: store SSO connections as connected accounts during sign-in (#18825)
## Summary

- Store SSO connections (Google, Microsoft, OIDC, SAML) as connected
accounts in the core schema during sign-in/sign-up, gated behind the
`IS_CONNECTED_ACCOUNT_MIGRATED` feature flag
- Add `OIDC` and `SAML` to `ConnectedAccountProvider` enum with
exhaustive switch handling across frontend and backend
- Add `IS_CONNECTED_ACCOUNT_MIGRATED` to `DEFAULT_FEATURE_FLAGS` for new
workspaces, with a fallback check so SSO accounts are created even
before workspace activation
- Always upsert connected accounts to both workspace and core schemas
during messaging OAuth flow, fixing FK constraint violations when
SSO-only accounts exist only in core
- Create message/calendar channels when they don't exist regardless of
new vs reconnect flow
- Filter settings accounts list to only show accounts that have message
or calendar channels

## Test plan

- [ ] Sign up with Google SSO → verify connected account is created in
core schema
- [ ] Connect messaging (Google APIs) after SSO sign-up → verify no FK
errors, channels created, configuration page renders correctly
- [ ] Reconnect an existing messaging account → verify tokens updated,
sync resets triggered
- [ ] Sign in with OIDC/SAML SSO → verify connected account created with
oidcTokenClaims
- [ ] Verify settings accounts page only shows accounts with channels
(SSO-only accounts hidden)
- [ ] Verify typecheck, lint, and unit tests pass
2026-03-22 12:29:58 +01:00
neo773andGitHub 246afe0f2a fix: skip chat messages query when thread ID is the unknown-thread default (#18828)
Prevents invalid UUID error on initial mount before a real thread is
selected
2026-03-22 10:53:43 +01:00
d69e4d7008 fix: prevent FIND_RECORDS from silently dropping unresolved filter variables (#18814)
## Summary

Fixes #18744 — The workflow FIND_RECORDS action silently drops filter
conditions when a variable resolves to null/empty, causing the query to
return **all records** instead of erroring.

**Root cause (three compounding layers):**

1. **`variable-resolver.ts`** — `resolveString` returns `undefined` when
a variable lookup fails (e.g., `{{steps.trigger.output.userId}}` where
`userId` doesn't exist in context). The return type says `string` but
`evalFromContext` actually returns `undefined` at runtime.

2. **`checkIfShouldSkipFiltering.ts`** — Treats `undefined`/`null`/`""`
values as "skip this filter." This is correct for the **UI filter
builder** (user hasn't finished typing), but wrong for **workflow
execution** (variable resolution failed = misconfigured workflow).

3. **`find-records.workflow-action.ts`** — When all filters are silently
skipped, `computeRecordGqlOperationFilter` returns `{}` (match
everything). The query runs with no filter, returning all records —
silently succeeding with wrong results.

## Fix

Added validation in `find-records.workflow-action.ts` **after**
`resolveInput` but **before** `computeRecordGqlOperationFilter`. For
each filter with a value-requiring operand (i.e., not IS_EMPTY,
IS_NOT_EMPTY, IS_IN_PAST, IS_IN_FUTURE, IS_TODAY), if the resolved value
is `undefined`, `null`, or `""`, throw `INVALID_STEP_INPUT` with a
descriptive error message.

**Why this approach:**
- Scoped to the workflow executor — does **not** break the UI filter
builder's intentional skip-on-empty behavior
- Does not change shared utilities (`checkIfShouldSkipFiltering`,
`resolveInput`) used across the app
- Fails fast with a clear error instead of silently returning wrong data
- 1 file changed, 23 lines added

## Test plan

- [x] Backend typecheck passes
- [x] oxlint passes (0 warnings, 0 errors)
- [x] Prettier passes
- [ ] Manual: Create a workflow with FIND_RECORDS using a variable that
doesn't exist → should error with "Filter condition has an empty value
after variable resolution" instead of returning all records
- [ ] Manual: Create a workflow with FIND_RECORDS using IS_EMPTY operand
(no value needed) → should still work correctly

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-21 18:27:01 +01:00
Charles BochetandGitHub 03a2abb305 fix: board view loads all records instead of showing skeleton placeholders (#18824)
## Summary

- The record board (Kanban view) only loaded the first 10 records per
column, then showed skeleton placeholder cards for the rest without ever
fetching the remaining data
- **Root cause**: The `RecordBoardFetchMoreInViewTriggerComponent`
(IntersectionObserver) is positioned at the bottom of the entire board.
With 10 real cards + 10 skeleton cards per column (~3500px of content),
the trigger div was pushed far beyond the 1600px `rootMargin` detection
zone, so `recordBoardShouldFetchMore` never became `true` and fetch-more
was never triggered
- **Fix**: The initial query now sets `recordBoardShouldFetchMore =
true` when columns need more data, and `triggerRecordBoardFetchMore`
uses an internal `while` loop to load all remaining pages in a single
invocation — making it immune to the InView component racing to reset
the flag between React render cycles
2026-03-21 17:20:53 +01:00
c01fc3bafb i18n - translations (#18823)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-21 16:21:41 +01:00
Félix MalfaitandGitHub 908aefe7c1 feat: replace hardcoded AI model constants with JSON seed catalog (#18818)
## Summary

- Replaces per-provider TypeScript constant files
(`openai-models.const.ts`, `anthropic-models.const.ts`, etc.) with a
single `ai-providers.json` catalog as the source of truth
- Adds runtime model discovery via AI SDK for self-hosted providers,
with `models.dev` enrichment for pricing/capabilities
- Introduces composite model IDs (`provider/modelId`) for canonical,
conflict-free identification
- Simplifies provider configuration: API keys are injected from
environment variables (e.g., `OPENAI_API_KEY`)
- Adds admin panel UI for provider management (add/remove/test), model
discovery, recommended model configuration, and default fast/smart model
selection per workspace
- Removes deprecated config variables (`AI_DISABLED_MODEL_IDS`,
`AUTO_ENABLE_NEW_AI_MODELS`, etc.)
- Adds database migration for composite model ID format

## Test plan

- [ ] Server typecheck passes
- [ ] Frontend typecheck passes
- [ ] Server unit tests pass
- [ ] Frontend unit tests pass
- [ ] CI pipeline green
- [ ] Admin panel AI tab loads correctly
- [ ] Provider discovery works for configured providers
- [ ] Model recommendation toggles persist
- [ ] Default fast/smart model selection works


Made with [Cursor](https://cursor.com)
2026-03-21 16:03:58 +01:00
50a0bef0e4 i18n - translations (#18822)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-21 16:03:02 +01:00
cd651f57cb fix: prevent blank subdomain from being saved (#18812)
## Summary

Fixes #17941 — Saving a blank subdomain causes a redirect to
`.website.com`, effectively breaking the workspace.

**Root cause:** Three layers all fail to reject an empty string `""`:

1. **Frontend (`SettingsDomain.tsx`):** `SaveButton` has both
`onClick={onSave}` and `type="submit"`. The `onClick` fires first,
calling `handleSave()` directly without running Zod validation. So
`isDefined("")` returns `true`, the confirmation modal opens, and the
blank subdomain is submitted.

2. **Backend DTO (`update-workspace-input.ts`):** The `subdomain` field
has `@IsString()` + `@IsOptional()` but no pattern validation, so an
empty string passes the DTO layer.

3. **Backend service (`workspace.service.ts:152`):** `if
(payload.subdomain && ...)` — empty string is falsy in JS, so it skips
`validateSubdomainOrThrow()` entirely and writes `subdomain: ""` to the
database.

**The crash:** After save, the redirect logic does
`"myworkspace.website.com".replace("myworkspace", "")` →
`".website.com"`, sending the user to an invalid URL.

## Fix

- **Frontend:** Call `form.trigger()` at the start of `handleSave` to
run Zod validation regardless of whether the function was invoked via
`onClick` or `form.handleSubmit`. Returns early with validation error if
invalid.
- **Backend DTO:** Add `@Matches(/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/)`
to reject invalid subdomains at the request validation layer
(defense-in-depth).
- **Backend service:** Change `if (payload.subdomain && ...)` to `if
(isDefined(payload.subdomain) && ...)` so empty strings route through
`validateSubdomainOrThrow()` instead of being silently skipped.

## Test plan

- [x] Existing `is-subdomain-valid.util.spec.ts` tests pass (36/36)
- [x] TypeScript type checks pass for both `twenty-server` and
`twenty-front`
- [x] oxlint passes on all changed files
- [x] Prettier passes on all changed files
- [ ] Manual: Navigate to Settings > Domains, clear the subdomain field,
click Save — should show validation error, not redirect

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-21 14:49:21 +00:00
fc9723949b Fix AI chat re-renders and refactored code (#18585)
This PR: 

- Breaks useAgentChatData into focused effect components (streaming,
fetch,
init, auto-scroll, diff sync)
- Splits message list into non-last (stable) + last (streaming/error) to
prevent full re-renders on each stream chunk
- Adds scroll-to-bottom button and MutationObserver-based auto-scroll on
thread switch
- Lifts loading state from context to atoms
- Adds areEqual to selector
factories

We could improve further but this sets up a robust architecture for
further refactoring.

## Messages flow

The flow of messages loading and streaming is now more solid. 

Everything goes out from `AgentChatAiSdkStreamEffect`, whether loaded
from the DB or streaming directly, and every consumers is using only one
atom `agentChatMessagesComponentFamilyState`

## Data sync effect with callbacks new hook 

See
`packages/twenty-front/src/modules/apollo/hooks/useQueryWithCallbacks.ts`
which allows to fix Apollo v4 migration leftovers and is an
implementation of the pattern we talked about with @charlesBochet

We could refine this pattern in another PR.

# Before



https://github.com/user-attachments/assets/84e7a96f-6790-405d-8a73-2dacbf783be5


# After



https://github.com/user-attachments/assets/4c692e3a-2413-4513-abcc-44d0da311203

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-21 12:52:21 +00:00
d389a4341d [Fix]: Vertical bar charts with max data range do not show data up to the top if above max data range (#18748)
fixes issue : #18606 

we have removed the irrelevant use of filters in the backend which
removes the values that are out of max, min range hence modifying the
rawValues.

- Modified relevant tests
- fixed regressions (Horizontal bar charts rightLabel cropping)
- removed dead utility filterByRange code

Before :
<img width="1253" height="722" alt="Screenshot 2026-03-18 at 10 24
14 PM"
src="https://github.com/user-attachments/assets/20adb5bb-0f96-4952-9777-1f1a6ebe03eb"
/>

After:
<img width="865" height="724" alt="Screenshot 2026-03-18 at 11 16 33 PM"
src="https://github.com/user-attachments/assets/b2695d85-ca5e-4923-8f57-8d3c87c8212a"
/>

<img width="1260" height="689" alt="Screenshot 2026-03-19 at 12 16
41 AM"
src="https://github.com/user-attachments/assets/fb25e15f-eec2-445e-9103-280d58664454"
/>

---------

Co-authored-by: Arun kumar <arunkumar@Aruns-MacBook-Air.local>
Co-authored-by: ehconitin <nitinkoche03@gmail.com>
2026-03-20 19:33:21 +00:00
2a1d22d870 Fix page scrolling when layout customization bar is visible (#18802)
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-03-20 18:02:15 +00:00
Charles BochetandGitHub 732aea9121 fix: restore original migration timestamp for messaging infrastructure (#18810)
## Summary
- Restores the original migration timestamp (`1773945207801`) that was
accidentally changed to `1774005903909` during PR #18787
- Keeps the content improvements (default column values) from that PR
intact

## Test plan
- [ ] Verify migration runs correctly with `npx nx run
twenty-server:database:migrate:prod`


Made with [Cursor](https://cursor.com)
2026-03-20 18:14:51 +01:00
42269cc45b fix(links): preserve percent-encoded URLs during normalization (#18792)
## Summary

This preserves percent-encoded payloads when normalizing links fields.

`lowercaseUrlOriginAndRemoveTrailingSlash` was decoding the path and
query string while lowercasing the URL origin. That changes URLs where
encoded payloads are semantically significant, such as Google Maps links
containing `%2F` segments.

Closes #18698.

## Changes

- stop decoding the path/query payload in
`lowercaseUrlOriginAndRemoveTrailingSlash`
- preserve the raw path, query, and hash while still lowercasing the
origin and trimming a trailing slash
- update shared URL normalization tests to assert encoded payloads stay
encoded
- add a server-side regression test covering imported links field
normalization

## Validation

- `corepack yarn jest --config packages/twenty-shared/jest.config.mjs
packages/twenty-shared/src/utils/url/__tests__/lowercaseUrlOriginAndRemoveTrailingSlash.test.ts
--runInBand`
- `corepack yarn jest --config packages/twenty-server/jest.config.mjs
packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/transform-links-value.util.spec.ts
--runInBand`
- `corepack yarn nx test twenty-server --runInBand
--testFile=src/engine/core-modules/record-transformer/utils/__tests__/transform-links-value.util.spec.ts`

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-20 16:56:12 +00:00
1be0f1554f i18n - docs translations (#18807)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-20 17:46:46 +01:00
d149c2a041 i18n - translations (#18806)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-20 17:44:20 +01:00
EtienneandGitHub 9698996771 fix useless auth in sdk gql codegen command (#18805) 2026-03-20 17:22:41 +01:00
9cb21e71fa feat: secure and user-scope metadata resolvers for messaging infrastructure (#18787)
## Summary

Builds on the messaging infrastructure migration (#18784) by securing
and user-scoping all 4 metadata resolvers:

### DTOs secured
- **ConnectedAccountDTO**: `@HideField()` on `accessToken`,
`refreshToken`, `connectionParameters`, `oidcTokenClaims`
- **MessageChannelDTO / CalendarChannelDTO**: `@HideField()` on
`syncCursor`
- **MessageFolderDTO**: `@HideField()` on `syncCursor`, `externalId`
- **UpdateMessageFolderInputUpdates**: stripped to only `isSynced`
(removed `name`, `syncCursor`, `pendingSyncAction`)

### Resolvers user-scoped via `@AuthUserWorkspaceId()`
- `myConnectedAccounts` — returns only the calling user's accounts (no
permission guard)
- `myMessageChannels(connectedAccountId?)` — returns channels for the
user's connected accounts
- `myCalendarChannels(connectedAccountId?)` — same pattern
- `myMessageFolders(messageChannelId?)` — returns folders through the
ownership chain

### Admin-only listing with permission guard
- `connectedAccounts` query retained with
`SettingsPermissionGuard(CONNECTED_ACCOUNTS)` for admin listing of all
workspace accounts

### Unsafe mutations removed
- Removed `createConnectedAccount`, `updateConnectedAccount` (OAuth/IMAP
flows create/refresh tokens server-side)
- Removed `create*`/`delete*` mutations from MessageChannel,
CalendarChannel, MessageFolder (managed by sync engine)

### Update mutations restricted with ownership verification
- `deleteConnectedAccount(id)` — verifies `entity.userWorkspaceId ===
currentUserWorkspaceId`
- `updateMessageChannel` / `updateCalendarChannel` /
`updateMessageFolder` — verify ownership through connected account chain
- New `OWNERSHIP_VIOLATION` exception codes map to `ForbiddenError` in
GraphQL

### `@AuthUserWorkspaceId` decorator hardened
- Added `allowUndefined` option (default: `false`) — throws
`ForbiddenException` if `userWorkspaceId` is undefined (e.g. API key
auth)
- Existing callers updated to `@AuthUserWorkspaceId({ allowUndefined:
true })` where needed
- New user-scoped resolvers enforce non-undefined `userWorkspaceId` at
decorator level

### Exception handler chaining
- `MessageFolderGraphqlApiExceptionInterceptor`,
`MessageChannelGraphqlApiExceptionInterceptor`,
`CalendarChannelGraphqlApiExceptionInterceptor` chain upstream exception
handling (ConnectedAccountException, MessageChannelException) for
correct `ForbiddenError` propagation

### Metadata services enhanced
- `findByUserWorkspaceId()`, `getUserConnectedAccountIds()`,
`findByConnectedAccountIds()`, `findByMessageChannelIds()`
- `findBy*ForUser()` methods encapsulate ownership checks before
querying
- `verifyOwnership()` on all 4 services with proper chain validation
- Named parameters throughout for clarity

### Dev seeds for both schemas
- Added JANE to connected account, message channel, calendar channel
workspace seeds
- Created message folder workspace seeds (TIM, JONY, JANE)
- New `seed-metadata-entities.util.ts` seeds core schema tables
(connectedAccount, messageChannel, calendarChannel, messageFolder) with
same IDs as workspace seeds, mapping `accountOwnerId` →
`userWorkspaceId`

### Integration tests (using seeds, not raw SQL)
- 4 test suites (`connected-account`, `message-channel`,
`calendar-channel`, `message-folder`)
- Tests use seeded data IDs from seed constants — no raw SQL
inserts/deletes
- Tests read via GraphQL resolvers
- Tests cover: user scoping, admin permission checks, sensitive field
exclusion, ownership enforcement on mutations

### Frontend migration
- Feature-flag-gated hooks (`useMyConnectedAccounts`,
`useMyMessageChannels`, `useMyCalendarChannels`, `useMyMessageFolders`)
- When `IS_CONNECTED_ACCOUNT_MIGRATED` is on: hooks use metadata API
(`POST /metadata`)
- When flag is off: hooks use existing workspace API (`POST /graphql`,
current behavior)
- Settings account pages updated to use new hooks
- `useEffect` extracted to
`SettingsAccountsSelectedMessageChannelEffect` component per project
conventions
- Error messages translated with Lingui

## Test plan
- [x] Server typecheck passes
- [x] Server lint passes
- [x] Server unit tests pass (477 suites, 4269 tests)
- [x] Frontend typecheck passes
- [x] Frontend lint passes
- [x] Integration tests verify user-scoping, ownership enforcement,
hidden fields
- [ ] CI green

---------

Co-authored-by: neo773 <neo773@protonmail.com>
2026-03-20 17:22:22 +01:00
Thomas TrompetteandGitHub a8625d8bfb Clean workflow query invalid input errors from sentry (#18804)
As title
2026-03-20 16:12:25 +00:00
Charles BochetandGitHub 3d49c21b51 fix: decouple viewPicker favorite detection from sorted navigation menu items (#18803)
## Summary

- Reverts the `useSortedNavigationMenuItems` coupling in
`ViewPickerOptionDropdown` introduced by #18791
- The viewPicker now uses `useNavigationMenuItemsData` directly for the
`isFavorite` check, keeping view ordering and navigation menu item
ordering independent

## Why

PR #18791 replaced `useNavigationMenuItemsData` with
`useSortedNavigationMenuItems` in the viewPicker for favorite detection.
While the intent was to filter out stale/orphaned navigation items, this
created an unnecessary coupling: `useSortedNavigationMenuItems`
subscribes to `viewsSelector` and `objectMetadataItemsSelector`, making
the viewPicker transitively dependent on the navigation menu item
ordering system. View ordering in the picker (driven by `view.position`)
and navigation menu item ordering (driven by
`navigationMenuItem.position`) should remain decorrelated.

## Test plan

- [ ] Open the viewPicker dropdown and verify views are listed in
correct order
- [ ] Drag-and-drop to reorder views in the viewPicker — confirm it
works
- [ ] Verify the "Add to Favorite" / "Manage favorite" label still
correctly reflects favorite state
- [ ] Reorder navigation menu items in the sidebar — confirm viewPicker
order is unaffected


Made with [Cursor](https://cursor.com)
2026-03-20 16:08:26 +00:00
7c8f060b08 Fix orphan navigation menu items for deleted views (#18791)
## Summary

Fixes #18757

This fixes a set of Favorites / navigation-menu-item integrity problems
related to deleted views, stale hidden items, and upgraded workspaces
with orphaned navigation items.

## What changed

- delete `navigationMenuItem` entries when their favorited view is
deleted
- keep the client metadata store in sync immediately when a view is
deleted
- determine whether a view is already favorited from visible valid
navigation items instead of raw stale items
- add a `1.20.0` upgrade repair command that deletes orphan navigation
menu items and normalizes positions
- add regression coverage for deletion of both record-based and
view-based navigation menu items

## Details

Server:
- extend `NavigationMenuItemDeletionService` so cleanup applies to
deleted views as well as deleted records
- add regression tests covering record-based deletion, view-based
deletion, and no-op behavior
- add `DeleteOrphanNavigationMenuItemsCommand` to remove orphaned items
pointing to:
  - deleted views
  - deleted records
  - missing folders
- normalize positions per scope (`userWorkspaceId + folderId`) after
repair
- wire the new repair command into the `1.20.0` upgrade flow

Frontend:
- add `useRemoveNavigationMenuItemByViewId`
- remove the related navigation item from client metadata immediately
when deleting a view
- use sorted / visible navigation items for favorite detection so stale
hidden rows do not block re-adding a favorite

## Why

Issue `#18757` reports mismatches between Favorites shown in the UI and
rows users can still find in the database. We found that current
Favorites behavior is driven by `navigationMenuItem`, not the legacy
`favorite` table, and that stale / orphaned `navigationMenuItem` rows
could:
- remain after deleting a favorited view
- stay hidden from the UI if they point to invalid targets
- still cause the UI to think a view was already favorited
- persist in workspaces with migration damage from skipped sequential
upgrades

This patch addresses those cases directly and adds an upgrade-time
repair path for older corrupted workspaces.

## Validation

Passed:
- `./node_modules/.bin/jest --config
packages/twenty-server/jest.config.mjs --runInBand
packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/__tests__/navigation-menu-item-deletion.service.spec.ts`
- `./node_modules/.bin/tsc -p packages/twenty-front/tsconfig.json
--noEmit --pretty false`

Known unrelated existing failure:
- `./node_modules/.bin/tsc -p packages/twenty-server/tsconfig.json
--noEmit --pretty false`

The server typecheck failure is pre-existing and unrelated to this
branch. Current errors are around `@file-type/pdf` module resolution and
`is-psl-parsed-domain.type.ts`.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-20 15:54:33 +01:00
89300564ba Add a note to documentation about variable change (#18752)
Fixes https://github.com/twentyhq/twenty/issues/18646

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-20 15:54:11 +01:00
e7434bdc39 Direct graphql execution (#18759)
On top of [previous closed
PR](https://github.com/twentyhq/twenty/pull/18713) from @FelixMalfait :
- add a schema-creation-skipping optimization
- extract a handler-per-operation pattern, 
- add runtime input validation guards,
- integrate with the standard workspace cache
- add gql-style error handling


To do/optimize/check : 
- gql parsing and null backfilling


## Intro 

This PR introduces a **direct GraphQL execution path** that bypasses
per-workspace GraphQL schema generation for workspace data queries (CRUD
on user-defined objects like companies, people, tasks, etc.).

## Why

In the current architecture, every workspace gets its own
dynamically-generated GraphQL schema reflecting its custom objects and
fields. This costs **~20MB of RAM per workspace per pod** and takes time
to build. For a multi-tenant SaaS with thousands of workspaces, this is
a significant infrastructure cost and a latency bottleneck (especially
on cold starts or cache misses).

The insight is that most workspace queries (`findMany`, `createOne`,
`updateOne`, etc.) don't actually *need* the full schema — they can be
routed directly to the existing Common API query runners by parsing the
GraphQL AST and matching resolver names against object metadata. The
schema is only truly needed for introspection, subscriptions, or queries
that mix core and workspace resolvers.

## How It Works

1. A Yoga `onRequest` plugin intercepts incoming GraphQL requests
2. It parses the query AST and checks if all top-level fields map to
generated workspace resolvers (e.g. `findManyCompanies`,
`createOnePerson`)
3. If yes, it executes them directly against the query runners, skipping
schema generation entirely
4. If the query contains introspection, subscriptions, or core-only
resolvers, it falls through to the normal path
5. Even for mixed queries it can't fully handle, it sets
`skipWorkspaceSchemaCreation` to avoid building the schema when
unnecessary

The whole thing is gated behind the
`IS_DIRECT_GRAPHQL_EXECUTION_ENABLED` feature flag for safe incremental
rollout.

**Net effect**: dramatically lower memory footprint and faster response
times for the vast majority of workspace API calls.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2026-03-20 14:33:56 +00:00
EtienneandGitHub 9a850e2241 fix - handle invoice.paid webhook to recover unpaid subscriptions (#18770)
Fixes https://github.com/twentyhq/private-issues/issues/432

## Problem

When a user's invoice goes unpaid, Stripe moves their subscription to
`unpaid` status, and Twenty suspends the workspace. But if the user pays
that invoice while a new billing period has started, Stripe has already
generated a new **draft** invoice for that period. Since the draft isn't
finalized or paid, Stripe doesn't reactivate the subscription — the
workspace stays suspended indefinitely.

## What was missing

- No handler for the `invoice.paid` Stripe webhook event
- No mechanism to finalize draft invoices that accumulated during the
`unpaid` period
- No way to reset the workspace deletion countdown (`suspendedAt`) when
a user shows payment intent

## What was added

### 1. `StripeInvoiceService` — new Stripe SDK wrapper

- `listDraftInvoices(stripeSubscriptionId)` — lists all draft invoices
for a subscription
- `finalizeInvoice(invoiceId)` — finalizes a draft with `auto_advance:
true` so Stripe auto-charges it

### 2. `INVOICE_PAID` enum value

Added to `BillingWebhookEvent` so the controller can route it.

### 3. `processInvoicePaid()` in `BillingWebhookInvoiceService`

New private handler that:

- Fetches all draft invoices for the subscription
- Filters to only those whose `period_end` is in the past (already
overdue)
- Finalizes each one (with error handling per invoice to avoid blocking
the webhook)
- If the workspace is suspended, resets `suspendedAt` to now (buys time
before deletion)

### 4. Controller routing

`INVOICE_FINALIZED` and `INVOICE_PAID` are now grouped in the same
switch case, both calling `processStripeEvent(data, eventType)`, which
forks internally in the service.

## Expected recovery flow

User pays overdue invoice
→ Stripe fires invoice.paid
→ Handler finalizes past-due draft invoices (auto_advance: true)
→ Stripe auto-charges them
→ Subscription becomes active
→ customer.subscription.updated fires
→ Existing logic unsuspends workspace
→ suspendedAt refreshed (resets deletion countdown while payments
cascade)
2026-03-20 14:19:53 +00:00
Charles BochetandGitHub 91793ef930 fix: update workspace members state on profile onboarding completion (#18799)
## Summary

Fixes #18610

After completing the CreateProfile onboarding step,
`currentWorkspaceMembersState` (plural, used by `useActorFieldDisplay`
to resolve "Created by" names) was never updated with the new name —
only `currentWorkspaceMemberState` (singular) was. This caused "Created
by" fields to display "Untitled" for the remainder of the session.

**Root cause analysis:**

The issue reporter attributed the bug to empty
`core.user.firstName/lastName`, but `createdBy` display doesn't use
`core.user` at all. The actual flow:

1. Sign-up creates workspace member with empty names (copied from empty
`core.user`)
2. `GetCurrentUserDocument` loads `currentWorkspaceMembersState` with
empty names
3. CreateProfile step updates workspace member in DB +
`currentWorkspaceMemberState` (singular) ✓
4. `currentWorkspaceMembersState` (plural) is **never refreshed** — the
query is skipped once `currentUser` exists
5. `useActorFieldDisplay` looks up from the stale plural state → empty
name → "Untitled"

**Fix:** Update `currentWorkspaceMembersState` alongside
`currentWorkspaceMemberState` in the CreateProfile submit handler.
2026-03-20 14:02:47 +00:00
Charles BochetandGitHub 217adc8d17 fix: add missing LEFT JOIN for relation fields in groupBy orderByForRecords (#18798)
## Summary

Fixes "missing FROM-clause entry for table" SQL error when using
`orderByForRecords` with a relation field (e.g. `{ company: { name:
"AscNullsFirst" } }`) in a `groupBy` query.

### Root cause

This is a regression from #18005 (Feb 17). That PR correctly removed a
duplicate `applyOrderToBuilder()` call on the groupBy subquery (which
was conflicting with the `ROW_NUMBER() OVER (... ORDER BY ...)` window
function), but in doing so it also removed the LEFT JOINs that
`applyOrderToBuilder` was adding for relation fields.

After that change, ordering relied solely on `getOrderByRawSQL()` inside
`applyPartitionByToBuilder()`, which builds raw SQL for the window
function but never added the required JOINs. Scalar field ordering (e.g.
`name`, `position`) kept working since those don't need JOINs, but
relation field ordering (e.g. `company.name`) broke.

### Fix

- `getOrderByRawSQL` now returns `relationJoins` alongside the SQL
string so callers get the join info they need
- `applyPartitionByToBuilder` in `GroupByWithRecordsService` adds the
required LEFT JOINs before building the `ROW_NUMBER()` window function,
mirroring the pattern used by `applyOrderToBuilder` in the `findMany`
path

## Test plan

- [x] Manually tested locally with a GraphQL query matching the
customer's failing pattern (`orderByForRecords: [{ company: { name:
"AscNullsFirst" } }]`)
- [x] Added integration tests for ascending and descending relation
field ordering in `group-by-with-records-resolver.integration-spec.ts`
- [x] Typecheck passes
- [x] Lint passes
- [x] CI green
2026-03-20 14:02:35 +00:00
Thomas TrompetteandGitHub 698c15b8cd Avoid side panel tab overriden by show page (#18800)
Missing prop so layout are considered as the same
2026-03-20 13:56:02 +00:00
Charles BochetandGitHub 34b3158308 fix: backfill missing ids on SELECT/MULTI_SELECT field metadata options (#18797)
## Summary
- Some production workspaces have `SELECT` or `MULTI_SELECT`
fieldMetadata options that are missing the `id` property (e.g.
`[{"color":"yellow","label":"Draft","value":"DRAFT","position":0},
...]`).
- Adds a new upgrade command
`upgrade:1-20:backfill-select-field-option-ids` that queries all
SELECT/MULTI_SELECT fieldMetadata per workspace, detects options missing
an `id`, and backfills them with a UUID v4.
- The command is idempotent (no-op when all options already have ids),
supports `--dry-run`, and invalidates workspace caches after patching.
2026-03-20 13:08:55 +00:00
Charles BochetandGitHub 66be7c3ef4 fix: autogrow input sizing + surface nested error details in upgrade command (#18796)
## Summary

### Fix 1: Autogrow input unclickable when value is empty string
- Fixes the last name input on the Person record show page being
unclickable on regular screens

### Fix 2: Surface nested migration errors in add-missing-system-fields
command
- `AddMissingSystemFieldsToStandardObjectsCommand` calls
`workspaceMigrationRunnerService.run()` directly and was re-throwing
`WorkspaceMigrationRunnerException` without reading its nested `errors`
- Extracted `getNestedErrorMessages()` helper (reused by both
`isUniqueViolationError` and `enrichErrorMessage`)
- Both catch sites now call `enrichErrorMessage()` before re-throwing,
appending nested error details to the message

**Before:**
```
ERROR [UpgradeCommand] Error in workspace ...: Migration action 'create' for 'fieldMetadata' failed
ERROR [UpgradeCommand] undefined
```

**After:**
```
ERROR [UpgradeCommand] Error in workspace ...: Migration action 'create' for 'fieldMetadata' failed (metadata: duplicate key value violates unique constraint "...")
```

## Test plan
- [x] Lint passes
- [x] Typecheck passes
- [x] Verify upgrade command errors now include nested error details
- [x] Verify autogrow input is clickable when value is empty string
2026-03-20 12:22:27 +00:00
Félix MalfaitandGitHub 0a6b514898 fix: remove redundant cookie write that made tokenPair a session cookie (#18795)
## Summary

- Removes a redundant direct `cookieStorage.setItem('tokenPair', ...)`
call in `handleSetAuthTokens` that was overwriting the Jotai-managed
cookie (which has a 180-day expiry) with a session cookie (no expiry)
- This caused users to be logged out whenever their browser fully
closed, instead of staying authenticated for 180 days

## Root cause

In April 2025 (`a7e6564017`), a direct `cookieStorage.setItem` call was
added alongside `setTokenPair()` as a workaround because Recoil's
`onSet` effect fired too late for the Apollo client to read the token
synchronously.

In February 2026 (`674f4353cd`), `tokenPairState` was migrated from
Recoil to Jotai's `atomWithStorage`, which writes the cookie
**synchronously** with a 180-day `expires`. The old direct write was
left in place and now runs *after* the Jotai write, overwriting the
cookie without an `expires` — making it a session cookie.

## Test plan

- [ ] Log in to the app
- [ ] Inspect the `tokenPair` cookie in DevTools → Application → Cookies
- [ ] Verify the cookie has an expiration date ~180 days from now (not
"Session")
- [ ] Close and reopen the browser — confirm you remain logged in


Made with [Cursor](https://cursor.com)
2026-03-20 12:03:30 +01:00
Thomas TrompetteandGitHub ffb02a6878 Improve workflow metrics with faillure reason (#18768)
- split workflow errors into system and user errors
- add workspace id to attributes for debugging
2026-03-20 10:04:40 +00:00
3cbd9f2b5d i18n - translations (#18786)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-20 00:39:40 +01:00
Charles BochetandGitHub cee4cf6452 feat: migrate ConnectedAccount infrastructure entities to metadata schema (#18784)
## Summary

- Migrates 4 entities (`connectedAccount`, `messageChannel`,
`calendarChannel`, `messageFolder`) from per-workspace schemas to the
shared `core` metadata schema
- Introduces a `IS_CONNECTED_ACCOUNT_MIGRATED` feature flag to control
the migration: when enabled, reads come from core metadata and all
writes are dual-written to both workspace and core
- Extracts 12 enums from workspace entity files to `twenty-shared` for
reuse across frontend and backend
- Creates new TypeORM entities, metadata services, GraphQL
resolvers/DTOs, and exception interceptors per entity
- Each entity owns its own data access module
(`ConnectedAccountDataAccessModule`, `MessageChannelDataAccessModule`,
`CalendarChannelDataAccessModule`, `MessageFolderDataAccessModule`) — no
umbrella infrastructure module
- Adds a 1.20 upgrade command that backfills data from workspace schemas
to core (preserving UUIDs) and enables the feature flag
- Replaces direct repository access with data access service calls
across ~50 files in messaging, calendar, and connected-account modules
- Adds `lastSignedInAt` and `oidcTokenClaims` fields to the new
`ConnectedAccountEntity`
- Drops unused `lastSyncHistoryId` field from the migrated connected
account entity

## Test plan

- [x] Lint passes (`npx nx lint:diff-with-main twenty-server`)
- [x] Typecheck passes (`npx nx typecheck twenty-server`)
- [x] All unit tests pass (477 suites, 4267 tests, 0 failures)
- [ ] Manual test: verify messaging sync works with feature flag
disabled (existing behavior)
- [ ] Manual test: run upgrade command on a workspace, verify data
backfilled to core tables
- [ ] Manual test: verify messaging/calendar sync works with feature
flag enabled (dual-write path)
- [ ] Manual test: verify GraphQL metadata resolvers return correct data
when flag enabled
2026-03-20 00:34:58 +01:00
Abdul RahmanandGitHub cd594ce8bd Fix: Hide object from Opened section when it already has a workspace nav item (#18766)
Previously, the Opened section was always hidden for all workflow
objects. We now base the “in nav” check on the full set of object
metadata IDs that have a workspace nav item, instead of only active
non-system objects. So: if an object has a nav item (e.g. workflow
runs), it no longer appears under Opened; if it doesn’t, it can appear
there. The hook was simplified to only expose
`objectMetadataIdsInWorkspaceNav`, and the unused return value was
removed.
2026-03-19 21:57:11 +00:00
26139ee463 fix: stop event propagation when removing file in AI chat preview (#18779)
## Summary

When clicking the ✕ button on an uploaded file in the AI chatbot context
preview, the click event bubbled up to the `StyledClickableContainer`
parent, which triggered `handleClick` (opening the file preview modal)
instead of — or in addition to — calling `onRemove`.

**Root cause:** `StyledClickableContainer` has `onClick={handleClick}`
at the div level. The `AvatarOrIcon` (X button) `onClick={onRemove}`
callback didn't stop propagation, so the event continued to bubble and
opened the preview.

**Fix:** Wrap `onRemove` in a `handleRemove` callback that calls
`e.stopPropagation()` before invoking the original handler.

Fixes #18298

---------

Co-authored-by: victorjzq <zhiqiangjia@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-19 19:07:19 +00:00
8005b35b56 Fix relation connect where failing on mixed-case email and URL (#18605)
Related to issue #17711 
Follow-up to PR #17774  which fixed the frontend link normalization only
## Summary
- Normalize `primaryEmail` (lowercase) and `primaryLinkUrl` in relation
`connect.where` composite values
- Applied in both frontend spreadsheet import and backend
`DataArgProcessorService` to cover all entry points (UI import, GraphQL
API)

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-03-19 17:56:26 +00:00
Abdul RahmanandGitHub 02bfebccc2 Fix: cascade delete favorite folder children on backend (#18765) 2026-03-19 17:55:28 +00:00
Thomas TrompetteandGitHub b86e6189c0 Remove postition from Timeline Activities + fix workflow title placeholder (#18777)
- Position were not properly displayed because we never implemented a
display for this
- Untitled placeholder was not displayed anymore
<img width="359" height="117" alt="Capture d’écran 2026-03-19 à 17 11
25"
src="https://github.com/user-attachments/assets/64c90d81-8262-4176-ae25-804748e36b1e"
/>
2026-03-19 17:25:35 +00:00
a9f8a7e1fa Fix: prevent record navigation when clicking Remove from favorite in nav sidebar (#18760)
https://github.com/user-attachments/assets/96abf04d-726a-4225-846e-e5d701a583a2

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-19 14:29:12 +00:00
5526d2e5d0 Separate metadata and object record publisher (#18740)
- Separate both publishers
- Renaming
- Removal of the old SSE endpoint

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-19 13:24:38 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Charles Bochet
0d27a255a9 chore(deps): bump nodemailer from 7.0.11 to 7.0.13 (#18754)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 7.0.11
to 7.0.13.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/nodemailer/nodemailer/releases">nodemailer's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.13</h2>
<h2><a
href="https://github.com/nodemailer/nodemailer/compare/v7.0.12...v7.0.13">7.0.13</a>
(2026-01-27)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>downgrade transient connection error logs to warn level (<a
href="https://github.com/nodemailer/nodemailer/commit/4c041db85d560e98bc5e1fd5d5a191835c5b7d2f">4c041db</a>)</li>
</ul>
<h2>v7.0.12</h2>
<h2><a
href="https://github.com/nodemailer/nodemailer/compare/v7.0.11...v7.0.12">7.0.12</a>
(2025-12-22)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>added support for REQUIRETLS (<a
href="https://redirect.github.com/nodemailer/nodemailer/issues/1793">#1793</a>)
(<a
href="https://github.com/nodemailer/nodemailer/commit/053ce6a772a7c608e6bee7f58ebe9900afbd9b84">053ce6a</a>)</li>
<li>use 8bit encoding for message/rfc822 attachments (<a
href="https://github.com/nodemailer/nodemailer/commit/adf86113217b23ff3cd1191af5cd1d360fcc313b">adf8611</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md">nodemailer's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/nodemailer/nodemailer/compare/v7.0.12...v7.0.13">7.0.13</a>
(2026-01-27)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>downgrade transient connection error logs to warn level (<a
href="https://github.com/nodemailer/nodemailer/commit/4c041db85d560e98bc5e1fd5d5a191835c5b7d2f">4c041db</a>)</li>
</ul>
<h2><a
href="https://github.com/nodemailer/nodemailer/compare/v7.0.11...v7.0.12">7.0.12</a>
(2025-12-22)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>added support for REQUIRETLS (<a
href="https://redirect.github.com/nodemailer/nodemailer/issues/1793">#1793</a>)
(<a
href="https://github.com/nodemailer/nodemailer/commit/053ce6a772a7c608e6bee7f58ebe9900afbd9b84">053ce6a</a>)</li>
<li>use 8bit encoding for message/rfc822 attachments (<a
href="https://github.com/nodemailer/nodemailer/commit/adf86113217b23ff3cd1191af5cd1d360fcc313b">adf8611</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/nodemailer/nodemailer/commit/893119505aa25723dd9a7d90c8dfd223d28a0cc4"><code>8931195</code></a>
chore(master): release 7.0.13 (<a
href="https://redirect.github.com/nodemailer/nodemailer/issues/1798">#1798</a>)</li>
<li><a
href="https://github.com/nodemailer/nodemailer/commit/9398d633d2c1bf5bf08a0e258ff17d5d7a45f0e6"><code>9398d63</code></a>
Bumped deps</li>
<li><a
href="https://github.com/nodemailer/nodemailer/commit/4c041db85d560e98bc5e1fd5d5a191835c5b7d2f"><code>4c041db</code></a>
fix: downgrade transient connection error logs to warn level</li>
<li><a
href="https://github.com/nodemailer/nodemailer/commit/a208a0bc86a315d037d5be8849fab5862c488baa"><code>a208a0b</code></a>
chore(master): release 7.0.12 (<a
href="https://redirect.github.com/nodemailer/nodemailer/issues/1785">#1785</a>)</li>
<li><a
href="https://github.com/nodemailer/nodemailer/commit/053ce6a772a7c608e6bee7f58ebe9900afbd9b84"><code>053ce6a</code></a>
fix: added support for REQUIRETLS (<a
href="https://redirect.github.com/nodemailer/nodemailer/issues/1793">#1793</a>)</li>
<li><a
href="https://github.com/nodemailer/nodemailer/commit/adf86113217b23ff3cd1191af5cd1d360fcc313b"><code>adf8611</code></a>
fix: use 8bit encoding for message/rfc822 attachments</li>
<li>See full diff in <a
href="https://github.com/nodemailer/nodemailer/compare/v7.0.11...v7.0.13">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=nodemailer&package-manager=npm_and_yarn&previous-version=7.0.11&new-version=7.0.13)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-19 09:22:17 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ef2a113a16 chore(deps): bump @dagrejs/dagre from 1.1.3 to 1.1.8 (#18753)
Bumps [@dagrejs/dagre](https://github.com/dagrejs/dagre) from 1.1.3 to
1.1.8.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/dagrejs/dagre/commit/7e4d15f191678f7f05f3c86d9071a193230e7e00"><code>7e4d15f</code></a>
Building for release</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/d3908e2c13148c9143db585accc10ae0b6634657"><code>d3908e2</code></a>
Bumping version</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/ce295f8e073c4fe96c9e36ecf08ae2940e5e6a10"><code>ce295f8</code></a>
Build for release</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/b64b9057726eee17f24f73579ba0668527276448"><code>b64b905</code></a>
Bumping the version</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/de169d24c13d06c1e9c560f4f4f8f98650109b94"><code>de169d2</code></a>
Merge pull request <a
href="https://redirect.github.com/dagrejs/dagre/issues/481">#481</a>
from Nathan-Fenner/nf/improve-network-simplex-perform...</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/065e0d8374f4c1c35a7cb4b84df37aaa31598d86"><code>065e0d8</code></a>
improve performance of graph node ranking</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/00d3178d671e49de9c032e3abd281dc9f2739e73"><code>00d3178</code></a>
Typo</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/3982a69d2b323b06aa969a4ec09829d37fe6e7bd"><code>3982a69</code></a>
Bump version and set as pre-release</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/1339f5516508dba0cbcc4ef1c0587e7384bec23d"><code>1339f55</code></a>
Building for release</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/9459f01bc815f16b87db727821d8401acbad2cd3"><code>9459f01</code></a>
Bumping the version</li>
<li>Additional commits viewable in <a
href="https://github.com/dagrejs/dagre/compare/v1.1.3...v1.1.8">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@dagrejs/dagre&package-manager=npm_and_yarn&previous-version=1.1.3&new-version=1.1.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 09:11:58 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e0e25eac2b chore(deps): bump @ai-sdk/mistral from 3.0.20 to 3.0.25 (#18755)
Bumps [@ai-sdk/mistral](https://github.com/vercel/ai) from 3.0.20 to
3.0.25.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vercel/ai/commit/4c1613ac8ff9f638da3a25fa6a1aa40362eb0e7c"><code>4c1613a</code></a>
Version Packages (<a
href="https://redirect.github.com/vercel/ai/issues/13131">#13131</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/64ac0fdd80da66d1e920a8a6309ebe05b9d686ad"><code>64ac0fd</code></a>
Backport: fix(security): validate redirect targets in download functions
to p...</li>
<li><a
href="https://github.com/vercel/ai/commit/f622bf85c114f810b00dd2825b56e044bce578a7"><code>f622bf8</code></a>
Version Packages (<a
href="https://redirect.github.com/vercel/ai/issues/13126">#13126</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/e2a59ef9273ef2ad97c8aec5bf5c0a7b9e8c436c"><code>e2a59ef</code></a>
Backport: fix(provider/google): preserve groundingMetadata when streamed
befo...</li>
<li><a
href="https://github.com/vercel/ai/commit/ebf43a6765e6fa337e8d5b6cf065625a1794c21c"><code>ebf43a6</code></a>
Version Packages (<a
href="https://redirect.github.com/vercel/ai/issues/13120">#13120</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/258900473214256af40b035b66f15362cbc53287"><code>2589004</code></a>
Backport: feat(openai): add GPT-5.4 model support (<a
href="https://redirect.github.com/vercel/ai/issues/13117">#13117</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/d23121fd71036611e0142a9233de4d1a1f5d54c0"><code>d23121f</code></a>
Backport: chore(ai): add optional ChatRequestOptions to
`addToolApprovalRespo...</li>
<li><a
href="https://github.com/vercel/ai/commit/55a2acf625c78d5c68ac2abc02bc26c3cd0ffa6e"><code>55a2acf</code></a>
Version Packages (<a
href="https://redirect.github.com/vercel/ai/issues/13101">#13101</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/45d71c381b756e03310f81bdd322aa7460cb5a15"><code>45d71c3</code></a>
Backport: fix(google): use VALIDATED function calling mode when any tool
has ...</li>
<li><a
href="https://github.com/vercel/ai/commit/ee92dc766422fffde3bae06437510296c4e860a8"><code>ee92dc7</code></a>
ci(release): add <code>--tag ai-v6</code> to <code>ci:release</code>
script (<a
href="https://redirect.github.com/vercel/ai/issues/13096">#13096</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vercel/ai/compare/@ai-sdk/mistral@3.0.20...@ai-sdk/mistral@3.0.25">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@ai-sdk/mistral&package-manager=npm_and_yarn&previous-version=3.0.20&new-version=3.0.25)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 09:10:58 +00:00
Charles BochetandGitHub 8ab8f80687 fix: use unique concurrency group per merge queue entry (#18756)
## Summary

- Fixes merge queue PRs blocking each other by changing the concurrency
group in `ci-merge-queue.yaml`
- The old concurrency group used `merge_group.base_ref` which resolves
to `refs/heads/main` for every PR, causing all merge queue entries to
serialize behind a single concurrency slot
- Now uses `github.ref` (unique per entry:
`refs/heads/gh-readonly-queue/main/pr-NUMBER-SHA`), matching what all
other CI workflows already do

## Recommended ruleset changes (in GitHub Settings > Rules > Rulesets >
"CI Status Checks")

- **Grouping strategy**: Switch `ALLGREEN` to `NONE` -- each PR is still
tested against the correct base (including all PRs ahead of it in the
queue), but failures only affect the failing PR instead of ejecting the
entire group. `max_entries_to_build: 5` still allows parallel
speculative testing.
- **`min_entries_to_merge_wait_minutes`**: Reduce from 5 to 1 -- the
5-minute wait adds unnecessary latency to every merge.

## Test plan

- [ ] Enqueue 2+ PRs in the merge queue and verify both trigger e2e
tests in parallel instead of one blocking the other
2026-03-19 10:10:47 +01:00
a370a26b79 i18n - docs translations (#18749)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-19 09:36:37 +01:00
martmullandGitHub 2f095c8903 Scaffold light twenty app dev container (#18734)
as title
2026-03-18 20:10:54 +01:00
Charles BochetandGitHub 394a3cef15 fix: restore ViewFilter value stringification lost in converter removal (#18745)
## Summary

- **Restores `convertViewFilterValueToString()` calls** that were lost
when the converter layer was removed in #18667. The GraphQL
`ViewFilter.value` is typed as `JSON` (can be a string, array, or
object), but the frontend type system expects a `string`. Without
stringification, SELECT/MULTI_SELECT filter values (e.g. `['LOST']`)
reach `arrayOfStringsOrVariablesSchema` as raw arrays, causing a
`ZodError: expected string, received array`.
- **Fixes applied at two data boundary points**: `splitViewWithRelated`
(primary entry from metadata store) and `mapViewFiltersToFilters` (which
also accepts `GqlViewFilter[]` directly).

Fixes a production regression introduced by #18667.

## Test plan

- [ ] Apply a SELECT filter (e.g. filter Opportunities by Stage =
"Lost") — should no longer throw ZodError
- [ ] Apply a MULTI_SELECT filter — should work correctly
- [ ] Verify filters with multiple selected values work (e.g. Stage is
"Lost" or "Won")
- [ ] Verify empty filters and "is not" operands still work
- [ ] Verify filters loaded from saved views still work after page
refresh


Made with [Cursor](https://cursor.com)
2026-03-18 19:06:43 +01:00
189c564840 i18n - translations (#18742)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-18 18:19:45 +01:00
Abdul RahmanandGitHub 6d4d1a97bc Migrate object permission to syncable entity (#18609)
Closes [#2223](https://github.com/twentyhq/core-team-issues/issues/2223)
2026-03-18 16:32:18 +00:00
BugIsGodandGitHub 8ccaf635bc fix: replace > button with button selector in styled wrappers (#18568)
fix CSS selector > button not reaching Button component's inner element
### Reproduce
- when you click Data model and create a new field, you will see this
bug.
- When you import record and check the height of remove button in the
final validation step.

### Root Reason
the Button component internally renders a wrapper div around the
<button> element, so > button only reaches the intermediate div, not the
actual button.

### Fix
- padding-right not applied → chevron overlapped with text
- ValidationStep: height: 24px not applied → Remove button was 32px
instead of 24px

<img width="1288" height="376" alt="image"
src="https://github.com/user-attachments/assets/885cd8b0-1fe2-484a-8425-70f52b784ecb"
/>

<img width="1001" height="291" alt="image"
src="https://github.com/user-attachments/assets/0b489478-8fbc-4f7e-886a-38012eeb07ef"
/>
2026-03-18 15:34:33 +00:00
91d6a69bf8 i18n - translations (#18738)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-18 16:18:52 +01:00
Félix MalfaitandGitHub c6f11d8adb fix: migrate driver modules to DriverFactoryBase lazy-loading pattern (#18731)
## Summary

- Migrates `LogicFunctionModule`, `CodeInterpreterModule`, and
`CaptchaModule` from the `forRootAsync` + injection token pattern to the
`DriverFactoryBase` lazy-loading pattern (matching `EmailModule` and
`FileStorageModule`)
- Fixes #18724 where `LOGIC_FUNCTION_TYPE` was not respected in worker
processes because the driver was created at module boot time before the
DB config cache was loaded
- Removes `isEnvOnly` from `LOGIC_FUNCTION_TYPE`,
`CODE_INTERPRETER_TYPE`, `CAPTCHA_DRIVER`, `IS_MULTIWORKSPACE_ENABLED`,
and `FRONTEND_URL` — these can now be safely configured via the database
at runtime

## How it works

Each migrated module now uses a `DriverFactory` (extending
`DriverFactoryBase`) instead of a module-level async factory + Symbol
injection token:

1. **Lazy creation**: `getCurrentDriver()` creates the driver on first
call, after `DatabaseConfigDriver.onModuleInit()` has loaded the DB
cache
2. **Auto-recreation**: If config changes in the DB, the next
`getCurrentDriver()` call detects the key mismatch and creates a new
driver instance
3. **Unified config**: Both server and worker read from the same
database — driver config only needs to be set once

### Files deleted (old pattern)
- `logic-function-module.factory.ts`,
`logic-function-drivers.module.ts`, `logic-function-driver.constants.ts`
- `code-interpreter-module.factory.ts`
- `captcha.module-factory.ts`, `captcha-driver.constants.ts`

### Files created (new pattern)
- `logic-function-driver.factory.ts`
- `code-interpreter-driver.factory.ts`
- `captcha-driver.factory.ts`

Net: **-150 lines**

## Test plan

- [x] `npx nx typecheck twenty-server` passes
- [x] `npx nx lint:diff-with-main twenty-server` passes
- [ ] Integration tests pass (`npx nx run
twenty-server:test:integration:with-db-reset`)
- [ ] Verify logic functions execute in workflow runs (the original bug)
- [ ] Verify code interpreter works in workflow code steps
- [ ] Verify captcha validation works on sign-up (when captcha is
configured)


Made with [Cursor](https://cursor.com)
2026-03-18 16:00:45 +01:00
fe4512f80c Add record page layout tabs editing (#18702)
- Fix duplication of tabs in dashboards that didn't open the duplicated
tab in the side panel: replaced the logic that used Math.round with an
algorithm that switches the positions of the elements. We will keep
relying on integers, but it will work well in all cases.
- Make dnd work with record page layouts, where there is a pinned tab
- Make tab movements work with pinned tab, too
- Allow user to set a tab as the pinned tab
- Make backend changes to be able to save tabs with `layoutMode`



https://github.com/user-attachments/assets/ce1130fa-71df-49ba-ba2f-6f971e15dd49

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 14:48:23 +00:00
Raphaël BosiandGitHub f2fa958f20 Add progress tracking to command menu items (#18732)
Introduces a progress indicator for command menu items (both engine
commands and front components). When a command is running, the menu item
now displays a percentage alongside the loader spinner instead of just a
spinner.

- Added `commandMenuItemProgressFamilyState` to track per-item progress
and `CommandListItemLoader` to render it
- Exposed `updateProgress` in the twenty-sdk public API so front
components can report execution progress back to the host
- Wired progress reporting into `ExportMultipleRecordsCommand` as the
first consumer, showing CSV export progress
- Progress state is cleaned up on unmount for both engine commands and
headless front components
- Refactor
2026-03-18 14:37:27 +00:00
Raphaël BosiandGitHub eb51f8e6da Remove front component and command menu item old seeds (#18737)
Deprecated seeds
2026-03-18 14:07:56 +00:00
Félix MalfaitandGitHub b6c62b3812 fix(auth): use dynamic SSE headers and add token renewal retry logic (#18706)
## Summary

- **Dynamic SSE headers**: The `graphql-sse` client was created with a
static `Authorization` header captured at creation time. When the access
token refreshed, the SSE client kept using the expired token on every
reconnection attempt, causing up to 10 wasted retries before the client
was disposed and recreated. Now `headers` is passed as a function that
reads the latest token from the Jotai store on each connection attempt.

- **Token renewal retry with error classification**:
`handleTokenRenewal` previously had zero retry tolerance — any failure
during `renewToken` (including transient network errors, server 500s, or
timeouts) triggered an immediate full logout via
`onUnauthenticatedError()`. Now the renewal retries up to 3 times with
linear backoff for transient errors. Only explicit server rejections
(`CombinedGraphQLErrors`, e.g. expired/revoked refresh token) skip
retries and proceed to logout immediately.

- **Preserved error types in `renewTokenMutation`**: The old code caught
all errors and re-threw them as a generic `new Error('Something went
wrong...')`, destroying the original error type. Callers couldn't
distinguish a GraphQL auth rejection from a network failure. Now errors
propagate with their original type.

- **Simplified SSE retry handler**: With dynamic headers handling token
freshness automatically, the retry handler no longer needs the
`initialTokenForSseClient` comparison to detect token mismatches. It now
only resets the SSE client when the user has logged out (no token) or
after 10+ consecutive failures.

## Test plan

- [ ] Log in, wait for access token to expire (~30 min or configure
shorter expiry), verify no unexpected logout occurs
- [ ] Simulate transient network failure during token renewal (e.g.
throttle network in devtools), verify the retry logic recovers without
logging out
- [ ] Verify SSE real-time updates continue working after a token
refresh
- [ ] Verify genuine logout still works when refresh token is actually
invalid/expired
- [ ] Open multiple browser tabs, verify token refresh works correctly
across all tabs without "suspicious activity" revocation


Made with [Cursor](https://cursor.com)
2026-03-18 15:08:30 +01:00
c676e46a1d i18n - translations (#18736)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-18 14:48:17 +01:00
Abdul RahmanandGitHub c7e89a18f0 Migrate permission flag to syncable entity (#18567)
Closes [#2225](https://github.com/twentyhq/core-team-issues/issues/2225)
2026-03-18 13:25:52 +00:00
Charles BochetandGitHub 58336fb70f fix: navigation menu item type backfill and frontend loading (#18730)
## Summary

- Move `BackfillNavigationMenuItemTypeCommand` from the 1-19 to the 1-20
upgrade path and split the DB transaction into two phases (data
backfill, then schema changes) to avoid the PostgreSQL error "cannot
ALTER TABLE because it has pending trigger events."
- Fix backfill logic to prefer `OBJECT` over `VIEW` for navigation menu
items that have `targetObjectMetadataId`, and correct already mis-typed
items. Tighten the `CHECK` constraint to enforce `viewId IS NULL` for
`OBJECT` type items.
- On the frontend, force `navigationMenuItems` into `staleEntityKeys`
when the server's `minimalMetadata` response omits the collection hash
(happens when the Redis cache hasn't been warmed after an upgrade),
ensuring the sidebar loads navigation items.

## Test plan

- [ ] Upgrade from 1.18 or 1.19 to 1.20 and verify the migration
completes without errors
- [ ] Verify navigation menu items of type `OBJECT` do not have a
`viewId` set in the database
- [ ] Sign out and sign in — confirm navigation menu items appear in the
sidebar on first load
- [ ] Verify `VIEW`-typed items also appear correctly in the sidebar

Made with [Cursor](https://cursor.com)
2026-03-18 11:56:19 +01:00
neo773andGitHub 87efaf2ff8 workspace:export command (#18695)
This PR adds a `workspace:export` server command for ongoing debug
tooling/administrative work

Demo Video shows
1. Exporting a sample workspace YC
2. Restoring Local DB Docker volume snapshot to Dropped YC Workspace
3. Importing exported SQL
4. Booting and navigating the imported Workspace



https://github.com/user-attachments/assets/0e1ac6cb-8ce1-440b-8b56-f81dcb27a9c8
2026-03-18 10:40:12 +00:00
Raphaël BosiandGitHub dedcf4e9b9 Support template variables in command menu item labels (#18707)
- Adds template variable interpolation (`${...}`) to command menu item
labels and short labels, enabling dynamic text like `Create new
${capitalize(objectMetadataItem.labelSingular)}` instead of static
`Create new record`.
- Supports `capitalize` and `lowercase` transform functions within
template expressions.
2026-03-18 10:26:58 +00:00
neo773andGitHub 928194cee4 fix Draft Email stuck Callout (#18719) 2026-03-18 10:19:33 +00:00
martmullandGitHub 13a357ab9f Publish new version (#18727)
as title
2026-03-18 09:54:54 +00:00
neo773andGitHub b1a7c5c4d6 Fix null connectedAccount crash in blocklist message deletion job (#18723)
Fixes TWENTY-SERVER-DRP
2026-03-18 10:41:56 +01:00
neo773andGitHub a8c445a1c2 Treat Microsoft Graph 400 with empty body as transient error (#18726)
Graph SDK occasionally returns a 400 with a null error message which is
not a real bad request but a transient hiccup.
Classify these as temporary errors so they get retried instead of
flooding Sentry.

Fixes TWENTY-SERVER-D3X
2026-03-18 10:41:10 +01:00
Charles BochetandGitHub a74edbf715 fix: route object color through standardOverrides for standard objects (#18717)
## Summary

- Fixes object `color` to use the `standardOverrides` mechanism for
standard objects, matching how `label`, `description`, and `icon`
already work
- Previously, color was written directly to the `objectMetadata.color`
column for **both** standard and custom objects, which meant user color
customizations on standard objects could be overwritten during metadata
syncs
- Custom objects continue to have `color` updated directly on the entity
(no change)

## Changes

| File | What changed |
|------|-------------|
| `object-metadata-standard-overrides-properties.constant.ts` | Added
`'color'` to `OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES` so
`sanitizeRawUpdateObjectInput` routes color into `standardOverrides` for
standard objects |
| `resolve-object-metadata-standard-override.util.ts` | Extended to
support `'color'` as a key — handled like `icon` (no i18n/translation,
just direct override check) |
| `object-metadata.resolver.ts` | Added `@ResolveField` for `color` that
resolves through `resolveObjectMetadataStandardOverride`, matching the
existing `labelSingular`/`labelPlural`/`description`/`icon` resolve
fields |
| `flat-object-metadata-validator.service.ts` | Removed `'color'` from
`allowedOverrideKeys` for system objects since it now flows through
`standardOverrides` |
| `resolve-object-metadata-standard-override.util.spec.ts` | Added test
cases for custom object color, standard object color override, and
standard object color fallback |
| `successful-update-one-standard-object-metadata.integration-spec.ts` |
Added `'when updating color'` test case, included `color` in GraphQL
queries and `standardOverrides` fragment, reset color in `afterEach`
cleanup |

## How it works now

| Object type | Color update flow |
|---|---|
| **Custom** | Written directly to `objectMetadata.color` column |
| **Standard** | Stored in `objectMetadata.standardOverrides.color`,
resolved via `@ResolveField` at query time |

This is identical to how `label`, `description`, and `icon` have always
worked.

## Test plan

- [x] Unit tests pass
(`resolve-object-metadata-standard-override.util.spec.ts` — 21 tests)
- [x] Typecheck passes (`npx nx typecheck twenty-server`)
- [x] Lint passes (`npx nx lint:diff-with-main twenty-server`)
- [ ] Integration test snapshot regenerates correctly
(`successful-update-one-standard-object-metadata`)
- [ ] Verify standard object color editing from sidebar persists via
`standardOverrides`
- [ ] Verify custom object color editing from sidebar persists directly
on entity

Made with [Cursor](https://cursor.com)
2026-03-18 10:40:57 +01:00
Charles BochetandGitHub 0ae62898a3 fix: restructure navigationMenuItem type migration for safe upgrade path (#18722)
## Summary

- Restructures the `navigationMenuItem.type` column migration to follow
the established 3-step safe upgrade pattern (used for webhooks, files,
etc.)
- The previous migration added `type` as `NOT NULL DEFAULT 'VIEW'` with
a CHECK constraint in one step, which incorrectly assigned `VIEW` to all
existing rows regardless of their actual type (folders, records, links,
objects)
- Now: (1) migration adds column as nullable, (2) upgrade command
backfills correct type from existing columns (`viewId`,
`targetRecordId`, `targetObjectMetadataId`, `link`) and cleans
conflicting columns, (3) shared utility applies `NOT NULL` + `CHECK`
constraint

### Changes

**Modified:**
- `1773681736596-add-type-to-navigation-menu-item.ts` -- adds column as
nullable, no DEFAULT, no CHECK
- `navigation-menu-item.entity.ts` -- removed `default:
NavigationMenuItemType.VIEW` from column decorator
- `upgrade.command.ts` / `1-19-upgrade-version-command.module.ts` --
wired new command

**Created:**
- `1773681736596-makeNavigationMenuItemTypeNotNull.util.ts` -- shared
utility applying NOT NULL + CHECK constraint
- `1773822077682-make-navigation-menu-item-type-not-null.ts` --
migration calling the util with savepoint (succeeds on fresh installs,
swallows error on upgrades with NULL data)
- `1-19-backfill-navigation-menu-item-type.command.ts` -- upgrade
command that backfills type, cleans conflicting columns, then applies
constraints via the shared utility

### Execution flow

**Existing deployments (upgrade):**
1. TypeORM migration adds nullable `type` column, drops old CHECK
2. Savepoint migration fails gracefully (existing rows have NULL type)
3. 1-18 favorites migration creates items WITH correct type
4. 1-19 backfill command infers type for remaining NULL rows, cleans
conflicting columns, applies NOT NULL + CHECK

**Fresh installs:**
1. TypeORM migration adds nullable `type` column
2. Savepoint migration succeeds immediately (no data, constraints apply
cleanly)

## Test plan

- [x] `npx nx typecheck twenty-server` passes
- [x] `npx nx lint:diff-with-main twenty-server` passes
- [x] `npx nx test twenty-server` passes (477 suites, 4297 tests)
- [ ] Verify fresh database setup with `npx nx database:reset
twenty-server` applies both migrations and constraints correctly
- [ ] Verify upgrade path: existing navigation menu items get correct
type backfilled based on their columns

Made with [Cursor](https://cursor.com)
2026-03-18 10:39:50 +01:00
neo773andGitHub fb5b68f1d8 Skip threads with no participants in timeline formatting (#18725)
Threads with no FROM participants have no entry in the participants map,
causing extractParticipantSummary to receive undefined and crash

Fixes TWENTY-SERVER-FB8
2026-03-18 10:32:49 +01:00
Charles BochetandGitHub 2d4f6f8f6f fix: make 1.19 upgrade commands resilient to pre-existing data (#18716)
## Summary

- **BackfillMissingStandardViewsCommand**: When view validation fails
(e.g. a viewField references a field metadata that doesn't exist in the
workspace), log a warning and skip instead of throwing — so the
workspace upgrade continues with the remaining commands.
- **AddMissingSystemFieldsToStandardObjectsCommand**: Wrap both the
non-tsVector batch migration and each individual tsVector migration in
try-catch. If a field already exists (e.g. duplicate key on `name +
objectMetadataId + workspaceId`), the error is logged as a warning and
the command moves on to the next field.

These errors were observed during the 1.18 → 1.19 production upgrade for
workspaces with non-standard state (missing "owner" field metadata on
Opportunity, or searchVector fields already present with a different
universalIdentifier).

## Test plan

- [ ] Re-run upgrade on the affected production workspaces
- [ ] Verify upgrade completes successfully with warnings instead of
failures
- [ ] Confirm that workspaces which were already upgrading cleanly are
unaffected


Made with [Cursor](https://cursor.com)
2026-03-17 23:57:40 +01:00
Charles BochetandGitHub a82739cdb1 feat: optimistic metadata store updates for navigation menu items (#18710)
## Summary

- **Optimistic metadata store updates**: Replace `refetchQueries` with
direct `addToDraft`/`applyChanges` calls in create, update, and delete
navigation menu item mutation hooks for instant UI feedback. Client-side
UUID generation enables optimistic creates before the server responds.
- **SSE event enrichment with `targetRecordIdentifier`**: Introduce
`NavigationMenuItemRecordIdentifierService` to resolve record display
info (label, image) and enrich SSE metadata events at emission time, so
the sidebar shows record names immediately without a page refresh.
- **Centralized role permission resolution**: Add
`resolveRolePermissionConfigFromAuthContext` to `PermissionsService`,
removing duplicated role resolution logic from individual services.
- **Mutation fragments include `targetRecordIdentifier`**: Switch
create/update/delete mutations from `NavigationMenuItemFields` to
`NavigationMenuItemQueryFields` so the mutation response includes
`targetRecordIdentifier`, preventing a brief gap where RECORD favorites
are invisible in the sidebar.
- **Folder UI fixes**: Remove transparent border on
`StyledFolderContainer` that caused a 1px size inconsistency between
folder and non-folder items in Favorites. Make the folder kebab menu
hover-only instead of always visible.
2026-03-17 23:18:07 +01:00
Charles BochetandGitHub 058414fae5 Upgrade Ink to v6 and pause TUI on idle/error (#18705)
## Summary
- Upgrade `ink` from 5.1.1 to 6.8.0 in twenty-sdk (React 19 required, no
API breaking changes)
- Upgrade `react`/`react-dom` from 18 to 19 and
`@types/react`/`@types/react-dom` to 19 in twenty-sdk
- Enable `incrementalRendering` — only redraws changed lines instead of
full output, reducing flickering
- Pause the animation timer when the pipeline is not actively building
or syncing, so Ink stops re-rendering and the terminal becomes
scrollable (fixes inability to scroll up to read errors)
- Remove `AnimationProvider` context — derive animation frames from
`Date.now()` directly in `useStatusIcon`
- Export `NavigationMenuItemType` from `twenty-sdk` (re-exported from
`twenty-shared/types`)
- Add dedicated NavigationMenuItem integration tests to postcard-app
(unique positions, unique identifiers, valid object references)

## Test plan
- [ ] Run `twenty dev` and verify the TUI renders normally during
build/sync
- [ ] Trigger an error and verify the terminal output freezes and
becomes scrollable
- [ ] Verify that after fixing the error, the TUI resumes animating on
next build cycle
- [ ] Verify `import { NavigationMenuItemType } from "twenty-sdk"` works
- [ ] Run postcard-app integration tests and verify new
NavigationMenuItem tests pass
2026-03-17 20:24:39 +01:00
Thomas TrompetteGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>github-actionsCharles Bochet
994215e0dc Update store on data model mutation (#18684)
- enrich SSE events with relations
- remove queries from sse metadata events
- on sse event, manage store
- on object/field metadata changes, manage store

TODO left:
- fix other metadata items

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-17 19:32:19 +01:00
e62dfae741 i18n - translations (#18709)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-17 19:08:21 +01:00
Charles BochetandGitHub 9f7c29bce8 refactor(twenty-front): unify Favorites and Workspace navigation menu item code (#18697)
## Summary

- Consolidate duplicated Favorites and Workspace navigation menu item
frontend code into a single unified codebase within
`navigation-menu-item/`
- Move all DnD-related code from `navigation/` module into
`navigation-menu-item/display/dnd/`, renaming `workspaceDndKit*` files
to `navigationMenuItemDndKit*`
- Unify duplicated components (DnD providers, DnD hooks, folder
components, orphan items, section shell) using a `NavigationSections`
enum to parameterize section-specific behavior
- Rename residual workspace-prefixed symbols
(`WorkspaceDndKitSortableItem`, `WorkspaceDndKitDroppableSlot`,
`useWorkspaceSectionItems`, etc.) to `navigationMenuItem`-prefixed
equivalents
- Clean `navigation/` module to only contain app-level concerns (drawer
layout, settings, routing)

### Key changes

| Before (duplicated) | After (unified) |
|---|---|
| `FavoritesDndKitProvider` + `WorkspaceDndKitProvider` |
`NavigationMenuItemDndKitProvider` with `section` prop |
| `useFavoritesDndKit` + `useWorkspaceDndKit` |
`useNavigationMenuItemDndKit(section)` |
| `FavoritesFolderItem` + `WorkspaceNavigationMenuItemsFolder` |
`NavigationMenuItemFolder` with `section` prop |
| `FavoritesOrphanItems` | `NavigationMenuItemOrphanItems` with
`section` prop |
| Separate section shells | Shared `NavigationMenuItemSection` with thin
wrappers |
| `WorkspaceDndKitSortableItem` | `NavigationMenuItemSortableItem` |
| `WorkspaceDndKitDroppableSlot` | `NavigationMenuItemDroppableSlot` |
| `useWorkspaceSectionItems` | `useNavigationMenuItemSectionItems` |
| `useWorkspaceFolderOpenState` | `useNavigationMenuItemFolderOpenState`
|

Net result: **~1650 lines deleted** across 51 files.

## Test plan

- [x] `npx nx typecheck twenty-front` passes
- [x] `npx nx lint twenty-front` passes (0 errors)
- [x] `npx nx test twenty-front` passes (763 suites, 4467 tests)
- [ ] Smoke test: Favorites section renders, DnD reorder works, folder
create/rename/delete works
- [ ] Smoke test: Workspace section renders, edit mode works, DnD
reorder works
- [ ] Smoke test: Add-to-navigation from side panel works for both
sections

Made with [Cursor](https://cursor.com)
2026-03-17 19:01:09 +01:00
5a51764b8e i18n - translations (#18704)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-17 17:38:17 +01:00
Raphaël BosiandGitHub abdab2fb7e [Command menu items] Create engine commands (#18681)
## Description

- Introduces a new engine command execution model that replaces the
previous approach of mapping `EngineComponentKey` to React components.
Instead, engine commands are now mounted headlessly via
`HeadlessEngineCommandMountRoot`, with their execution context populated
synchronously before mounting.
- Creates new headless command components
- Moves error handling from the SDK layer to the host app by wrapping
all mounted commands with a new `CommandMenuItemErrorBoundary`

The new flow works as follows:
- When a command menu item with an `engineComponentKey` is clicked,
`useCommandMenuItemFrontComponentCommands` calls
`useMountEngineCommand`, which synchronously reads the current context
store (object metadata, selected records, filters, view ID, etc.) and
writes a `MountedEngineCommandContext` into
`mountedEngineCommandsState`.
- The command is then mounted into `mountedEngineCommandsState`, which
triggers `HeadlessEngineCommandMountRoot` to render the corresponding
headless component from `ENGINE_COMPONENT_KEY_HEADLESS_COMPONENT_MAP`,
wrapped in `CommandMenuItemErrorBoundary`,
`ContextStoreComponentInstanceContext.Provider`, and
`EngineCommandComponentInstanceContext.Provider`.
- Each command component reads its execution context and delegates to
one of the 4 execution patterns: `HeadlessEngineCommandWrapperEffect`
(simple actions), `HeadlessConfirmationModalEngineCommandEffect`
(destructive actions needing confirmation),
`HeadlessNavigateEngineCommand` (GO_TO_* commands), or
`HeadlessOpenSidePanelPageEngineCommand` (SEARCH_RECORDS, ASK_AI,
VIEW_PREVIOUS_AI_CHATS).
- After execution, the command self-unmounts via
`useUnmountEngineCommand`, which removes the entry from
`mountedEngineCommandsState` and stops rendering the component.
2026-03-17 17:25:18 +01:00
bee474afdf i18n - translations (#18703)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-17 16:47:38 +01:00
nitinandGitHub ab881350b2 refactor: unify layout customization mode (record pages + navigation) (#18640)
### What

Unifies record page layout editing and navigation menu editing into a
single global "layout customization" session. Dashboard editing stays
separate.

  ### How it works

  **Two edit mode systems, one context-based read:**
- `isLayoutCustomizationModeEnabledState` -- global atom for record
pages + navigation
- `isDashboardInEditModeComponentState` -- dashboard-only, independent
per-component atom
- `PageLayoutEditModeProvider` -- context that dispatches to
`RecordPageLayoutEditModeProvider` (reads global atom) or
`DashboardPageLayoutEditModeProvider` (reads component atom), one
component per file

  **Session registry + independent atoms:**
- `activeCustomizationPageLayoutIdsState` -- accumulates page layout IDs
as user navigates during customization (`string[]`)
- Save/cancel iterate the ID list and read each layout's draft/persisted
atoms independently
- Follows the same pattern as `settingsRoleIdsState` +
`settingsDraftRoleFamilyState`

  **Unified UI:**
- `LayoutCustomizationBar` replaces the old `NavigationMenuEditModeBar`
- Enter once -- edit record layouts + navigation -- save/cancel
everything together
- `useSaveLayoutCustomization` orchestrates sequential save: navigation
draft -- page layouts -- field widget groups
- Error snackbar on partial save failure (with TODO for future atomic
server mutation)

  **Draft protection during customization:**
- `PageLayoutRelationWidgetsSyncEffect` guarded -- only updates
persisted state from server, skips draft/currentLayouts while
customization is active
- `useExecuteTasksOnAnyLocationChange` skips draft reset when
customization mode is enabled
  - Command execution blocked during layout customization

  ### Cleanup
- Deleted `NavigationMenuEditModeBar`,
`isNavigationMenuInEditModeState`,
`isPageLayoutInEditModeComponentState`,
`useIsGlobalLayoutCustomizationActive`
- `DraftPageLayout` type changed from `Omit` to `Pick` (explicit fields)
- Removed save/cancel from `DefaultRecordCommandMenuItemsConfig` (bar
handles it now)
  - Extracted `useSaveFieldsWidgetGroups` from save orchestration
- Split `PageLayoutEditModeProvider` into 3 separate files (one
component per file, Twenty convention)

  ### Known issues
- **Stale deleted widget after save (pre-existing on `main`)**: Delete
widget -- save -- exit customization -- Apollo cache stale -- sync
effect overwrites Jotai from stale data -- widget reappears until
refresh. Separate PR needed, likely tied to the planned server-side
`saveLayoutCustomization` atomic endpoint.

  ### Open questions
- **Module location**: Layout customization hooks/states live in `/app`
-- should they move to their own `modules/layout-customization/`?
- **Atomic server mutation**: All save mutations are on metadata schema
(`createNavigationMenuItem`, `deleteNavigationMenuItem`,
`updateNavigationMenuItem`, `updatePageLayoutWithTabsAndWidgets`,
`upsertFieldsWidget`). A single `saveLayoutCustomization` endpoint could
make saves truly atomic.


https://github.com/user-attachments/assets/036ef542-97f3-485b-a68f-3726002c81fb
2026-03-17 16:24:09 +01:00
Thomas TrompetteandGitHub f38d72a4c2 Setup local instance on app creation (#18184)
Needs for `generate-api-key` command to be available on docker
2026-03-17 15:23:43 +01:00
3204175065 [FIx]: using the dynamic {objectLabelPlural} instead of hardcoded name (#18700)
Fixes #18607 

So previously i followed the frontend approach which led to
architectural mismatch.


- We already have the correct things implemented in the
`processViewNameWithTemplate` and in object metadata service. Found that
the seeder files had the hardcoded names instead of {objectLabelPlural}.
So changed all the hardcoded string to contain {objectLabelPlural} to
seed the new workspaces accurately.



- it will have a follow up PR for typeORM migration to migrate the
existing workspaces





https://github.com/user-attachments/assets/3b460f9f-c59d-49d1-8baa-17322d696ecc



I hope i am correct this time :)

Co-authored-by: Arun kumar <arunkumar@Aruns-MacBook-Air.local>
2026-03-17 14:16:23 +01:00
05a81c82a9 Add hotkeys to command menu items (#18682)
Add hotkeys column to the entity and plug it to the front end command
menu item display

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-17 14:09:19 +01:00
731e297147 Twenty sdk cli oauth (#18638)
<img width="1418" height="804" alt="image"
src="https://github.com/user-attachments/assets/de6c8222-6496-4a71-bc21-7e5e1269d5cb"
/>

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
2026-03-17 11:43:17 +01:00
111debc1ce i18n - docs translations (#18692)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-17 11:14:55 +01:00
770a685556 i18n - translations (#18696)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-17 07:01:07 +01:00
Charles BochetandGitHub f1dfcfd163 refactor(twenty-front): reorganize NavigationMenuItem module into common/display/edit subfolders (#18691)
## Summary

- Reorganizes the `navigation-menu-item` frontend module from a flat
structure into `common/`, `display/`, and `edit/` subfolders with
type-specific subdirectories (`link/`, `folder/`, `object/`, `view/`,
`record/`)
- Every file is now in a leaf folder describing its type: `components/`,
`hooks/`, `utils/`, `types/`, or `constants/`
- Moves 14 NavigationMenuItem-related components out of
`object-metadata/` and `side-panel/pages/` into the
`navigation-menu-item` module where they belong
- Creates type-specific display utility functions (e.g.,
`getLinkNavigationMenuItemLabel`,
`getObjectNavigationMenuItemComputedLink`) to replace generic
switch-based functions
- Unifies the Favorites section drag-and-drop from `@hello-pangea/dnd`
to `@dnd-kit/react`, matching the Workspace section's DnD library
- Renames Favorites section components from `CurrentWorkspaceMember*` to
`Favorites*` for clarity
- Deletes unused `FavoritesDragDropProviderContent` and
`NavbarDragProvider`

## Test plan

- [x] `npx nx typecheck twenty-front` passes
- [x] `npx nx lint:diff-with-main twenty-front` passes (0 warnings, 0
errors)
- [x] `npx nx test twenty-front` passes (763 suites, 4467 tests)
- [ ] Verify favorites drag-and-drop still works in the UI (reorder
items, move between folders)
- [ ] Verify workspace edit mode drag-and-drop still works
- [ ] Verify "add to navigation" drag from command menu/side panel still
works
2026-03-17 06:46:50 +01:00
Charles BochetandGitHub 1be87eb97b chore: frontend dead code removal and naming cleanup (#18690)
## Summary

- **Delete 10 unused files**: 7 hooks (`useWorkflowRunUnsafe`,
`useGetViewById`, `useCreateViewFieldGroup`, `useDeleteViewFieldGroup`,
`useUpdateViewFieldGroup`, `useCreateManyViewFieldGroups`,
`useMoveViewColumns` + test), 1 component (`SettingsSummaryCard`), 1
utility (`createEventContext`)
- **Rename `objectMetadataItemsState` → `objectMetadataItemsSelector`**
across ~85 files to accurately reflect it is a derived selector (via
`createAtomSelector`), not a base Jotai atom

## Details

### Dead code removed

| Type | Name | Reason |
|------|------|--------|
| Hook | `useWorkflowRunUnsafe` | Never imported — duplicate of
`useWorkflowRun` without schema validation |
| Hook | `useGetViewById` | Never imported — `useViewById` is used
instead |
| Hook | `useCreateViewFieldGroup` | Never imported — CRUD done via
`usePerformViewFieldGroupAPIPersist` |
| Hook | `useDeleteViewFieldGroup` | Same as above |
| Hook | `useUpdateViewFieldGroup` | Same as above |
| Hook | `useCreateManyViewFieldGroups` | Same as above |
| Hook | `useMoveViewColumns` | Only imported by its own test — no
production usage |
| Component | `SettingsSummaryCard` | Never imported anywhere |
| Utility | `createEventContext` | Never imported anywhere |

### Rename

`objectMetadataItemsState` is created via `createAtomSelector` (it
derives from `objectMetadataItemsWithFieldsSelector`), so naming it
`*State` is misleading. Renamed to `objectMetadataItemsSelector` for
consistency with sibling selectors like
`objectMetadataItemsByNamePluralMapSelector`.
2026-03-17 00:49:37 +01:00
neo773andGitHub 1735c7527c fix 2FA UI layout (#18688)
/closes #18685
2026-03-17 00:33:16 +01:00
Charles BochetandGitHub 1bb642d7cd refactor: remove ProcessedNavigationMenuItem, derive display fields at point of use (#18687)
## Summary

- Removes `ProcessedNavigationMenuItem` type and the
`sortNavigationMenuItems` enrichment pipeline that pre-computed display
fields (label, link, icon, avatarUrl, etc.) for every navigation menu
item upfront
- Replaces with `filterAndSortNavigationMenuItems` (pure filter+sort
returning raw `NavigationMenuItem[]`) and small utility functions
(`getNavigationMenuItemLabel`, `getNavigationMenuItemComputedLink`,
`getNavigationMenuItemObjectNameSingular`) that components call on
demand
- Eliminates the redundant `itemType` field (was identical to the raw
`type` field) across ~30 consumer files
- Each type-specific renderer
(`NavigationDrawerItemForObjectMetadataItem`, `NavigationMenuItemIcon`,
link/folder components) now derives only the 1-2 display fields it
actually needs from the raw item + globally available Jotai atoms

Net result: -1199 / +1177 lines, 7 files deleted, 4 new utility files.
2026-03-17 00:33:06 +01:00
48285be164 i18n - translations (#18686)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-17 00:09:04 +01:00
Charles BochetandGitHub a121d00ddd feat: add color property to ObjectMetadata for object icon customization (#18672)
## Summary

- Adds a `color` column to `ObjectMetadataEntity` with full GraphQL
support so object icon colors are persisted at the metadata level
- Adds a `type` column to `NavigationMenuItemEntity` (enum: `OBJECT`,
`VIEW`, `FOLDER`, `LINK`, `RECORD`) replacing field-based type inference
- Updates frontend to read object colors from `objectMetadata.color`
(falling back to standard defaults) in the sidebar nav, record index
header, and record show breadcrumb
- Simplifies `NavigationMenuItemIcon` color resolution via
`getEffectiveNavigationMenuItemColor` util

## Color rules

| Item type | Color source | Editable in sidebar? |
|-----------|-------------|---------------------|
| **Object** | `objectMetadata.color` | Yes — persisted to
`objectMetadata.color` on Save |
| **Folder** | `navigationMenuItem.color` | Yes |
| **Link** | Fixed default (`DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK`) |
No |
| **View** | `objectMetadata.color` (from the parent object) | No |
| **Record** | None | No |

- **Object** items represent the whole object (e.g. "Companies") and
point to the INDEX view. Changing their color updates
`objectMetadata.color` via `useSaveObjectMetadataColorsFromDraft`.
- **View** items represent specific non-INDEX views. Their color comes
from the parent object's metadata (read-only).
- Only **folders** store their color on `navigationMenuItem.color` —
enforced by `hasNavigationMenuItemOwnColor` util.
- `getEffectiveNavigationMenuItemColor` returns `objectColor` for both
OBJECT and VIEW items, folder's own color for folders, and the fixed
default for links.

## NavigationMenuItemType enum

- Shared enum created in `twenty-shared` with values: `OBJECT`, `VIEW`,
`FOLDER`, `LINK`, `RECORD`
- Registered as a GraphQL enum on the backend
- Replaces string literals across entity, DTOs, input, converters, and
frontend hooks
- Migration backfills existing rows: INDEX views → `OBJECT`, non-INDEX
views → `VIEW`, based on join with the view table

## Design decisions

- **OBJECT vs VIEW distinction**: Items pointing to INDEX views are
typed as `OBJECT` (represent the whole object, color editable). Items
pointing to non-INDEX views are typed as `VIEW` (specific view, color
read-only from parent object).
- **Dual color storage**: `navigationMenuItem.color` is preserved for
folders only. Objects use `objectMetadata.color` as their source of
truth.
- **Type discriminator**: The `type` column replaces field-based
inference (checking `viewId`, `link`, `targetRecordId` presence) with an
explicit enum, simplifying `isNavigationMenuItemLink` /
`isNavigationMenuItemFolder` to simple `item.type ===` checks.
- **No settings page color picker**: Object color editing is done from
the sidebar edit panel, not the data model settings page.

## Test plan

- [ ] Verify objects display their default standard colors in the
sidebar
- [ ] Verify object color editing works in the sidebar edit panel
(persists to objectMetadata.color)
- [ ] Verify folder color editing works in the sidebar edit panel
- [ ] Verify views, links, and records do NOT show a color picker in the
sidebar edit panel
- [ ] Run `npx nx typecheck twenty-front` and `npx nx typecheck
twenty-server`
- [ ] Verify the database migrations add `color` to `objectMetadata` and
`type` to `navigationMenuItem`


Made with [Cursor](https://cursor.com)
2026-03-16 23:54:56 +01:00
087ee19807 i18n - translations (#18683)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-16 18:17:39 +01:00
Félix MalfaitandGitHub c4e55d08ff fix: allow identical singular and plural labels for objects (#18678)
## Summary

Closes #18673

Some languages (e.g., German "Unternehmen") and even English words
(sheep, deer, aircraft, series) have identical singular and plural
forms. Twenty previously blocked saving when labels matched, making it
impossible to correctly name objects in these cases.

- **Labels** are purely display strings — removed the equality
validation from both the frontend Zod schema and backend validator
- **API names** (nameSingular/namePlural) must stay different since they
generate distinct GraphQL resolvers (`findOne` vs `findMany`,
`createOne` vs `createMany`, etc.) and REST endpoints — this validation
is preserved
- Added a shared `computeMetadataNamesFromLabels` util in
`twenty-shared` that auto-appends `'s'` to the plural API name when both
labels produce the same camelCase name (e.g., "Unternehmen" →
`unternehmen` / `unternehmens`)
- Both the frontend form and backend sync-check use the same shared util
— single source of truth, no duplicated logic

**No retroactive impact**: since the old code prevented identical labels
from ever being saved, no existing workspace has `labelSingular ===
labelPlural`.

## Test plan

- [x] New unit tests for `computeMetadataNamesFromLabels` (7 tests:
standard labels, Sheep, Unternehmen, Aircraft, empty labels, different
labels, applyCustomSuffix)
- [x] Updated frontend schema validation tests (identical labels with
different names now passes; identical names still fails)
- [x] Updated backend integration test cases (removed identical-label
failing cases)
- [ ] Manual: create a new object with identical singular/plural labels
(e.g. "Sheep" / "Sheep") — should save successfully with API names
`sheep` / `sheeps`
- [ ] Manual: verify existing objects with different labels still work
unchanged


Made with [Cursor](https://cursor.com)
2026-03-16 18:07:34 +01:00
13ff7af297 Update sidebar section toggle to match Figma chevron (#18631)
Summary
- thicken the sidebar toggle chevron to align with the Figma design
- add animation for the chevron when sections open or close



https://github.com/user-attachments/assets/67bff9e1-4df5-4ff2-a48d-b761030ada51

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
2026-03-16 18:04:26 +01:00
WeikoandGitHub b6b96be603 Fix custom object view fields creation (#18629) 2026-03-16 17:24:44 +01:00
Baptiste DevessierandGitHub 93bd4960e3 Fix styles side column (#18632)
## Before



https://github.com/user-attachments/assets/7abe2131-52b8-4254-a50e-0043f6d5fbe8



## After


https://github.com/user-attachments/assets/5c350dbe-cd57-4952-ad12-29a7d3cd33fe
2026-03-16 17:17:07 +01:00
Assad RoblesandGitHub cb4efb1d0e fix: respect standard object rename across all locales (Closes #18650) (#18652)
## Fix: Standard object rename ignored when UI language is not English
(Closes #18650)

### Problem
When a user renames a standard object (for example, changing **"People"
→ "Contacts"**), the custom name is only respected when the UI language
is set to **English**.

For any other locale, the resolver ignores the user-defined override and
falls back to the i18n translation of the original default label.

As a result, the custom name defined by the user is not displayed when
the UI language changes.

### Root Cause
`resolveObjectMetadataStandardOverride` only applied direct overrides
when the locale matched `SOURCE_LOCALE` (English):

```ts
if (
  safeLocale === SOURCE_LOCALE &&
  isNonEmptyString(objectMetadata.standardOverrides?.[labelKey])
) {
  return objectMetadata.standardOverrides[labelKey] ?? '';
}
2026-03-16 17:01:04 +01:00
Charles BochetandGitHub 5dfdc1d81d refactor: consolidate database query timeout config variables (#18670)
## Summary

- Removes the redundant `DATABASE_STATEMENT_TIMEOUT_MS` config variable
(default 15s) from `ConfigVariables`
- Updates the core TypeORM datasource to use
`PG_DATABASE_PRIMARY_TIMEOUT_MS` (default 10s) for its `query_timeout`,
aligning it with the workspace datasource which already uses this
variable
- This consolidates two separate env vars that controlled the same
concern (database query timeout) into a single one
2026-03-16 15:10:38 +01:00
Thomas TrompetteandGitHub 32d7fa09a3 Fix timeline activities + breadcrumb (#18626)
Before
<img width="412" height="223" alt="Capture d’écran 2026-03-13 à 17 42
37"
src="https://github.com/user-attachments/assets/350edca5-53a3-4ff9-8c81-80d012bf2170"
/>

After
<img width="412" height="251" alt="Capture d’écran 2026-03-13 à 17 43
06"
src="https://github.com/user-attachments/assets/75a86a10-b551-416b-9103-6db03a6ca395"
/>
2026-03-16 15:10:11 +01:00
nitinandGitHub e552704201 fix: add viewFieldGroupId to ViewField fragment and connect page layout selectors to live metadata store (#18676) 2026-03-16 14:24:41 +01:00
ddedecbb36 i18n - docs translations (#18677)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-16 14:14:29 +01:00
5011e1d77b i18n - docs translations (#18674)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
2026-03-16 13:02:42 +01:00
a07337fea0 fix: return method-specific MCP responses (#18671)
## Summary

Fixes #18524 

Fixes the MCP response contract for non-`initialize` methods.

Previously, `/mcp` returned initialize-style metadata for methods like
`tools/list`, which caused strict MCP clients to reject the response
shape. The endpoint also returned `201 Created` for RPC calls even
though no resource was being created.

## Changes

- return only method-specific payloads for MCP list methods
  - `tools/list` -> `{ tools: [...] }`
  - `prompts/list` -> `{ prompts: [] }`
  - `resources/list` -> `{ resources: [] }`
- keep MCP server metadata only on `initialize`
- make `/mcp` return `200 OK` instead of `201 Created`
- add regression tests for:
  - `tools/list` response shape
  - `prompts/list` response shape
  - `resources/list` response shape

## Why

Strict MCP clients expect:
- standard RPC transport semantics over HTTP
- method-specific JSON-RPC result payloads

Returning initialize metadata for non-`initialize` methods breaks that
expectation and can cause client deserialization or protocol validation
failures.

## Verification

- reproduced the issue locally against `/mcp`
- verified `tools/list` was previously returning initialize-style fields
- verified `tools/list` now returns only `result.tools`
- verified `/mcp` now returns `200 OK`
- ran targeted Jest tests:

```bash
cd /Users/apple/MyProjects/OpenSource/twenty/packages/twenty-server
npx jest --runInBand src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts src/engine/api/mcp/services/__tests__/mcp-tool-executor.service.spec.ts

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2026-03-16 12:46:35 +01:00
Abdullah.andGitHub 6e36ad9fa2 fix: mailparser vulnerable to cross-site scripting (#18664)
Resolves [Dependabot Alert
595](https://github.com/twentyhq/twenty/security/dependabot/595) and
[Dependabot Alert
596](https://github.com/twentyhq/twenty/security/dependabot/596).
2026-03-16 09:59:36 +01:00
Charles BochetandGitHub 5c745059ad refactor: remove "core" naming from views and eliminate converter layer (#18667)
## Summary

- **Remove all "core" prefixes** from the views system — the
metadata-based storage migration is complete, so `CoreView`,
`coreViewsSelector`, `getCoreViews`, etc. are now just `View`,
`viewsSelector`, `getViews`
- **Eliminate the entire converter layer** (15 files, ~850 lines
deleted) — `convertCoreViewToView` and all sub-converters were either
no-ops or trivially adding `__typename` / mapping identical enum values.
Local enums now re-export from generated GraphQL types directly (single
source of truth)
- **Unify `View` and `ViewWithRelations`** into one type —
`ViewWithRelations` is now a type alias for `View`, selectors return
data directly without conversion

### Backend
- Rename `@ObjectType('CoreView')` → `@ObjectType('View')` (and all
sub-entities)
- Rename resolver methods: `getCoreViews` → `getViews`, `createCoreView`
→ `createView`, etc.
- Rename `FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION` →
`FIND_ALL_VIEWS_GRAPHQL_OPERATION`

### Frontend
- Delete 15 converter files (`convertGqlView*ToView*`,
`convertView*ToGql`, `convertViewWithRelationsToView`)
- Re-export `ViewType`, `ViewKey`, `ViewFilterGroupLogicalOperator` from
generated enums (no more duplicate enum definitions with different
casing)
- Replace `ViewOpenRecordInType` with `ViewOpenRecordIn` from generated
- Remove `__typename` from all local view sub-types
- Remove unused `variant` from `ViewFilter`, make `displayValue` and
`definition` optional
- Rename ~45 GraphQL query/mutation files and all selectors to drop
"core" prefix
- Delete unused `viewsWithRelationsSelector`
2026-03-16 09:57:18 +01:00
6340b9e2f7 i18n - translations (#18669)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-16 09:49:16 +01:00
Abdullah.andGitHub 2c2f66b584 fix: DOMPurify contains a cross-site scripting vulnerability (#18665)
Resolves [Dependabot Alert
597](https://github.com/twentyhq/twenty/security/dependabot/597),
[Dependabot Alert
598](https://github.com/twentyhq/twenty/security/dependabot/598),
[Dependabot Alert
599](https://github.com/twentyhq/twenty/security/dependabot/599) and
[Dependabot Alert
600](https://github.com/twentyhq/twenty/security/dependabot/600).
2026-03-16 09:49:03 +01:00
Félix MalfaitGitHubClaudeclaude[bot] <41898282+claude[bot]@users.noreply.github.com>github-actionscubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
95a35f8a1d Implement OAuth 2.0 Dynamic Client Registration (RFC 7591) (#18608)
## Summary
This PR implements OAuth 2.0 Dynamic Client Registration (RFC 7591) and
OAuth 2.0 Protected Resource Metadata (RFC 9728) support, enabling
third-party applications to dynamically register as OAuth clients
without manual configuration.

## Key Changes

### OAuth Dynamic Client Registration
- **New Controller**: `OAuthRegistrationController` at `POST
/oauth/register` endpoint
  - Validates client metadata according to RFC 7591 specifications
  - Enforces PKCE-only public client model (no client secrets)
- Supports only `authorization_code` grant type and `code` response type
  - Rate limits registrations to 10 per hour per IP address
  - Returns `client_id` and registration metadata in response

- **Input Validation**: `OAuthRegisterInput` DTO with constraints on:
  - Client name (max 256 chars)
  - Redirect URIs (max 20, validated for security)
  - Grant types, response types, scopes, and auth methods
  - Logo and client URIs (max 2048 chars)

- **Discovery Endpoint Update**: Added `registration_endpoint` to OAuth
discovery metadata

### Stale Registration Cleanup
- **Cleanup Service**: Automatically removes OAuth-only registrations
older than 30 days that have no active installations
- **Cron Job**: Runs daily at 02:30 AM UTC with batch processing (100
records per batch)
- **CLI Command**: `cron:stale-registration-cleanup` to manually trigger
cleanup

### MCP (Model Context Protocol) Authentication
- **New Guard**: `McpAuthGuard` implements RFC 9728 compliance
  - Wraps JWT authentication with proper error responses
- Returns `WWW-Authenticate` header with protected resource metadata URL
on 401
  - Enables OAuth-protected MCP endpoints

### Protected Resource Metadata
- **New Endpoint**: `GET /.well-known/oauth-protected-resource` (RFC
9728)
  - Advertises MCP resource as OAuth-protected
  - Lists supported scopes and bearer token methods
  - Enables OAuth clients to discover authorization requirements

### Application Registration Updates
- **New Source Type**: `OAUTH_ONLY` enum value for OAuth-only
registrations
- **Install Service**: Skips artifact installation for OAuth-only apps
(no code artifacts)

### Frontend Updates
- **Authorization Page**: Support both snake_case (standard OAuth) and
camelCase (legacy) query parameters
  - `client_id` / `clientId`
  - `code_challenge` / `codeChallenge`
  - `redirect_uri` / `redirectUrl`

## Implementation Details

- **Rate Limiting**: Uses token bucket algorithm with 10 registrations
per 3,600,000ms window per IP
- **Scope Validation**: Requested scopes are capped to allowed OAuth
scopes; defaults to all scopes if not specified
- **Redirect URI Validation**: Uses existing `validateRedirectUri`
utility for security
- **Cache Headers**: Registration responses include `Cache-Control:
no-store` and `Pragma: no-cache`
- **Batch Processing**: Cleanup operations process 100 records at a time
to avoid memory issues
- **Grace Period**: 30-day grace period before cleanup to allow time for
client activation

https://claude.ai/code/session_01PxcuWFFRuXMASMaMGTLYk2

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-03-16 09:42:28 +01:00
Abdullah.andGitHub 87c519b72f fix: multer vulnerable to denial of service via uncontrolled recursion (#18659)
Resolves [Dependabot Alert
608](https://github.com/twentyhq/twenty/security/dependabot/608).
2026-03-16 09:08:40 +01:00
Abdullah.andGitHub 1b20bdaf6d fix: @isaacs/brace-expansion has uncontrolled resource consumption (#18660)
Resolves [Dependabot Alert
414](https://github.com/twentyhq/twenty/security/dependabot/414).
2026-03-16 09:08:25 +01:00
Abdullah.andGitHub 67866ff59c fix: expr-eval related dependabot alerts (#18661)
Resolves [Dependabot Alert
593](https://github.com/twentyhq/twenty/security/dependabot/593) and
[Dependabot Alert
594](https://github.com/twentyhq/twenty/security/dependabot/594).

expr-eval was last published six years ago and had changes five years
ago. NPM contains a fork that published the changes from five years ago
under the same name with `-fork` suffix. This PR uses that fork as
suggested by Dependabot.
2026-03-16 09:08:19 +01:00
Abdullah.andGitHub e6f1bdd1c8 fix: yauzl contains an off-by-one error (#18662)
Resolves [Dependabot Alert
633](https://github.com/twentyhq/twenty/security/dependabot/633).
2026-03-16 09:08:12 +01:00
Charles BochetandGitHub ba9aa41bba refactor: metadata store cleanup, SSE unification, mock metadata loading & login redirect fix (#18651)
## Summary
- **SSE unification**: Replaced 11 individual SSE effect components with
a single generic `MetadataStoreSSEEffect`
- **Metadata store cleanup**: Merged `metadataCollectionHashesState`
into `metadataStoreState` (currentCollectionHash / draftCollectionHash
per entity), moved `objectMetadataItemsSelector` to `object-metadata`
domain, converted `navigationMenuItemsState` to a derived selector
- **Naming clarity**: Renamed `isAppMetadataReadyState` →
`isMinimalMetadataReadyState`, `MetadataGater` → `MinimalMetadataGater`,
`useIsLogged` → `useHasAccessTokenPair`,
`patchMetadataStoreFromSSEEvent` now takes named object params
- **Mock metadata loading**: Added `generate-navigation-menu-items.ts`
script, rewrote `useLoadMockedMinimalMetadata` to load full
objects/fields/indexes/views/navItems from generated mock data, enabling
proper sign-in background rendering (table columns, view picker,
navigation)
- **Login/logout transitions**: `MinimalMetadataLoadEffect` manages
mocked↔real metadata transitions based on auth state,
`MainContextStoreProvider` computes context on auth pages for view
picker support
- **Login redirect fix**: `handleLoadWorkspaceAfterAuthentication` now
re-enables `isAppEffectRedirectEnabled` after `loadCurrentUser()`
completes, fixing the blocked post-login navigation
- **Dead code removal**: Deleted `useRefreshPageLayouts`,
`useApplyPageLayouts`, `useStaleMetadataEntities`,
`metadataCollectionHashesState`, and all individual SSE effects

## Test plan
- [x] Login from welcome page redirects to companies page
- [x] Logout transitions cleanly to mocked metadata on welcome page
- [x] Sign-in background shows table columns, view picker, and
navigation items
- [x] SSE events still update metadata store entries correctly
- [x] Navigation menu items persist across page refreshes
- [ ] CI: lint, typecheck, tests pass
2026-03-16 00:38:11 +01:00
Charles BochetandGitHub 06efee1eef feat: hash-based metadata staleness detection (#18649)
## Summary

Replace the single `metadataVersion` integer with per-entity-type
**collection hashes** for granular metadata staleness detection. The
backend already generates a UUID per flat entity map on each cache
recompute (`crypto.randomUUID()` in `WorkspaceCacheService`); we now
expose these via the minimal metadata endpoint and SSE events so the
frontend can compare and know exactly which entity types are stale.

### Key changes

**Backend:**
- `WorkspaceCacheService.getCacheHashes()` — new public method that
reads only `:hash` keys from Redis without fetching full data
- `MinimalMetadataDTO` — added `collectionHashes: Record<string,
string>` (JSON scalar mapping `AllMetadataName` → collection hash),
removed `metadataVersion`
- `MetadataEventDTO` — added optional `updatedCollectionHash` field to
SSE events
- `MetadataEventsToDbListener` — reads the collection hash for the
affected entity type after cache invalidation and attaches it to the SSE
event before publishing
- `MinimalMetadataService` — no longer queries the workspace table; uses
`getCacheHashes()` for all flat entity maps and maps cache keys to
`AllMetadataName` locally

**Frontend:**
- `metadataCollectionHashesState` — new Jotai atom with
`atomWithStorage` + `getOnInit: true` storing
`Partial<Record<MetadataEntityKey, string>>`
- `mapAllMetadataNameToEntityKey()` — explicit mapping from backend
`AllMetadataName` to frontend `MetadataEntityKey` (23 entries)
- `useLoadMinimalMetadata` — stores `collectionHashes` from server,
computes `staleEntityKeys` by comparing local vs server hashes
- `patchMetadataStoreFromSSEEvent()` — accepts optional
`updatedCollectionHash` and updates `metadataCollectionHashesState`
- All 11 SSE effect components — pass
`eventDetail.updatedCollectionHash` through to the patch function
- `useStaleMetadataEntities` — new hook returning entity keys missing
from collection hashes (not yet loaded/synced)
- `resetMetadataStore()` — also clears collection hashes
- Deleted `metadataVersionState` (superseded by collection hashes)

### Design decisions

- **No change to hash generation** — existing `crypto.randomUUID()` is
sufficient. Hashes are persisted in Redis, survive server restarts, and
change only on `invalidateAndRecompute`.
- **"Collection hash" naming** — used consistently to clarify the hash
represents an entire entity collection (e.g., all views), not a single
record.
- **Mapping localized** — backend `WorkspaceCacheKeyName` →
`AllMetadataName` mapping lives in the minimal metadata service.
Frontend `AllMetadataName` → `MetadataEntityKey` mapping lives in a
local utility. Nothing in `twenty-shared`.
- **Backward compatible** — `collectionHashes` is additive;
`updatedCollectionHash` is nullable.
2026-03-14 23:38:37 +01:00
3060 changed files with 105511 additions and 49210 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(git stash:*)"
]
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
.git
.env
node_modules
**/node_modules
.nx/cache
packages/twenty-server/.env
+68
View File
@@ -0,0 +1,68 @@
name: AI Catalog Sync
on:
schedule:
- cron: '0 6 * * *' # Daily at 6 AM UTC
workflow_dispatch: # Allow manual trigger
permissions:
contents: write
pull-requests: write
jobs:
sync-catalog:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
NODE_OPTIONS: '--max-old-space-size=4096'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-shared
- name: Run catalog sync
run: npx nx run twenty-server:ts-node-no-deps-transpile-only -- ./scripts/ai-sync-models-dev.ts
- name: Check for changes
id: changes
run: |
if git diff --quiet packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-providers.json; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Create pull request
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: sync AI model catalog from models.dev'
title: 'chore: sync AI model catalog from models.dev'
body: |
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev).
This PR updates pricing, context windows, and model availability based on the latest data.
New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same model family.
**Please review before merging** — verify no critical models were incorrectly deprecated.
branch: chore/ai-catalog-sync
base: main
labels: ai, automated
delete-branch: true
- name: Trigger automerge
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: automated-pr-ready
+6 -4
View File
@@ -100,7 +100,7 @@ jobs:
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --exhaustive --display-name "Test App" --description "E2E test app"
create-twenty-app test-app --exhaustive --display-name "Test App" --description "E2E test app" --skip-local-instance
- name: Install scaffolded app dependencies
run: |
@@ -151,22 +151,24 @@ jobs:
SEED_API_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik'
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty auth:login --api-key $SEED_API_KEY --api-url http://localhost:3000
npx --no-install twenty remote add --token $SEED_API_KEY --url http://localhost:3000
- name: Build scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty app:build
npx --no-install twenty build
test -d .twenty/output
- name: Execute hello-world logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty function:execute --functionName hello-world-logic-function)
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName hello-world-logic-function)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
- name: Run scaffolded app integration test
env:
TWENTY_API_URL: http://localhost:3000
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
+34
View File
@@ -151,6 +151,40 @@ jobs:
# npx nyc merge coverage-artifacts ${{ env.PATH_TO_COVERAGE }}/coverage-storybook.json
# - name: Checking coverage
# run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
visual-regression-dispatch:
needs: front-sb-build
if: github.event_name == 'pull_request'
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Download storybook build
uses: actions/download-artifact@v4
with:
name: storybook-static
path: storybook-static
- name: Package storybook
run: tar -czf /tmp/storybook-twenty-front.tar.gz -C storybook-static .
- name: Upload storybook tarball
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-front-tarball
path: /tmp/storybook-twenty-front.tar.gz
retention-days: 1
- name: Dispatch to ci-privileged
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: visual-regression
client-payload: >-
{
"pr_number": "${{ github.event.pull_request.number }}",
"run_id": "${{ github.run_id }}",
"repo": "${{ github.repository }}",
"project": "twenty-front",
"branch": "${{ github.head_ref }}",
"commit": "${{ github.event.pull_request.head.sha }}"
}
front-task:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
+2 -2
View File
@@ -10,8 +10,8 @@ permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'merge_group' && github.event.merge_group.base_ref || github.ref }}
cancel-in-progress: ${{ github.event_name != 'merge_group' }}
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
e2e-test:
+146
View File
@@ -0,0 +1,146 @@
name: CI UI
on:
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
yarn.lock
packages/twenty-ui/**
packages/twenty-shared/**
ui-task:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run ${{ matrix.task }}
run: npx nx ${{ matrix.task }} twenty-ui
ui-sb-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build storybook
run: npx nx storybook:build twenty-ui
- name: Upload storybook build
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
retention-days: 1
ui-sb-test:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: ui-sb-build
env:
STORYBOOK_URL: http://localhost:6007
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-shared
- name: Download storybook build
uses: actions/download-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
- name: Install Playwright
run: |
cd packages/twenty-ui
npx playwright install
- name: Serve storybook & run tests
run: |
npx http-server packages/twenty-ui/storybook-static --port 6007 --silent &
timeout 30 bash -c 'until curl -sf http://localhost:6007 > /dev/null 2>&1; do sleep 1; done'
npx nx storybook:test twenty-ui
visual-regression-dispatch:
needs: ui-sb-build
if: github.event_name == 'pull_request'
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Download storybook build
uses: actions/download-artifact@v4
with:
name: storybook-twenty-ui
path: storybook-static
- name: Package storybook
run: tar -czf /tmp/storybook-twenty-ui.tar.gz -C storybook-static .
- name: Upload storybook tarball
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-ui-tarball
path: /tmp/storybook-twenty-ui.tar.gz
retention-days: 1
- name: Dispatch to ci-privileged
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: visual-regression
client-payload: >-
{
"pr_number": "${{ github.event.pull_request.number }}",
"run_id": "${{ github.run_id }}",
"repo": "${{ github.repository }}",
"project": "twenty-ui",
"branch": "${{ github.head_ref }}",
"commit": "${{ github.event.pull_request.head.sha }}"
}
ci-ui-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs:
[
changed-files-check,
ui-task,
ui-sb-build,
ui-sb-test,
]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+8
View File
@@ -150,3 +150,11 @@ jobs:
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+8
View File
@@ -138,3 +138,11 @@ jobs:
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: steps.compile_translations.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+8
View File
@@ -102,3 +102,11 @@ jobs:
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+2 -2
View File
@@ -2,8 +2,8 @@
"mcpServers": {
"postgres": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "${PG_DATABASE_URL}"],
"command": "bash",
"args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""],
"env": {}
},
"playwright": {
+11
View File
@@ -80,6 +80,17 @@ npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/mi
npx nx run twenty-server:command workspace:sync-metadata
```
### Database Inspection (Postgres MCP)
A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
- Inspect workspace data, metadata, and object definitions while developing
- Verify migration results (columns, types, constraints) after running migrations
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
- Inspect metadata tables to debug GraphQL schema generation or `workspace:sync-metadata` issues
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
### GraphQL
```bash
# Generate GraphQL types (run after schema changes)
+44 -29
View File
@@ -25,47 +25,47 @@ See Twenty application documentation https://docs.twenty.com/developers/extend/c
## Prerequisites
- Node.js 24+ (recommended) and Yarn 4
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
- Docker (for the local Twenty dev server)
## Quick start
```bash
# Scaffold a new app — the CLI will offer to start a local Twenty server
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Get help and list all available commands
yarn twenty help
# The scaffolder can automatically:
# 1. Start a local Twenty server (Docker)
# 2. Open the browser to log in (tim@apple.dev / tim@apple.dev)
# 3. Authenticate your app via OAuth
# Authenticate using your API key (you'll be prompted)
yarn twenty auth:login
# Add a new entity to your application (guided)
yarn twenty entity:add
# Or do it manually:
yarn twenty server start # Start local Twenty server
yarn twenty remote add --local # Authenticate via OAuth
# Start dev mode: watches, builds, and syncs local changes to your workspace
# (also auto-generates typed CoreApiClient — MetadataApiClient ships pre-built with the SDK — both available via `twenty-sdk/clients`)
yarn twenty app:dev
yarn twenty dev
# Watch your application's function logs
yarn twenty function:logs
yarn twenty logs
# Execute a function with a JSON payload
yarn twenty function:execute -n my-function -p '{"key": "value"}'
yarn twenty exec -n my-function -p '{"key": "value"}'
# Execute the pre-install function
yarn twenty function:execute --preInstall
yarn twenty exec --preInstall
# Execute the post-install function
yarn twenty function:execute --postInstall
yarn twenty exec --postInstall
# Build the app for distribution
yarn twenty app:build
yarn twenty build
# Publish the app to npm or directly to a Twenty server
yarn twenty app:publish
yarn twenty publish
# Uninstall the application from the current workspace
yarn twenty app:uninstall
yarn twenty uninstall
```
## Scaffolding modes
@@ -107,13 +107,27 @@ npx create-twenty-app@latest my-app -m
- `skills/example-skill.ts` — Example AI agent skill definition
- `__tests__/app-install.integration-test.ts` — Integration test that builds, installs, and verifies the app (includes `vitest.config.ts`, `tsconfig.spec.json`, and a setup file)
## Local server
The scaffolder can start a local Twenty dev server for you (all-in-one Docker image with PostgreSQL, Redis, server, and worker). You can also manage it manually:
```bash
yarn twenty server start # Start (pulls image if needed)
yarn twenty server status # Check if it's healthy
yarn twenty server logs # Stream logs
yarn twenty server stop # Stop (data is preserved)
yarn twenty server reset # Wipe all data and start fresh
```
The server is pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`).
## Next steps
- Run `yarn twenty help` to see all available commands.
- Use `yarn twenty auth:login` to authenticate with your Twenty workspace.
- Explore the generated project and add your first entity with `yarn twenty entity:add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
- Use `yarn twenty app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
- `CoreApiClient` (for workspace data via `/graphql`) is auto-generated by `yarn twenty app:dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/clients'`.
- Use `yarn twenty remote add --local` to authenticate with your Twenty workspace via OAuth.
- Explore the generated project and add your first entity with `yarn twenty add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
- Use `yarn twenty dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
- `CoreApiClient` (for workspace data via `/graphql`) is auto-generated by `yarn twenty dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/clients'`.
## Build and publish your application
@@ -121,19 +135,19 @@ Once your app is ready, build and publish it using the CLI:
```bash
# Build the app (output goes to .twenty/output/)
yarn twenty app:build
yarn twenty build
# Build and create a tarball (.tgz) for distribution
yarn twenty app:build --tarball
yarn twenty build --tarball
# Publish to npm (requires npm login)
yarn twenty app:publish
yarn twenty publish
# Publish with a dist-tag (e.g. beta, next)
yarn twenty app:publish --tag beta
yarn twenty publish --tag beta
# Publish directly to a Twenty server (builds, uploads, and installs in one step)
yarn twenty app:publish --server https://app.twenty.com
# Deploy directly to a Twenty server (builds, uploads, and installs in one step)
yarn twenty deploy
```
### Publish to the Twenty marketplace
@@ -153,8 +167,9 @@ Our team reviews contributions for quality, security, and reusability before mer
## Troubleshooting
- Auth prompts not appearing: run `yarn twenty auth:login` again and verify the API key permissions.
- Types not generated: ensure `yarn twenty app:dev` is running — it autogenerates the typed client.
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
- Auth not working: make sure you're logged in to Twenty in the browser first, then run `yarn twenty remote add --local`.
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
## Contributing
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "0.7.0",
"version": "0.8.0-canary.1",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
+6
View File
@@ -27,6 +27,10 @@ const program = new Command(packageJson.name)
'--description <description>',
'Application description (skips prompt)',
)
.option(
'--skip-local-instance',
'Skip the local Twenty instance setup prompt',
)
.helpOption('-h, --help', 'Display this help message.')
.action(
async (
@@ -37,6 +41,7 @@ const program = new Command(packageJson.name)
name?: string;
displayName?: string;
description?: string;
skipLocalInstance?: boolean;
},
) => {
const modeFlags = [options?.exhaustive, options?.minimal].filter(Boolean);
@@ -72,6 +77,7 @@ const program = new Command(packageJson.name)
name: options?.name,
displayName: options?.displayName,
description: options?.description,
skipLocalInstance: options?.skipLocalInstance,
});
},
);
@@ -1,62 +1,11 @@
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Getting Started
First, authenticate to your workspace:
```bash
yarn twenty auth:login
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty app:dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
## Available Commands
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Authentication
yarn twenty auth:login # Authenticate with Twenty
yarn twenty auth:logout # Remove credentials
yarn twenty auth:status # Check auth status
yarn twenty auth:switch # Switch default workspace
yarn twenty auth:list # List all configured workspaces
# Application
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty function:logs # Stream function logs
yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
```
## Integration Tests
If your project includes the example integration test (`src/__tests__/app-install.integration-test.ts`), you can run it with:
```bash
# Make sure a Twenty server is running at http://localhost:3000
yarn test
```
The test builds and installs the app, then verifies it appears in the applications list. Test configuration (API URL and API key) is defined in `vitest.config.ts`.
## LLMs instructions
Main docs and pitfalls are available in LLMS.md file.
Run `yarn twenty help` to list all available commands.
## Learn More
To learn more about Twenty applications, take a look at the following resources:
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/capabilities/apps)
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)
@@ -1,11 +1,16 @@
import { copyBaseApplicationProject } from '@/utils/app-template';
import { convertToLabel } from '@/utils/convert-to-label';
import { install } from '@/utils/install';
import {
type LocalInstanceResult,
setupLocalInstance,
} from '@/utils/setup-local-instance';
import { tryGitInit } from '@/utils/try-git-init';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import kebabCase from 'lodash.kebabcase';
import { execSync } from 'node:child_process';
import * as path from 'path';
import { isDefined } from 'twenty-shared/utils';
@@ -22,6 +27,7 @@ type CreateAppOptions = {
name?: string;
displayName?: string;
description?: string;
skipLocalInstance?: boolean;
};
export class CreateAppCommand {
@@ -40,6 +46,7 @@ export class CreateAppCommand {
await fs.ensureDir(appDirectory);
console.log(chalk.gray(' Scaffolding project files...'));
await copyBaseApplicationProject({
appName,
appDisplayName,
@@ -48,11 +55,24 @@ export class CreateAppCommand {
exampleOptions,
});
console.log(chalk.gray(' Installing dependencies...'));
await install(appDirectory);
console.log(chalk.gray(' Initializing git repository...'));
await tryGitInit(appDirectory);
this.logSuccess(appDirectory);
let localResult: LocalInstanceResult = { running: false };
if (!options.skipLocalInstance) {
// Auto-detect a running server first
localResult = await setupLocalInstance(appDirectory);
if (localResult.running && localResult.serverUrl) {
await this.connectToLocal(appDirectory, localResult.serverUrl);
}
}
this.logSuccess(appDirectory, localResult);
} catch (error) {
console.error(
chalk.red('Initialization failed:'),
@@ -173,22 +193,55 @@ export class CreateAppCommand {
appDirectory: string;
appName: string;
}): void {
console.log(chalk.blue('🎯 Creating Twenty Application'));
console.log(chalk.gray(`📁 Directory: ${appDirectory}`));
console.log(chalk.gray(`📝 Name: ${appName}`));
console.log(chalk.blue('Creating Twenty Application'));
console.log(chalk.gray(` Directory: ${appDirectory}`));
console.log(chalk.gray(` Name: ${appName}`));
console.log('');
}
private logSuccess(appDirectory: string): void {
private async connectToLocal(
appDirectory: string,
serverUrl: string,
): Promise<void> {
try {
execSync(
`npx nx run twenty-sdk:start -- remote add ${serverUrl} --as local`,
{
cwd: appDirectory,
stdio: 'inherit',
},
);
console.log(chalk.green('Authenticated with local Twenty instance.'));
} catch {
console.log(
chalk.yellow(
'Authentication skipped. Run `npx nx run twenty-sdk:start -- remote add --local` manually.',
),
);
}
}
private logSuccess(
appDirectory: string,
localResult: LocalInstanceResult,
): void {
const dirName = appDirectory.split('/').reverse()[0] ?? '';
console.log(chalk.green('Application created!'));
console.log(chalk.green('Application created!'));
console.log('');
console.log(chalk.blue('Next steps:'));
console.log(chalk.gray(` cd ${dirName}`));
if (!localResult.running) {
console.log(
chalk.gray(
' yarn twenty remote add --local # Authenticate with Twenty',
),
);
}
console.log(
chalk.gray(' yarn twenty auth:login # Authenticate with Twenty'),
chalk.gray(' yarn twenty dev # Start dev mode'),
);
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
}
}
@@ -103,7 +103,6 @@ describe('scaffoldIntegrationTest', () => {
expect(content).toContain('TWENTY_API_KEY');
expect(content).not.toContain('TWENTY_TEST_API_KEY');
expect(content).toContain('TWENTY_API_URL');
expect(content).toContain('setup-test.ts');
expect(content).toContain('tsconfig.spec.json');
expect(content).toContain('integration-test.ts');
@@ -474,7 +474,7 @@ const createExampleNavigationMenuItem = async ({
const universalIdentifier = v4();
const content = `import { defineNavigationMenuItem } from 'twenty-sdk';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '${universalIdentifier}',
@@ -482,6 +482,7 @@ export default defineNavigationMenuItem({
icon: 'IconList',
color: 'blue',
position: 0,
type: 'VIEW',
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
});
`;
@@ -609,8 +610,9 @@ const createPackageJson = async ({
const devDependencies: Record<string, string> = {
typescript: '^5.9.3',
'@types/node': '^24.7.2',
'@types/react': '^18.2.0',
react: '^18.2.0',
'@types/react': '^19.0.0',
react: '^19.0.0',
'react-dom': '^19.0.0',
oxlint: '^0.16.0',
'twenty-sdk': createTwentyAppPackageJson.version,
};
@@ -0,0 +1,101 @@
import chalk from 'chalk';
import { execSync } from 'node:child_process';
import { platform } from 'node:os';
const DEFAULT_PORT = 2020;
// Minimal health check — the full implementation lives in twenty-sdk
const isServerReady = async (port: number): Promise<boolean> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(`http://localhost:${port}/healthz`, {
signal: controller.signal,
});
const body = await response.json();
return body.status === 'ok';
} catch {
return false;
} finally {
clearTimeout(timeoutId);
}
};
export type LocalInstanceResult = {
running: boolean;
serverUrl?: string;
};
export const setupLocalInstance = async (
appDirectory: string,
): Promise<LocalInstanceResult> => {
console.log('');
console.log(chalk.blue('Setting up local Twenty instance...'));
if (await isServerReady(DEFAULT_PORT)) {
const serverUrl = `http://localhost:${DEFAULT_PORT}`;
console.log(chalk.green(`Twenty server detected on ${serverUrl}.`));
return { running: true, serverUrl };
}
// Delegate to `twenty server start` from the scaffolded app
console.log(chalk.gray('Starting local Twenty server...'));
try {
execSync('yarn twenty server start', {
cwd: appDirectory,
stdio: 'inherit',
});
} catch {
console.log(
chalk.yellow(
'Failed to start Twenty server. Run `yarn twenty server start` manually.',
),
);
return { running: false };
}
console.log(chalk.gray('Waiting for Twenty to be ready...'));
const startTime = Date.now();
const timeoutMs = 180 * 1000;
while (Date.now() - startTime < timeoutMs) {
if (await isServerReady(DEFAULT_PORT)) {
const serverUrl = `http://localhost:${DEFAULT_PORT}`;
console.log(chalk.green(`Twenty server is running on ${serverUrl}.`));
console.log(
chalk.gray(
'Workspace ready — login with tim@apple.dev / tim@apple.dev',
),
);
const openCommand = platform() === 'darwin' ? 'open' : 'xdg-open';
try {
execSync(`${openCommand} ${serverUrl}`, { stdio: 'ignore' });
} catch {
// Ignore if browser can't be opened
}
return { running: true, serverUrl };
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
console.log(
chalk.yellow(
'Twenty server did not become healthy in time. Check: yarn twenty server logs',
),
);
return { running: false };
};
@@ -45,7 +45,6 @@ export default defineConfig({
include: ['src/**/*.integration-test.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL: 'http://localhost:3000',
TWENTY_API_KEY:
'${SEED_API_KEY}',
},
@@ -94,7 +93,7 @@ import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
const assertServerIsReachable = async () => {
@@ -120,12 +119,13 @@ beforeAll(async () => {
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
profiles: {
default: {
remotes: {
local: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_API_KEY,
},
},
defaultRemote: 'local',
};
fs.writeFileSync(
@@ -5,13 +5,13 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty auth:login
yarn twenty remote add --local
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty app:dev
yarn twenty dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
@@ -21,19 +21,19 @@ Open your Twenty instance and go to `/settings/applications` section to see the
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Authentication
yarn twenty auth:login # Authenticate with Twenty
yarn twenty auth:logout # Remove credentials
yarn twenty auth:status # Check auth status
yarn twenty auth:switch # Switch default workspace
yarn twenty auth:list # List all configured workspaces
# Remotes & Authentication
yarn twenty remote add --local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Application
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty function:logs # Stream function logs
yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
```
## LLMs instructions
@@ -9,9 +9,9 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"app:dev": "twenty app:dev",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"dev": "twenty dev",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
@@ -9,16 +9,16 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"auth:login": "twenty auth:login",
"auth:logout": "twenty auth:logout",
"auth:status": "twenty auth:status",
"auth:switch": "twenty auth:switch",
"auth:list": "twenty auth:list",
"app:dev": "twenty app:dev",
"entity:add": "twenty entity:add",
"function:logs": "twenty function:logs",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"remote:add": "twenty remote add --local",
"remote:status": "twenty remote status",
"remote:switch": "twenty remote switch",
"remote:list": "twenty remote list",
"remote:remove": "twenty remote remove",
"dev": "twenty dev",
"add": "twenty add",
"logs": "twenty logs",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"help": "twenty help",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
@@ -9,16 +9,16 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"auth:login": "twenty auth:login",
"auth:logout": "twenty auth:logout",
"auth:status": "twenty auth:status",
"auth:switch": "twenty auth:switch",
"auth:list": "twenty auth:list",
"app:dev": "twenty app:dev",
"entity:add": "twenty entity:add",
"function:logs": "twenty function:logs",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"remote:add": "twenty remote add --local",
"remote:status": "twenty remote status",
"remote:switch": "twenty remote switch",
"remote:list": "twenty remote list",
"remote:remove": "twenty remote remove",
"dev": "twenty dev",
"add": "twenty add",
"logs": "twenty logs",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"help": "twenty help",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
@@ -9,16 +9,16 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"auth:login": "twenty auth:login",
"auth:logout": "twenty auth:logout",
"auth:status": "twenty auth:status",
"auth:switch": "twenty auth:switch",
"auth:list": "twenty auth:list",
"app:dev": "twenty app:dev",
"entity:add": "twenty entity:add",
"function:logs": "twenty function:logs",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"remote:add": "twenty remote add --local",
"remote:status": "twenty remote status",
"remote:switch": "twenty remote switch",
"remote:list": "twenty remote list",
"remote:remove": "twenty remote remove",
"dev": "twenty dev",
"add": "twenty add",
"logs": "twenty logs",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"help": "twenty help",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
@@ -1,8 +1,10 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { ALL_POST_CARD_RECIPIENTS_VIEW_ID } from '../views/all-post-card-recipients.view';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
export default defineNavigationMenuItem({
universalIdentifier: 'c1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
position: 2,
viewUniversalIdentifier: ALL_POST_CARD_RECIPIENTS_VIEW_ID,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
});
@@ -1,8 +1,10 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { ALL_POST_CARDS_VIEW_ID } from '../views/all-post-cards.view';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineNavigationMenuItem({
universalIdentifier: 'c1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
position: 0,
viewUniversalIdentifier: ALL_POST_CARDS_VIEW_ID,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
});
@@ -1,8 +1,10 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { ALL_RECIPIENTS_VIEW_ID } from '../views/all-recipients.view';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/recipient.object';
export default defineNavigationMenuItem({
universalIdentifier: 'c1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
position: 1,
viewUniversalIdentifier: ALL_RECIPIENTS_VIEW_ID,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: RECIPIENT_UNIVERSAL_IDENTIFIER,
});
+13 -13
View File
@@ -5,13 +5,13 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty auth:login
yarn twenty remote add --local
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty app:dev
yarn twenty dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
@@ -21,19 +21,19 @@ Open your Twenty instance and go to `/settings/applications` section to see the
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Authentication
yarn twenty auth:login # Authenticate with Twenty
yarn twenty auth:logout # Remove credentials
yarn twenty auth:status # Check auth status
yarn twenty auth:switch # Switch default workspace
yarn twenty auth:list # List all configured workspaces
# Remotes & Authentication
yarn twenty remote add --local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Application
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty function:logs # Stream function logs
yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
```
## Integration Tests
@@ -29,12 +29,13 @@ beforeAll(async () => {
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
profiles: {
default: {
remotes: {
local: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_API_KEY,
},
},
defaultRemote: 'local',
};
fs.writeFileSync(
@@ -1,5 +1,6 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '10f90627-e9c2-44b7-9742-bed77e3d1b17',
@@ -7,5 +8,6 @@ export default defineNavigationMenuItem({
icon: 'IconList',
color: 'blue',
position: 0,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -5,13 +5,13 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty auth:login
yarn twenty remote add --local
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty app:dev
yarn twenty dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
@@ -21,19 +21,19 @@ Open your Twenty instance and go to `/settings/applications` section to see the
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Authentication
yarn twenty auth:login # Authenticate with Twenty
yarn twenty auth:logout # Remove credentials
yarn twenty auth:status # Check auth status
yarn twenty auth:switch # Switch default workspace
yarn twenty auth:list # List all configured workspaces
# Remotes & Authentication
yarn twenty remote add --local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Application
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty function:logs # Stream function logs
yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
```
## LLMs instructions
@@ -236,7 +236,7 @@ const handler = async (event: any) => {
},
});
// TODO: remove `as any` after running `yarn twenty app:dev` to regenerate the typed client
// TODO: remove `as any` after running `yarn twenty dev` to regenerate the typed client
const updateSummary = async (markdown: string) => {
await client.mutation({
updateCallRecording: {
@@ -1,10 +1,12 @@
import { CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/call-recording-view';
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
export default defineNavigationMenuItem({
universalIdentifier: '5248a62d-7d2e-43a7-ba45-6e8f61876a71',
name: 'Call recordings',
icon: 'IconPhone',
position: 0,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,4 +1,5 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export default defineNavigationMenuItem({
@@ -6,6 +7,7 @@ export default defineNavigationMenuItem({
name: 'Self host user',
icon: 'IconList',
position: 1,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.views.selfHostingUserView.universalIdentifier,
});
@@ -38,7 +38,6 @@ services:
# EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-contact@yourdomain.com}
# EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-"John from YourDomain"}
# EMAIL_SYSTEM_ADDRESS: ${EMAIL_SYSTEM_ADDRESS:-system@yourdomain.com}
# EMAIL_DRIVER: ${EMAIL_DRIVER:-smtp}
# EMAIL_SMTP_HOST: ${EMAIL_SMTP_HOST:-smtp.gmail.com}
# EMAIL_SMTP_PORT: ${EMAIL_SMTP_PORT:-465}
@@ -92,7 +91,6 @@ services:
# EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-contact@yourdomain.com}
# EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-"John from YourDomain"}
# EMAIL_SYSTEM_ADDRESS: ${EMAIL_SYSTEM_ADDRESS:-system@yourdomain.com}
# EMAIL_DRIVER: ${EMAIL_DRIVER:-smtp}
# EMAIL_SMTP_HOST: ${EMAIL_SMTP_HOST:-smtp.gmail.com}
# EMAIL_SMTP_PORT: ${EMAIL_SMTP_PORT:-465}
@@ -89,6 +89,15 @@ password
{{- end -}}
{{- end -}}
{{/* Check if using external secret for redis password */}}
{{- define "twenty.redis.useExternalSecret" -}}
{{- if and (not .Values.redisInternal.enabled) .Values.redis.external.secretName .Values.redis.external.passwordKey -}}
true
{{- else -}}
false
{{- end -}}
{{- end -}}
{{/* Compose Redis URL */}}
{{- define "twenty.redisUrl" -}}
{{- if .Values.server.env.REDIS_URL -}}
@@ -99,9 +108,14 @@ password
{{- else -}}
{{- $host := .Values.redis.external.host | default "redis" -}}
{{- $port := .Values.redis.external.port | default 6379 -}}
{{- if or (eq (include "twenty.redis.useExternalSecret" .) "true") (.Values.redis.external.password) -}}
{{- $auth := ":$(REDIS_PASSWORD)@" -}}
{{- printf "redis://%s%s:%v" $auth $host $port -}}
{{- else -}}
{{- printf "redis://%s:%v" $host $port -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/* Compose Server URL from override, ingress, or service */}}
{{- define "twenty.serverUrl" -}}
@@ -46,12 +46,12 @@ spec:
volumeMounts:
- name: redis-data
mountPath: /data
volumes:
- name: redis-data
{{- if .Values.redisInternal.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Values.redisInternal.persistence.existingClaim | default (printf "%s-redis" (include "twenty.fullname" .)) }}
{{- else }}
emptyDir: {}
{{- end }}
volumes:
- name: redis-data
{{- if .Values.redisInternal.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Values.redisInternal.persistence.existingClaim | default (printf "%s-redis" (include "twenty.fullname" .)) }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
@@ -83,16 +83,16 @@ spec:
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v db="${DBNAME}" -Atc "SELECT 1 FROM pg_database WHERE datname = :'db'" | grep -q 1 || \
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v db="${DBNAME}" -c 'CREATE DATABASE :"db";'
echo "Creating app user ${APP_USER} if it doesn't exist..."
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v app_user="${APP_USER}" -v app_password="${APP_PASSWORD}" <<'EOSQL'
DO
$do$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_user') THEN
EXECUTE format('CREATE USER %I WITH PASSWORD %L', :'app_user', :'app_password');
END IF;
END
$do$;
EOSQL
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v app_user="${APP_USER}" -v app_password="${APP_PASSWORD}" <<'EOSQL'
DO
$do$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_user') THEN
EXECUTE format('CREATE USER %I WITH PASSWORD %L', :'app_user', :'app_password');
END IF;
END
$do$;
EOSQL
echo "Creating core schema and granting permissions..."
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'CREATE SCHEMA IF NOT EXISTS core'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v db="${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON DATABASE :"db" TO :"app_user";'
@@ -106,31 +106,6 @@ spec:
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO :"app_user";'
echo "Database ${DBNAME} is ready."
{{- end }}
- name: run-migrations
{{- $img := include "twenty.server.image" . }}
image: {{ include "twenty.image.repository" $img }}:{{ include "twenty.image.tag" $img }}
imagePullPolicy: {{ include "twenty.image.pullPolicy" $img }}
command:
- sh
- -c
- >-
npx -y typeorm migration:run -d dist/database/typeorm/core/core.datasource
env:
{{- if eq (include "twenty.db.useExternalSecret" .) "true" }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbPassword.secretName" . }}
key: {{ include "twenty.dbPassword.secretKey" . }}
- name: PG_DATABASE_URL
value: {{ include "twenty.dbUrl.template" . | quote }}
{{- else }}
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbUrl.secretName" . }}
key: url
{{- end }}
containers:
- name: server
{{- $img := include "twenty.server.image" . }}
@@ -154,6 +129,16 @@ spec:
name: {{ include "twenty.dbUrl.secretName" . }}
key: url
{{- end }}
{{- if eq (include "twenty.redis.useExternalSecret" .) "true" }}
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.redis.external.secretName }}
key: {{ .Values.redis.external.passwordKey }}
{{- else if .Values.redis.external.password }}
- name: REDIS_PASSWORD
value: {{ .Values.redis.external.password | quote }}
{{- end }}
- name: REDIS_URL
value: {{ include "twenty.redisUrl" . | quote }}
- name: SIGN_IN_PREFILLED
@@ -171,7 +156,10 @@ spec:
key: accessToken
{{- $storageEnv := (include "twenty.storageEnv" .) }}
{{- if $storageEnv }}
{{ $storageEnv | nindent 12 }}
{{- $storageEnv | nindent 12 }}
{{- end }}
{{- with .Values.server.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http-tcp
@@ -67,6 +67,16 @@ spec:
name: {{ include "twenty.dbUrl.secretName" . }}
key: url
{{- end }}
{{- if eq (include "twenty.redis.useExternalSecret" .) "true" }}
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.redis.external.secretName }}
key: {{ .Values.redis.external.passwordKey }}
{{- else if .Values.redis.external.password }}
- name: REDIS_PASSWORD
value: {{ .Values.redis.external.password | quote }}
{{- end }}
- name: REDIS_URL
value: {{ include "twenty.redisUrl" . | quote }}
- name: STORAGE_TYPE
@@ -76,9 +86,12 @@ spec:
secretKeyRef:
name: {{ include "twenty.secret.tokens.name" . }}
key: accessToken
{{- with .Values.worker.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- $storageEnv := (include "twenty.storageEnv" .) }}
{{- if $storageEnv }}
{{ $storageEnv | nindent 12 }}
{{- $storageEnv | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.worker.resources | nindent 12 }}
@@ -0,0 +1,20 @@
{{- if and .Values.redisInternal.enabled .Values.redisInternal.persistence.enabled (not .Values.redisInternal.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "twenty.fullname" . }}-redis
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
spec:
accessModes:
{{ toYaml .Values.redisInternal.persistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.redisInternal.persistence.size }}
{{- if .Values.redisInternal.persistence.storageClass }}
storageClassName: {{ .Values.redisInternal.persistence.storageClass }}
{{- end }}
{{- end }}
@@ -0,0 +1,58 @@
suite: env mapping
templates:
- templates/deployment-server.yaml
- templates/deployment-worker.yaml
release:
name: my-twenty
namespace: default
tests:
- it: renders server extraEnv with plain value
template: templates/deployment-server.yaml
set:
server.extraEnv:
- name: FEATURE_X_ENABLED
value: "true"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: FEATURE_X_ENABLED
value: "true"
- it: renders server extraEnv with valueFrom
template: templates/deployment-server.yaml
set:
server.extraEnv:
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: smtp-creds
key: password
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: smtp-creds
key: password
- it: renders worker extraEnv with valueFrom
template: templates/deployment-worker.yaml
set:
worker.extraEnv:
- name: CUSTOM_SECRET
valueFrom:
secretKeyRef:
name: my-secret
key: custom
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: CUSTOM_SECRET
valueFrom:
secretKeyRef:
name: my-secret
key: custom
@@ -0,0 +1,89 @@
suite: redis external authentication
templates:
- templates/deployment-server.yaml
- templates/deployment-worker.yaml
release:
name: my-twenty
namespace: default
tests:
- it: injects REDIS_PASSWORD from external secret into server
template: templates/deployment-server.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.secretName: redis-creds
redis.external.passwordKey: password
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: redis-creds
key: password
- it: injects REDIS_PASSWORD from external secret into worker
template: templates/deployment-worker.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.secretName: redis-creds
redis.external.passwordKey: password
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: redis-creds
key: password
- it: injects plaintext REDIS_PASSWORD when password set directly in server
template: templates/deployment-server.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.password: "s3cr3t"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
value: "s3cr3t"
- it: injects plaintext REDIS_PASSWORD when password set directly in worker
template: templates/deployment-worker.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.password: "s3cr3t"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
value: "s3cr3t"
- it: does not inject REDIS_PASSWORD into server when using internal redis
template: templates/deployment-server.yaml
set:
redisInternal.enabled: true
asserts:
- notContains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
any: true
- it: does not inject REDIS_PASSWORD into worker when using internal redis
template: templates/deployment-worker.yaml
set:
redisInternal.enabled: true
asserts:
- notContains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
any: true
@@ -105,18 +105,3 @@ tests:
path: spec.template.spec.initContainers[?(@.name=="ensure-database-exists")].command[2]
pattern: ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES
# TypeORM Migration Tests
# ======================
# TypeORM migrations are configured to use the core.datasource which targets the
# 'core' schema. This ensures the _typeorm_migrations table and all application
# tables use the dedicated core schema.
- it: migrations run against core datasource
template: templates/deployment-server.yaml
set:
db.enabled: true
asserts:
- matchRegex:
path: spec.template.spec.initContainers[?(@.name=="run-migrations")].command[2]
pattern: core\.datasource
@@ -19,7 +19,7 @@ tests:
- crm.example.com
asserts:
- equal:
path: spec.template.spec.containers[0].env[0].value
path: spec.template.spec.containers[0].env[?(@.name=="SERVER_URL")].value
value: "https://crm.example.com:443"
- it: falls back to service when ingress disabled
set:
@@ -28,7 +28,7 @@ tests:
server.env.SERVER_URL: ""
asserts:
- matchRegex:
path: spec.template.spec.containers[0].env[0].value
path: spec.template.spec.containers[0].env[?(@.name=="SERVER_URL")].value
pattern: ^http://my-twenty-twenty-server\.default\.svc\.cluster\.local:3000$
---
suite: ingress configuration
@@ -66,6 +66,7 @@ tests:
set:
server.ingress.acme: true
asserts:
- equal:
path: metadata.annotations[cert-manager.io/cluster-issuer]
value: letsencrypt-prod
- isSubset:
path: metadata.annotations
content:
cert-manager.io/cluster-issuer: letsencrypt-prod
@@ -61,12 +61,27 @@
"EMAIL_SMTP_NO_TLS": { "type": "boolean" },
"EMAIL_FROM_ADDRESS": { "type": "string" },
"EMAIL_FROM_NAME": { "type": "string" },
"EMAIL_SYSTEM_ADDRESS": { "type": "string" },
"IS_EMAIL_VERIFICATION_REQUIRED": { "type": "boolean" },
"EMAIL_VERIFICATION_TOKEN_EXPIRES_IN": { "type": "string" },
"PASSWORD_RESET_TOKEN_EXPIRES_IN": { "type": "string" }
}
},
"extraEnv": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"value": { "type": "string" },
"valueFrom": { "type": "object" }
},
"required": ["name"],
"oneOf": [
{ "required": ["value"] },
{ "required": ["valueFrom"] }
]
}
},
"service": {
"type": "object",
"properties": {
@@ -142,6 +157,22 @@
"STORAGE_TYPE": { "type": "string" },
"DISABLE_DB_MIGRATIONS": { "type": "string" }
}
},
"extraEnv": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"value": { "type": "string" },
"valueFrom": { "type": "object" }
},
"required": ["name"],
"oneOf": [
{ "required": ["value"] },
{ "required": ["valueFrom"] }
]
}
}
}
},
@@ -52,6 +52,14 @@ server:
SIGN_IN_PREFILLED: "false"
ACCESS_TOKEN_EXPIRES_IN: "7d"
LOGIN_TOKEN_EXPIRES_IN: "1h"
extraEnv: []
# - name: EMAIL_DRIVER
# value: smtp
# - name: SMTP_PASSWORD
# valueFrom:
# secretKeyRef:
# name: smtp-creds
# key: password
service:
type: ClusterIP
@@ -105,6 +113,8 @@ worker:
cpu: 1000m
memory: 2048Mi
extraEnv: []
# PostgreSQL
db:
enabled: true
@@ -174,3 +184,6 @@ redis:
external:
host: ""
port: 6379
password: ""
secretName: ""
passwordKey: ""
@@ -0,0 +1,130 @@
ARG APP_VERSION
# === Stage 1: Common dependencies ===
FROM node:22-alpine AS common-deps
WORKDIR /app
COPY ./package.json ./yarn.lock ./.yarnrc.yml ./tsconfig.base.json ./nx.json /app/
COPY ./.yarn/releases /app/.yarn/releases
COPY ./.yarn/patches /app/.yarn/patches
COPY ./packages/twenty-emails/package.json /app/packages/twenty-emails/
COPY ./packages/twenty-server/package.json /app/packages/twenty-server/
COPY ./packages/twenty-server/patches /app/packages/twenty-server/patches
COPY ./packages/twenty-ui/package.json /app/packages/twenty-ui/
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/
COPY ./packages/twenty-front/package.json /app/packages/twenty-front/
COPY ./packages/twenty-sdk/package.json /app/packages/twenty-sdk/
RUN yarn && yarn cache clean && npx nx reset
# === Stage 2: Build server from source ===
FROM common-deps AS twenty-server-build
COPY ./packages/twenty-emails /app/packages/twenty-emails
COPY ./packages/twenty-shared /app/packages/twenty-shared
COPY ./packages/twenty-ui /app/packages/twenty-ui
COPY ./packages/twenty-sdk /app/packages/twenty-sdk
COPY ./packages/twenty-server /app/packages/twenty-server
RUN npx nx run twenty-server:build
RUN yarn workspaces focus --production twenty-emails twenty-shared twenty-sdk twenty-server
# === Stage 3: Build frontend from source ===
# Requires ~10GB Docker memory. To skip, pre-build on host first:
# npx nx build twenty-front
# The COPY below will pick up packages/twenty-front/build/ if it exists.
FROM common-deps AS twenty-front-build
COPY --from=twenty-server-build /app/package.json /tmp/.build-sentinel
COPY ./packages/twenty-front /app/packages/twenty-front
COPY ./packages/twenty-ui /app/packages/twenty-ui
COPY ./packages/twenty-shared /app/packages/twenty-shared
COPY ./packages/twenty-sdk /app/packages/twenty-sdk
RUN if [ -d /app/packages/twenty-front/build ]; then \
echo "Using pre-built frontend from host"; \
else \
NODE_OPTIONS="--max-old-space-size=8192" npx nx build twenty-front; \
fi
# === Stage 4: s6-overlay ===
FROM alpine:3.20 AS s6-fetch
ARG S6_OVERLAY_VERSION=3.2.0.2
ARG TARGETARCH
RUN if [ "$TARGETARCH" = "arm64" ]; then echo "aarch64" > /tmp/s6arch; \
else echo "x86_64" > /tmp/s6arch; fi
RUN S6_ARCH=$(cat /tmp/s6arch) && \
wget -O /tmp/s6-overlay-noarch.tar.xz \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz" && \
wget -O /tmp/s6-overlay-noarch.tar.xz.sha256 \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz.sha256" && \
wget -O /tmp/s6-overlay-arch.tar.xz \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz" && \
wget -O /tmp/s6-overlay-arch.tar.xz.sha256 \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz.sha256" && \
cd /tmp && \
NOARCH_SUM=$(awk '{print $1}' s6-overlay-noarch.tar.xz.sha256) && \
ARCH_SUM=$(awk '{print $1}' s6-overlay-arch.tar.xz.sha256) && \
echo "$NOARCH_SUM s6-overlay-noarch.tar.xz" | sha256sum -c - && \
echo "$ARCH_SUM s6-overlay-arch.tar.xz" | sha256sum -c -
# === Stage 5: Final all-in-one image ===
FROM node:22-alpine
# s6-overlay
COPY --from=s6-fetch /tmp/s6-overlay-noarch.tar.xz /tmp/
COPY --from=s6-fetch /tmp/s6-overlay-arch.tar.xz /tmp/
RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz \
&& tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz \
&& rm /tmp/s6-overlay-*.tar.xz
# Infrastructure: Postgres, Redis, and utilities
RUN apk add --no-cache \
postgresql16 postgresql16-contrib \
redis \
curl jq su-exec
# tsx for database setup scripts
RUN npm install -g tsx
# Twenty application (both server and frontend built from source)
COPY --from=twenty-server-build /app /app
COPY --from=twenty-front-build /app/packages/twenty-front/build /app/packages/twenty-server/dist/front
# s6 service definitions
COPY packages/twenty-docker/twenty-app-dev/rootfs/ /
# Data directories
RUN mkdir -p /data/postgres /data/redis /app/.local-storage \
&& chown -R postgres:postgres /data/postgres \
&& chown 1000:1000 /data/redis /app/.local-storage
ARG REACT_APP_SERVER_BASE_URL
ARG APP_VERSION=0.0.0
# Tell s6-overlay to preserve container environment variables
ENV S6_KEEP_ENV=1
# Environment defaults
ENV PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default \
SERVER_URL=http://localhost:2020 \
REDIS_URL=redis://localhost:6379 \
STORAGE_TYPE=local \
APP_SECRET=twenty-app-dev-secret-not-for-production \
REACT_APP_SERVER_BASE_URL=$REACT_APP_SERVER_BASE_URL \
APP_VERSION=$APP_VERSION \
NODE_ENV=development \
NODE_PORT=3000 \
DISABLE_DB_MIGRATIONS=true \
DISABLE_CRON_JOBS_REGISTRATION=true \
IS_BILLING_ENABLED=false \
SIGN_IN_PREFILLED=true
EXPOSE 3000
VOLUME ["/data/postgres", "/app/.local-storage"]
LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty
LABEL org.opencontainers.image.description="All-in-one Twenty image for local development and SDK usage. Includes PostgreSQL, Redis, server, and worker."
ENTRYPOINT ["/init"]
@@ -0,0 +1 @@
/bin/sh /etc/s6-overlay/scripts/init-db.sh
@@ -0,0 +1,13 @@
#!/bin/sh
# Initialize PostgreSQL data directory if empty
if [ ! -f /data/postgres/PG_VERSION ]; then
echo "Initializing PostgreSQL data directory..."
su-exec postgres initdb -D /data/postgres --auth=trust --encoding=UTF8
# Allow local connections without password
echo "host all all 127.0.0.1/32 trust" >> /data/postgres/pg_hba.conf
echo "host all all ::1/128 trust" >> /data/postgres/pg_hba.conf
fi
exec su-exec postgres postgres -D /data/postgres \
-c listen_addresses=localhost \
-c unix_socket_directories=/tmp
@@ -0,0 +1,6 @@
#!/bin/sh
exec redis-server \
--dir /data/redis \
--maxmemory-policy noeviction \
--bind 127.0.0.1 \
--protected-mode yes
@@ -0,0 +1,3 @@
#!/bin/sh
cd /app/packages/twenty-server
exec yarn start:prod
@@ -0,0 +1,3 @@
#!/bin/sh
cd /app/packages/twenty-server
exec yarn worker:prod
@@ -0,0 +1,56 @@
#!/bin/sh
set -e
# Wait for PostgreSQL to be ready (timeout after 60s)
echo "Waiting for PostgreSQL..."
TRIES=0
until su-exec postgres pg_isready -h localhost; do
TRIES=$((TRIES + 1))
if [ "$TRIES" -ge 120 ]; then
echo "ERROR: PostgreSQL did not become ready within 60s"
exit 1
fi
sleep 0.5
done
echo "PostgreSQL is ready."
# Create role if it doesn't exist
su-exec postgres psql -h localhost -tc \
"SELECT 1 FROM pg_roles WHERE rolname='twenty'" | grep -q 1 \
|| su-exec postgres psql -h localhost -c "CREATE ROLE twenty WITH LOGIN PASSWORD 'twenty' SUPERUSER"
# Create database if it doesn't exist
su-exec postgres psql -h localhost -tc \
"SELECT 1 FROM pg_database WHERE datname='default'" | grep -q 1 \
|| su-exec postgres createdb -h localhost -O twenty default
# Run Twenty database setup and migrations
cd /app/packages/twenty-server
has_schema=$(PGPASSWORD=twenty psql -h localhost -U twenty -d default -tAc \
"SELECT EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'core')")
if [ "$has_schema" = "f" ]; then
echo "Database appears to be empty, running initial setup..."
NODE_OPTIONS="--max-old-space-size=1500" tsx ./scripts/setup-db.ts
fi
# Always run migrations (idempotent — skips already-applied ones)
yarn database:migrate:prod
yarn command:prod cache:flush
yarn command:prod upgrade
yarn command:prod cache:flush
# Only seed on first boot — check if the dev workspace already exists
has_workspace=$(PGPASSWORD=twenty psql -h localhost -U twenty -d default -tAc \
"SELECT EXISTS (SELECT 1 FROM core.workspace WHERE id = '20202020-1c25-4d02-bf25-6aeccf7ea419')")
if [ "$has_workspace" = "f" ]; then
echo "Seeding app dev data..."
yarn command:prod workspace:seed:dev --light || true
else
echo "Dev workspace already seeded, skipping."
fi
echo "Database initialization complete."
+7
View File
@@ -30,6 +30,11 @@ COPY ./packages/twenty-ui /app/packages/twenty-ui
COPY ./packages/twenty-sdk /app/packages/twenty-sdk
COPY ./packages/twenty-server /app/packages/twenty-server
RUN npx nx run twenty-server:lingui:extract && \
npx nx run twenty-server:lingui:compile && \
npx nx run twenty-emails:lingui:extract && \
npx nx run twenty-emails:lingui:compile
RUN npx nx run twenty-server:build
RUN yarn workspaces focus --production twenty-emails twenty-shared twenty-sdk twenty-server
@@ -43,6 +48,8 @@ COPY ./packages/twenty-front /app/packages/twenty-front
COPY ./packages/twenty-ui /app/packages/twenty-ui
COPY ./packages/twenty-shared /app/packages/twenty-shared
COPY ./packages/twenty-sdk /app/packages/twenty-sdk
RUN npx nx run twenty-front:lingui:extract && \
npx nx run twenty-front:lingui:compile
RUN npx nx build twenty-front
@@ -20,19 +20,51 @@ Apps let you build and manage Twenty customizations **as code**. Instead of conf
## Prerequisites
- Node.js 24+ and Yarn 4
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
- Docker (for the local Twenty dev server)
## Getting Started
Create a new app using the official scaffolder, then authenticate and start developing:
Create a new app using the official scaffolder. It can automatically start a local Twenty instance for you:
```bash filename="Terminal"
# Scaffold a new app (includes all examples by default)
# Scaffold a new app — the CLI will offer to start a local Twenty server
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Start dev mode: automatically syncs local changes to your workspace
yarn twenty app:dev
yarn twenty dev
```
### Local Server Management
The SDK includes commands to manage a local Twenty dev server (all-in-one Docker image with PostgreSQL, Redis, server, and worker):
```bash filename="Terminal"
# Start the local server (pulls the image if needed)
yarn twenty server start
# Check server status
yarn twenty server status
# Stream server logs
yarn twenty server logs
# Stop the server
yarn twenty server stop
# Reset all data and start fresh
yarn twenty server reset
```
The local server comes pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`), so you can start developing immediately without any manual setup.
### Authentication
Connect your app to the local server using OAuth:
```bash filename="Terminal"
# Authenticate via OAuth (opens browser)
yarn twenty remote add --local
```
The scaffolder supports two modes for controlling which example files are included:
@@ -157,7 +157,9 @@ plugins: [
Run `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` in database container to get access to admin panel.
#### When running workflow, workflow run fails with "Logic function execution is disabled. Set LOGIC_FUNCTION_TYPE to LOCAL or LAMBDA to enable."
In production, logic functions are disabled by default. Set the `LOGIC_FUNCTION_TYPE` environment variable to `LOCAL` or `LAMBDA` to enable them. This can be configured via environment variables or through the admin panel database variables. See the [Logic Functions setup guide](/developers/self-host/capabilities/setup#logic-functions-available-drivers) for details.
### 1-click Docker compose
+32 -14
View File
@@ -175,7 +175,8 @@
"user-guide/workflows/how-tos/crm-automations/formula-fields",
"user-guide/workflows/how-tos/crm-automations/display-related-record-data",
"user-guide/workflows/how-tos/crm-automations/closed-won-automations",
"user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
"user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
"user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
]
},
{
@@ -621,7 +622,8 @@
"l/fr/user-guide/workflows/how-tos/crm-automations/formula-fields",
"l/fr/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
"l/fr/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
"l/fr/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
"l/fr/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
"l/fr/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
]
},
{
@@ -1067,7 +1069,8 @@
"l/ar/user-guide/workflows/how-tos/crm-automations/formula-fields",
"l/ar/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
"l/ar/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
"l/ar/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
"l/ar/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
"l/ar/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
]
},
{
@@ -1513,7 +1516,8 @@
"l/cs/user-guide/workflows/how-tos/crm-automations/formula-fields",
"l/cs/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
"l/cs/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
"l/cs/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
"l/cs/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
"l/cs/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
]
},
{
@@ -1959,7 +1963,8 @@
"l/de/user-guide/workflows/how-tos/crm-automations/formula-fields",
"l/de/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
"l/de/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
"l/de/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
"l/de/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
"l/de/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
]
},
{
@@ -2405,7 +2410,8 @@
"l/es/user-guide/workflows/how-tos/crm-automations/formula-fields",
"l/es/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
"l/es/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
"l/es/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
"l/es/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
"l/es/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
]
},
{
@@ -2851,7 +2857,8 @@
"l/it/user-guide/workflows/how-tos/crm-automations/formula-fields",
"l/it/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
"l/it/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
"l/it/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
"l/it/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
"l/it/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
]
},
{
@@ -3297,7 +3304,8 @@
"l/ja/user-guide/workflows/how-tos/crm-automations/formula-fields",
"l/ja/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
"l/ja/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
"l/ja/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
"l/ja/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
"l/ja/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
]
},
{
@@ -3743,7 +3751,8 @@
"l/ko/user-guide/workflows/how-tos/crm-automations/formula-fields",
"l/ko/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
"l/ko/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
"l/ko/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
"l/ko/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
"l/ko/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
]
},
{
@@ -4189,7 +4198,8 @@
"l/pt/user-guide/workflows/how-tos/crm-automations/formula-fields",
"l/pt/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
"l/pt/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
"l/pt/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
"l/pt/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
"l/pt/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
]
},
{
@@ -4635,7 +4645,8 @@
"l/ro/user-guide/workflows/how-tos/crm-automations/formula-fields",
"l/ro/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
"l/ro/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
"l/ro/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
"l/ro/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
"l/ro/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
]
},
{
@@ -5081,7 +5092,8 @@
"l/ru/user-guide/workflows/how-tos/crm-automations/formula-fields",
"l/ru/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
"l/ru/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
"l/ru/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
"l/ru/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
"l/ru/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
]
},
{
@@ -5527,7 +5539,8 @@
"l/tr/user-guide/workflows/how-tos/crm-automations/formula-fields",
"l/tr/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
"l/tr/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
"l/tr/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
"l/tr/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
"l/tr/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
]
},
{
@@ -5973,7 +5986,8 @@
"l/zh/user-guide/workflows/how-tos/crm-automations/formula-fields",
"l/zh/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
"l/zh/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
"l/zh/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
"l/zh/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
"l/zh/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
]
},
{
@@ -6302,6 +6316,10 @@
"source": "/developers/extend/capabilities/apps",
"destination": "/developers/extend/apps/getting-started"
},
{
"source": "/developers/extend/mcp",
"destination": "/user-guide/ai/capabilities/mcp"
},
{
"source": "/developers/local-setup",
"destination": "/developers/contribute/capabilities/local-setup"
Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 931 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

@@ -21,19 +21,51 @@ description: أنشئ وأدِر تخصيصات Twenty على هيئة كود.
## المتطلبات الأساسية
* Node.js 24+ وYarn 4
* مساحة عمل Twenty ومفتاح واجهة برمجة التطبيقات (أنشئ واحدًا على https://app.twenty.com/settings/api-webhooks)
* Docker (لخادم تطوير Twenty المحلي)
## البدء
أنشئ تطبيقًا جديدًا باستخدام المُهيئ الرسمي، ثم قم بالمصادقة وابدأ التطوير:
أنشئ تطبيقًا جديدًا باستخدام المولّد الرسمي. يمكنه بدء مثيل محلي من Twenty تلقائيًا لك:
```bash filename="Terminal"
# إنشاء تطبيق جديد (يتضمن جميع الأمثلة افتراضيًا)
# إنشاء تطبيق جديد — ستعرض واجهة سطر الأوامر خيار بدء خادم Twenty محلي
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# ابدأ وضع التطوير: يُزامن التغييرات المحلية تلقائيًا مع مساحة العمل الخاصة بك
yarn twenty app:dev
yarn twenty dev
```
### إدارة الخادم المحلي
يتضمن SDK أوامر لإدارة خادم تطوير Twenty محلي (صورة Docker متكاملة تتضمن PostgreSQL وRedis والخادم والعامل):
```bash filename="Terminal"
# ابدأ الخادم المحلي (يسحب الصورة إذا لزم الأمر)
yarn twenty server start
# تحقّق من حالة الخادم
yarn twenty server status
# بثّ سجلات الخادم
yarn twenty server logs
# أوقف الخادم
yarn twenty server stop
# أعد ضبط جميع البيانات وابدأ من جديد
yarn twenty server reset
```
يأتي الخادم المحلي مهيأً مسبقًا بمساحة عمل ومستخدم (`tim@apple.dev` / `tim@apple.dev`)، بحيث يمكنك البدء في التطوير فورًا دون أي إعداد يدوي.
### المصادقة
وصّل تطبيقك بالخادم المحلي باستخدام OAuth:
```bash filename="Terminal"
# المصادقة عبر OAuth (يفتح المتصفح)
yarn twenty remote add --local
```
يدعم المُنشئ وضعين للتحكم في ملفات الأمثلة التي سيتم تضمينها:
@@ -166,6 +166,10 @@ plugins: [
قم بتشغيل `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` في حاوية قاعدة البيانات للحصول على الوصول إلى لوحة الإدارة.
#### عند تشغيل سير العمل، يفشل تشغيل سير العمل مع الرسالة "تم تعطيل تنفيذ دوال المنطق. عيّن LOGIC_FUNCTION_TYPE إلى LOCAL أو LAMBDA لتمكين ذلك."
في بيئة الإنتاج، يتم تعطيل دوال المنطق افتراضيًا. قم بتعيين متغير البيئة `LOGIC_FUNCTION_TYPE` إلى `LOCAL` أو `LAMBDA` لتمكينها. يمكن تكوين ذلك عبر متغيرات البيئة أو من خلال متغيرات قاعدة البيانات في لوحة الإدارة. راجع [دليل إعداد دوال المنطق](/l/ar/developers/self-host/capabilities/setup#logic-functions-available-drivers) للحصول على التفاصيل.
### Docker compose بنقرة واحدة
#### غير قادر على تسجيل الدخول
@@ -0,0 +1,142 @@
---
title: خادم MCP
description: اربط مساعدي الذكاء الاصطناعي بمساحة عمل Twenty الخاصة بك باستخدام بروتوكول سياق النموذج.
---
<Warning>
MCP حاليًا في مرحلة **ألفا** وهو متاح فقط في بعض مساحات العمل. قد لا يكون مفعّلًا لمساحة عملك بعد.
</Warning>
تعرض Twenty خادم [MCP](https://modelcontextprotocol.io/) بحيث تتمكّن مساعدات الذكاء الاصطناعي — Claude Desktop وClaude Code وCursor وChatGPT وغيرها — من قراءة وكتابة بيانات نظام إدارة علاقات العملاء (CRM) لديك باستخدام اللغة الطبيعية.
استخدم **عنوان URL لمساحة العمل** (عنوان URL الذي تستخدمه للوصول إلى Twenty) كنقطة نهاية MCP. على Twenty Cloud، قد يكون عنوان URL لمساحة العمل هو `https://{mycompany}.twenty.com` أو نطاق مخصص. الخادم متاح على:
| البيئة | نقطة نهاية MCP |
| --------------------- | ---------------------------------------------------------------------------------------- |
| **السحابة** | `https://{your-workspace-url}/mcp` (على سبيل المثال: `https://mycompany.twenty.com/mcp`) |
| **الاستضافة الذاتية** | `https://{your-domain}/mcp` |
## طرق المصادقة
لديك طريقتان لمصادقة عميل MCP: **OAuth** (مُوصى بها) أو **مفتاح API**.
### الخيار أ — OAuth (مُوصى به)
باستخدام OAuth، يفتح عميل MCP لديك نافذة متصفح لتسجيل الدخول. لا يتم تخزين أي أسرار في ملفات الإعداد، ويتم تحديث الرموز المميِّزة تلقائيًا.
<Note>
يتطلب OAuth عميل MCP يدعم [مواصفة تفويض MCP](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization). تدعمه Claude Desktop وClaude Code وCursor وChatGPT.
</Note>
أضِف ما يلي إلى تهيئة عميل MCP لديك، واستبدِل `{your-workspace-url}` بمضيف مساحة العمل لديك (على سبيل المثال: `mycompany.twenty.com`):
```json
{
"mcpServers": {
"twenty": {
"type": "streamable-http",
"url": "https://{your-workspace-url}/mcp"
}
}
}
```
هذا كل شيء — لا حاجة إلى مفتاح API. عند اتصال العميل للمرة الأولى، سيفعل ما يلي:
1. اكتشاف بيانات التعريف الخاصة بـ OAuth لدى Twenty عبر `/.well-known/oauth-protected-resource` و`/.well-known/oauth-authorization-server`
2. تسجيل نفسه كعميل OAuth عبر التسجيل الديناميكي للعميل (RFC 7591)
3. فتح متصفحك لتفويض الوصول
4. استلام الرموز المميِّزة والاتصال بخادم MCP
تعيد الاتصالات اللاحقة استخدام الرموز المميِّزة المخزنة وتحدِّثها تلقائيًا.
### الخيار ب — مفتاح API
إذا كان عميل MCP لديك لا يدعم OAuth، أو كنت تفضّل بيانات اعتماد ثابتة، فمرِّر مفتاح API في ترويسة `Authorization`:
```json
{
"mcpServers": {
"twenty": {
"type": "streamable-http",
"url": "https://{your-workspace-url}/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}
```
<Warning>
يمنح مفتاح API الخاص بك حق الوصول إلى بيانات مساحة العمل. أبعِده عن أنظمة التحكم في الإصدارات وملفات dotfiles المشتركة.
</Warning>
لإنشاء مفتاح API، انتقل إلى **Settings > APIs & Webhooks > + Create key**. راجع [واجهات برمجة التطبيقات](/l/ar/developers/extend/api#create-an-api-key) للتفاصيل.
## البدء السريع
### 1. انسخ الإعداد
انتقل إلى **Settings > AI > More > MCP Server** في Twenty. اختر طريقة المصادقة (OAuth أو مفتاح API)، وانسخ مقطع JSON (سيستخدم بالفعل عنوان URL لمساحة العمل لديك)، ثم الصقه في ملف إعدادات عميل MCP لديك.
| العميل | موقع ملف الإعداد |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Claude Desktop** | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) أو `%APPDATA%\Claude\claude_desktop_config.json` (Windows) |
| **Claude Code** | `~/.claude.json` (المستخدم) أو `.mcp.json` (المشروع) |
| **Cursor** | `.cursor/mcp.json` ضمن مشروعك، أو `~/.cursor/mcp.json` عالميًا |
| **ChatGPT** | فعِّل وضع المطوّر في **Settings > Apps & Connectors > Advanced settings**، ثم استخدم **Create** في **Settings > Apps & Connectors** لإضافة خادم MCP |
### 2. الاتصال
أعِد تشغيل عميل MCP لديك (أو أعد تحميل ملف الإعداد). إذا كنت تستخدم OAuth فسيتم توجيهك إلى Twenty لتفويض الوصول. إذا كنت تستخدم مفتاح API فسيكون الاتصال فوريًا.
### 3. ابدأ باستخدامه
اطلب من مساعد الذكاء الاصطناعي التفاعل مع نظام إدارة علاقات العملاء (CRM) لديك:
* *"أرني أحدث 5 شركات تم إنشاؤها"*
* *"أنشئ شخصًا جديدًا باسم Jane Doe في Acme Corp"*
* *"اعثر على جميع الفرص المفتوحة التي تزيد قيمتها عن 10 آلاف دولار"*
## الأدوات المتاحة
بعد الاتصال، يوفّر خادم MCP أدوات تعكس واجهة برمجة تطبيقات Twenty (API). سير العمل الموصى به هو:
1. **`get_tool_catalog`** — اكتشف جميع الأدوات المتاحة
2. **`learn_tools`** — احصل على مخطط الإدخال لأدوات محددة
3. **`execute_tool`** — شغّل أداة
لا تحتاج إلى تذكّر أسماء الأدوات. اسأل مساعد الذكاء الاصطناعي عمّا يمكنه فعله وسيستدعي `get_tool_catalog` تلقائيًا.
## الصلاحيات
ترث اتصالات MCP أذونات المستخدم المُصادَق عليه (OAuth) أو الدور المُعيَّن لمفتاح API. لتقييد ما يمكن لخادم MCP القيام به:
* **OAuth**: ينطبق دور المستخدم في مساحة العمل.
* **API Key**: عيِّن دورًا لمفتاح API ضمن **Settings > Roles**. راجع [الأذونات](/l/ar/user-guide/permissions-access/capabilities/permissions).
## التكوين ذاتي الاستضافة
في حالات الاستضافة الذاتية، استبدِل `{your-workspace-url}` بعنوان URL الخاص بالخادم لديك. تأكّد من أن قيمة `SERVER_URL` في بيئتك تطابق عنوان URL العام لمثيل Twenty لديك — إذ يُستخدَم ذلك لإنشاء بيانات تعريف اكتشاف OAuth.
```bash
SERVER_URL=https://twenty.yourcompany.com
```
تُشتق نقطة نهاية MCP ونقاط نهاية OAuth وبيانات تعريف الاكتشاف جميعها من هذه القيمة.
## استكشاف الأخطاء وإصلاحها
**أخطاء "Unauthorized" أو 401**
* OAuth: أعد التفويض عبر مسح الرموز المميِّزة المخزنة في عميل MCP لديك ثم أعد الاتصال.
* API Key: تحقّق من أن المفتاح صالح ولم تنتهِ صلاحيته. أعِد توليده إذا لزم الأمر.
**عملية OAuth لا تفتح متصفحًا**
* تأكّد من أن عميل MCP لديك يدعم تفويض MCP. ارجع إلى طريقة مفتاح API إذا لم يكن كذلك.
**انتهاء مهلة الاتصال**
* تحقّق من إمكانية الوصول إلى عنوان URL لنقطة نهاية MCP من جهازك. بالنسبة لحالات الاستضافة الذاتية، تحقّق من أن الخادم يعمل وأن `SERVER_URL` مُعيَّن بشكل صحيح.
@@ -0,0 +1,121 @@
---
title: الرد التلقائي على رسائل البريد الإلكتروني الواردة
description: أنشئ سير عمل يستخدم الذكاء الاصطناعي لفرز رسائل البريد الإلكتروني الواردة وإرسال ردود ضمن سلسلة المحادثة تلقائيًا.
---
استجب لرسائل البريد الإلكتروني الواردة خلال ثوانٍ — وليس ساعات. يستخدم سير العمل هذا وكيلاً للذكاء الاصطناعي لتصفية الضوضاء (النشرات الإخبارية، الرسائل غير المرغوب فيها، الردود التلقائية) وصياغة رد مخصص للرسائل الحقيقية، ثم يرسله كرد مُسلسل داخل المحادثة الأصلية.
## كيف يعمل تسلسل رسائل البريد الإلكتروني
تحمل كل رسالة بريد إلكتروني ترويسة `Message-ID` مخفية — بصمة فريدة يعيّنها خادم بريد المرسل. عند الرد على بريد إلكتروني، يقوم عميل البريد لديك بتعيين ترويسة `In-Reply-To` التي تُشير إلى تلك البصمة. بهذه الطريقة يقوم Gmail وOutlook وكل عميل آخر بتجميع الرسائل في سلاسل المحادثات.
في Twenty، تُخزَّن تلك البصمة كـ `headerMessageId` على كائن Message. يلتقطه سير العمل لديك ويمرّره إلى حقل In-Reply-To لإجراء Send Email.
## بناء سير العمل
### الخطوة 1: إنشاء سير عمل جديد
توجّه إلى **Settings -> Workflows** وانقر **+ New Workflow**.
### الخطوة 2: التشغيل عند الرسائل الواردة
اختر **When a Record is Created** ثم **Messages**.
في كل مرة يصل فيها بريد إلكتروني إلى Twenty، يُشغَّل هذا.
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}} />
### الخطوة 3: البحث عن المُرسِل
أضِف إجراء **Search Records**.
عنوان المُرسِل ليس في الرسالة نفسها — بل في سجل Message Participant المرتبط.
| الحقل | القيمة |
| ---------- | ---------------------------------- |
| **الكائن** | المشاركون في الرسالة |
| **تصفية** | Message **يساوي** `{{trigger.id}}` |
| **تصفية** | Role **يساوي** From |
| **الحد** | 1 |
سيمنحك هذا بريد المُرسِل الإلكتروني في `handle` واسمه في `displayName`.
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}} />
### الخطوة 4: الفرز بالذكاء الاصطناعي وصياغة الرد
أضِف إجراء **AI Agent**. تقوم هذه الخطوة الواحدة بأمرين: تقرر ما إذا كان البريد الإلكتروني يستحق الرد، وإن كان كذلك، تصوغ ردًا.
استخدم موجهًا مثل:
```
You are an email triage assistant for a sales team. Read the following
inbound email and decide if it deserves a reply.
Subject: {{trigger.subject}}
Body: {{trigger.text}}
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
If this email is spam, a newsletter, an automated notification, or
otherwise does not need a human reply, respond with exactly: SKIP
Otherwise, write a short, professional reply (3-4 sentences max) that:
- Acknowledges their specific message
- Lets them know someone from the team will follow up shortly
- Is warm but not overly casual
Respond with only the reply text, no subject line or greeting prefix.
```
يُنتِج AI Agent استجابته في الحقل `response` الذي يمكن للخطوات التالية الرجوع إليه.
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}} />
### الخطوة 5: التفريع بناءً على قرار الذكاء الاصطناعي
أضِف إجراء **If/Else** للتحقق مما إذا كان الذكاء الاصطناعي قرر الرد أم التخطي.
| الحقل | القيمة |
| ------------------ | ------------------------------------------- |
| **الشرط** | AI Agent `response` **لا يحتوي على** `SKIP` |
| **إذا كان صحيحًا** | المتابعة إلى Send Email |
| **وإلا** | لا تفعل شيئًا (ينتهي سير العمل) |
يتم تجاهل الرسائل غير المرغوب فيها والنشرات البريدية والرسائل المُنشأة تلقائيًا. كل ما عدا ذلك ينتقل إلى الخطوة التالية.
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}} />
### الخطوة 6: إرسال رد ضمن سلسلة المحادثة
أضِف إجراء **Send Email** على فرع "if true". انقر **Advanced options** ثم **Add In-Reply-To**.
| الحقل | القيمة |
| --------------- | -------------------------------------- |
| **إلى** | `{{Find Sender.first.handle}}` |
| **الموضوع** | `Re: {{trigger.subject}}` |
| **المحتوى** | `{{AI Triage & Draft Reply.response}}` |
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
حقل In-Reply-To هو ما يجعل هذا ردًا بدلًا من محادثة جديدة. سيراه المستلِم ضمن سلسلة تحت البريد الأصلي في Gmail أو Outlook أو أي عميل آخر.
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}} />
<Tip>
**In-Reply-To** يتوقّع `message.headerMessageId` من المشغّل — إنها البصمة الفريدة للبريد الإلكتروني، وليست عنوان المستلم. إذا تركته فارغًا، فسيُرسَل البريد الإلكتروني على أي حال، ولكن كرسالة مستقلة.
</Tip>
<Warning>
يستخدم Gmail سطر الموضوع لتجميع الرسائل في سلاسل المحادثات. يجب أن يبدأ الموضوع بـ `Re:` (بما في ذلك النقطتان والمسافة) ليعرض Gmail الرد داخل سلسلة المحادثة الأصلية. بدون ذلك، سيظهر الرد كمحادثة منفصلة — حتى إذا تم تعيين ترويسة In-Reply-To بشكل صحيح.
</Warning>
### الخطوة 7: الاختبار والتفعيل
اضغط **Test**، ثم تحقّق من عميل البريد لديك. يجب أن يظهر الرد متداخلًا تحت الرسالة الأصلية.
فعِّل عندما تكون راضيًا عنه.
## أفكار للبناء عليها
* **الرد على كبار الشخصيات فقط** — أضِف فرعًا يتحقق من مجال المُرسِل أو مما إذا كان موجودًا كجهة اتصال في Twenty
* **التوجيه حسب النية** — استخدم موجهات AI Agent منفصلة للتعامل مع استفسارات المبيعات بشكل مختلف عن طلبات الدعم
* **الإثراء قبل الرد** — أضِف خطوة Search Records لجلب شركة المُرسِل أو سجل الصفقات إلى الموجه الخاص بالذكاء الاصطناعي للحصول على ردود أكثر تخصيصًا
@@ -21,19 +21,51 @@ Mit Apps können Sie Twenty-Anpassungen **als Code** erstellen und verwalten. An
## Voraussetzungen
* Node.js 24+ und Yarn 4
* Ein Twenty-Workspace und ein API-Schlüssel (unter https://app.twenty.com/settings/api-webhooks erstellen)
* Docker (für den lokalen Twenty-Dev-Server)
## Erste Schritte
Erstellen Sie mit dem offiziellen Scaffolder eine neue App, authentifizieren Sie sich und beginnen Sie mit der Entwicklung:
Erstelle eine neue App mit dem offiziellen Scaffolder. Der Scaffolder kann für dich automatisch eine lokale Twenty-Instanz starten:
```bash filename="Terminal"
# Eine neue App erstellen (enthält standardmäßig alle Beispiele)
# Eine neue App erstellen — die CLI bietet an, einen lokalen Twenty-Server zu starten
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Dev-Modus starten: synchronisiert lokale Änderungen automatisch mit deinem Arbeitsbereich
yarn twenty app:dev
yarn twenty dev
```
### Lokale Serververwaltung
Das SDK enthält Befehle zur Verwaltung eines lokalen Twenty-Dev-Servers (All-in-One-Docker-Image mit PostgreSQL, Redis, Server und Worker):
```bash filename="Terminal"
# Den lokalen Server starten (lädt das Image bei Bedarf herunter)
yarn twenty server start
# Serverstatus prüfen
yarn twenty server status
# Serverprotokolle streamen
yarn twenty server logs
# Server stoppen
yarn twenty server stop
# Alle Daten zurücksetzen und neu starten
yarn twenty server reset
```
Der lokale Server ist bereits mit einem Arbeitsbereich und einem Benutzer (`tim@apple.dev` / `tim@apple.dev`) vorbefüllt, sodass Sie ohne manuelle Einrichtung sofort mit der Entwicklung beginnen können.
### Authentifizierung
Verbinden Sie Ihre App mithilfe von OAuth mit dem lokalen Server:
```bash filename="Terminal"
# Authenticate via OAuth (opens browser)
yarn twenty remote add --local
```
Das Scaffolding-Tool unterstützt zwei Modi, um zu steuern, welche Beispieldateien enthalten sind:
@@ -166,6 +166,10 @@ plugins: [
Führen Sie den folgenden Befehl im Datenbankcontainer aus, um Zugriff auf das Admin-Panel zu erhalten: `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';`
#### Beim Ausführen eines Workflows schlägt die Workflow-Ausführung fehl mit "Logic function execution is disabled. Set LOGIC_FUNCTION_TYPE to LOCAL or LAMBDA to enable."
In der Produktion sind Logikfunktionen standardmäßig deaktiviert. Setzen Sie die Umgebungsvariable `LOGIC_FUNCTION_TYPE` auf `LOCAL` oder `LAMBDA`, um sie zu aktivieren. Dies kann über Umgebungsvariablen oder über die Datenbankvariablen des Admin-Panels konfiguriert werden. Details finden Sie im [Leitfaden zur Einrichtung von Logikfunktionen](/l/de/developers/self-host/capabilities/setup#logic-functions-available-drivers).
### 1-Klick Docker Compose
#### Kann mich nicht einloggen
@@ -0,0 +1,142 @@
---
title: MCP-Server
description: Verbinden Sie KI-Assistenten mit Ihrem Twenty-Workspace über das Model Context Protocol.
---
<Warning>
MCP befindet sich derzeit in **alpha** und ist nur in einigen Workspaces verfügbar. Möglicherweise ist es für Ihren Workspace noch nicht aktiviert.
</Warning>
Twenty stellt einen [MCP](https://modelcontextprotocol.io/)-Server bereit, damit KI-Assistenten — Claude Desktop, Claude Code, Cursor, ChatGPT und andere — Ihre CRM-Daten in natürlicher Sprache lesen und schreiben können.
Verwenden Sie Ihre **Workspace-URL** (die URL, mit der Sie auf Twenty zugreifen) als MCP-Endpunkt. In Twenty Cloud kann Ihre Workspace-URL `https://{mycompany}.twenty.com` oder eine benutzerdefinierte Domain sein. Der Server ist verfügbar unter:
| Umgebung | MCP-Endpunkt |
| ----------------- | ----------------------------------------------------------------------------- |
| **Cloud** | `https://{your-workspace-url}/mcp` (z. B. `https://mycompany.twenty.com/mcp`) |
| **Selbsthosting** | `https://{your-domain}/mcp` |
## Authentifizierungsmethoden
Sie haben zwei Möglichkeiten, Ihren MCP-Client zu authentifizieren: **OAuth** (empfohlen) oder **API-Schlüssel**.
### Option A — OAuth (empfohlen)
Mit OAuth öffnet Ihr MCP-Client ein Browserfenster, damit Sie sich anmelden können. Es werden keine geheimen Informationen in Konfigurationsdateien gespeichert, und Token werden automatisch erneuert.
<Note>
OAuth erfordert einen MCP-Client, der die [MCP-Autorisierungsspezifikation](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) unterstützt. Claude Desktop, Claude Code, Cursor und ChatGPT unterstützen dies.
</Note>
Fügen Sie dies zu Ihrer MCP-Client-Konfiguration hinzu und ersetzen Sie `{your-workspace-url}` durch den Host Ihrer Workspace-URL (z. B. `mycompany.twenty.com`):
```json
{
"mcpServers": {
"twenty": {
"type": "streamable-http",
"url": "https://{your-workspace-url}/mcp"
}
}
}
```
Das ist alles — kein API-Schlüssel erforderlich. Wenn der Client sich zum ersten Mal verbindet, wird er:
1. Die OAuth-Metadaten von Twenty über `/.well-known/oauth-protected-resource` und `/.well-known/oauth-authorization-server` ermitteln
2. Sich über die dynamische Client-Registrierung (RFC 7591) als OAuth-Client registrieren
3. Ihren Browser öffnen, um den Zugriff zu autorisieren
4. Token empfangen und eine Verbindung zum MCP-Server herstellen
Nachfolgende Verbindungen verwenden die gespeicherten Token erneut und erneuern sie automatisch.
### Option B — API-Schlüssel
Wenn Ihr MCP-Client OAuth nicht unterstützt oder Sie statische Anmeldeinformationen bevorzugen, übergeben Sie einen API-Schlüssel im `Authorization`-Header:
```json
{
"mcpServers": {
"twenty": {
"type": "streamable-http",
"url": "https://{your-workspace-url}/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}
```
<Warning>
Ihr API-Schlüssel gewährt Zugriff auf Workspace-Daten. Halten Sie es von der Versionskontrolle und von gemeinsam genutzten Dotfiles fern.
</Warning>
Um einen API-Schlüssel zu erstellen, gehen Sie zu **Settings > APIs & Webhooks > + Create key**. Details finden Sie unter [APIs](/l/de/developers/extend/api#create-an-api-key).
## Schnellstart
### 1. Konfiguration kopieren
Gehen Sie in Twenty zu **Settings > AI > More > MCP Server**. Wählen Sie Ihre Authentifizierungsmethode (OAuth oder API-Schlüssel), kopieren Sie das JSON-Snippet (es verwendet bereits Ihre Workspace-URL) und fügen Sie es in die Konfigurationsdatei Ihres MCP-Clients ein.
| Client | Speicherort der Konfigurationsdatei |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Claude Desktop** | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) oder `%APPDATA%\Claude\claude_desktop_config.json` (Windows) |
| **Claude Code** | `~/.claude.json` (Benutzer) oder `.mcp.json` (Projekt) |
| **Cursor** | `.cursor/mcp.json` in Ihrem Projekt oder `~/.cursor/mcp.json` global |
| **ChatGPT** | Aktivieren Sie den Entwicklermodus in **Settings > Apps & Connectors > Advanced settings** und verwenden Sie dann **Create** in **Settings > Apps & Connectors**, um den MCP-Server hinzuzufügen |
### 2. Verbinden
Starten Sie Ihren MCP-Client neu (oder laden Sie die Konfiguration neu). Bei Verwendung von OAuth werden Sie zu Twenty weitergeleitet, um den Zugriff zu autorisieren. Bei Verwendung eines API-Schlüssels wird die Verbindung sofort hergestellt.
### 3. Jetzt loslegen
Bitten Sie Ihren KI-Assistenten, mit Ihrem CRM zu interagieren:
* *"Zeige mir die 5 zuletzt erstellten Unternehmen"*
* *"Erstelle eine neue Person namens Jane Doe bei Acme Corp"*
* *"Finde alle offenen Verkaufschancen mit einem Wert von mehr als $10k"*
## Verfügbare Tools
Nach der Verbindung stellt der MCP-Server Tools bereit, die die Twenty-API widerspiegeln. Der empfohlene Workflow ist:
1. **`get_tool_catalog`** — alle verfügbaren Tools entdecken
2. **`learn_tools`** — das Eingabeschema für bestimmte Tools abrufen
3. **`execute_tool`** — ein Tool ausführen
Sie müssen sich die Tool-Namen nicht merken. Fragen Sie Ihren KI-Assistenten, was er tun kann, und er ruft `get_tool_catalog` automatisch auf.
## Berechtigungen
MCP-Verbindungen erben die Berechtigungen des authentifizierten Benutzers (OAuth) oder die dem API-Schlüssel zugewiesene Rolle. So beschränken Sie, was der MCP-Server tun darf:
* **OAuth**: Es gilt die Workspace-Rolle des Benutzers.
* **API-Schlüssel**: Weisen Sie dem API-Schlüssel unter **Settings > Roles** eine Rolle zu. Siehe [Berechtigungen](/l/de/user-guide/permissions-access/capabilities/permissions).
## Selbstgehostete Konfiguration
Für selbstgehostete Instanzen ersetzen Sie `{your-workspace-url}` durch die URL Ihres Servers. Stellen Sie sicher, dass `SERVER_URL` in Ihrer Umgebung der öffentlichen URL Ihrer Twenty-Instanz entspricht — dieser Wert wird verwendet, um die OAuth-Discovery-Metadaten zu generieren.
```bash
SERVER_URL=https://twenty.yourcompany.com
```
Der MCP-Endpunkt, die OAuth-Endpunkte und die Discovery-Metadaten leiten sich alle von diesem Wert ab.
## Fehlerbehebung
**"Unauthorized"- oder 401-Fehler**
* OAuth: Autorisieren Sie erneut, indem Sie die gespeicherten Token in Ihrem MCP-Client löschen und die Verbindung wiederherstellen.
* API-Schlüssel: Überprüfen Sie, ob der Schlüssel gültig ist und nicht abgelaufen ist. Generieren Sie ihn bei Bedarf neu.
**Der OAuth-Flow öffnet keinen Browser**
* Stellen Sie sicher, dass Ihr MCP-Client MCP Authorization unterstützt. Wechseln Sie andernfalls zur API-Schlüssel-Methode.
**Verbindungszeitüberschreitung**
* Stellen Sie sicher, dass die MCP-Endpunkt-URL von Ihrem Rechner aus erreichbar ist. Bei selbstgehosteten Instanzen prüfen Sie, ob der Server läuft und `SERVER_URL` korrekt gesetzt ist.
@@ -0,0 +1,121 @@
---
title: Automatische Antwort auf eingehende E-Mails
description: Erstellen Sie einen Workflow, der KI nutzt, um eingehende E-Mails zu sichten und automatisch Antworten im Thread zu senden.
---
Antworten Sie auf eingehende E-Mails in Sekunden — nicht in Stunden. Dieser Workflow verwendet einen KI-Agenten, um Störendes auszufiltern (Newsletter, Spam, automatische Antworten) und eine personalisierte Antwort auf echte Nachrichten zu verfassen und sie dann als Antwort im Thread innerhalb der ursprünglichen Unterhaltung zu senden.
## Wie E-Mail-Threading funktioniert
Jede E-Mail enthält einen verborgenen `Message-ID`-Header — einen eindeutigen Fingerabdruck, der vom Mailserver des Absenders zugewiesen wird. Wenn Sie auf eine E-Mail antworten, setzt Ihr E-Mail-Client einen `In-Reply-To`-Header, der auf diesen Fingerabdruck verweist. So gruppieren Gmail, Outlook und alle anderen Clients Nachrichten in Threads.
In Twenty wird dieser Fingerabdruck als `headerMessageId` am Message-Objekt gespeichert. Ihr Workflow greift ihn auf und übergibt ihn an das Feld In-Reply-To der Aktion Send Email.
## Den Workflow erstellen
### Schritt 1: Neuen Workflow erstellen
Gehen Sie zu **Settings -> Workflows** und klicken Sie auf **+ New Workflow**.
### Schritt 2: Bei eingehenden Nachrichten auslösen
Wählen Sie **When a Record is Created** und dann **Messages**.
Jedes Mal, wenn eine E-Mail in Twenty eingeht, wird dies ausgelöst.
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}} />
### Schritt 3: Absender ermitteln
Fügen Sie eine **Datensätze suchen**-Aktion hinzu.
Die Absenderadresse befindet sich nicht in der Nachricht selbst — sie steht im zugehörigen Datensatz Message Participant.
| Feld | Wert |
| ---------- | -------------------------------- |
| **Objekt** | Nachrichtenteilnehmer |
| **Filter** | Message **ist** `{{trigger.id}}` |
| **Filter** | Rolle **ist** From |
| **Limit** | 1 |
Dadurch erhalten Sie die E-Mail-Adresse des Absenders in `handle` und den Namen in `displayName`.
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}} />
### Schritt 4: KI-Triage und Antwortentwurf
Fügen Sie eine **AI Agent**-Aktion hinzu. Dieser einzelne Schritt erledigt zwei Dinge: Er entscheidet, ob die E-Mail eine Antwort verdient, und falls ja, verfasst er eine.
Verwenden Sie einen Prompt wie:
```
You are an email triage assistant for a sales team. Read the following
inbound email and decide if it deserves a reply.
Subject: {{trigger.subject}}
Body: {{trigger.text}}
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
If this email is spam, a newsletter, an automated notification, or
otherwise does not need a human reply, respond with exactly: SKIP
Otherwise, write a short, professional reply (3-4 sentences max) that:
- Acknowledges their specific message
- Lets them know someone from the team will follow up shortly
- Is warm but not overly casual
Respond with only the reply text, no subject line or greeting prefix.
```
Der AI Agent gibt seine Antwort in einem Feld `response` aus, auf das sich die nächsten Schritte beziehen können.
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}} />
### Schritt 5: Verzweigen anhand der KI-Entscheidung
Fügen Sie eine **If/Else**-Aktion hinzu, um zu prüfen, ob die KI sich entschieden hat zu antworten oder zu überspringen.
| Feld | Wert |
| ------------- | -------------------------------------------- |
| **Bedingung** | AI Agent `response` **enthält nicht** `SKIP` |
| **Wenn wahr** | Mit Send Email fortfahren |
| **Sonst** | Nichts tun (Workflow endet) |
Spam, Newsletter und automatisch erzeugte Nachrichten werden verworfen. Alles andere geht zum nächsten Schritt über.
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}} />
### Schritt 6: Eine Antwort im Thread senden
Fügen Sie im "Wenn wahr"-Zweig eine **Send Email**-Aktion hinzu. Klicken Sie auf **Advanced options**, dann **Add In-Reply-To**.
| Feld | Wert |
| --------------- | -------------------------------------- |
| **An** | `{{Find Sender.first.handle}}` |
| **Betreff** | `Re: {{trigger.subject}}` |
| **Body** | `{{AI Triage & Draft Reply.response}}` |
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
Das Feld In-Reply-To sorgt dafür, dass dies eine Antwort und keine neue Unterhaltung ist. Der Empfänger sieht sie als Teil des Threads unter der ursprünglichen E-Mail in Gmail, Outlook oder jedem anderen Client.
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}} />
<Tip>
**In-Reply-To** erwartet eine `message.headerMessageId` aus dem Trigger — das ist der eindeutige Fingerabdruck der E-Mail, keine Empfängeradresse. Wenn Sie es leer lassen, wird die E-Mail trotzdem gesendet, nur als eigenständige Nachricht.
</Tip>
<Warning>
Gmail verwendet die Betreffzeile, um Nachrichten in Threads zu gruppieren. Der Betreff **muss** mit `Re:` beginnen (einschließlich Doppelpunkt und Leerzeichen), damit Gmail die Antwort im ursprünglichen Thread anzeigt. Ohne dies erscheint die Antwort als separate Unterhaltung — selbst wenn der In-Reply-To-Header korrekt gesetzt ist.
</Warning>
### Schritt 7: Testen und aktivieren
Klicken Sie auf **Test**, und prüfen Sie dann Ihren E-Mail-Client. Die Antwort sollte unter der ursprünglichen Nachricht verschachtelt erscheinen.
Aktivieren Sie ihn, wenn Sie zufrieden sind.
## Ideen zum Ausbau
* **Nur an VIPs antworten** — fügen Sie einen Zweig hinzu, der die Domain des Absenders prüft oder ob er in Twenty als Kontakt existiert
* **Nach Absicht routen** — verwenden Sie separate AI Agent-Prompts, um Vertriebsanfragen anders zu bearbeiten als Supportanfragen
* **Vor der Antwort anreichern** — fügen Sie einen Schritt Search Records hinzu, um das Unternehmen oder die Deal-Historie des Absenders in den AI-Prompt zu übernehmen, für persönlichere Antworten
@@ -21,19 +21,51 @@ Le app ti consentono di creare e gestire le personalizzazioni di Twenty **come c
## Prerequisiti
* Node.js 24+ e Yarn 4
* Uno spazio di lavoro Twenty e una chiave API (creane una su https://app.twenty.com/settings/api-webhooks)
* Docker (per il server di sviluppo locale di Twenty)
## Per iniziare
Crea una nuova app utilizzando lo scaffolder ufficiale, quindi autenticati e inizia a sviluppare:
Crea una nuova app utilizzando lo scaffolder ufficiale. Può avviare automaticamente un'istanza locale di Twenty per te:
```bash filename="Terminal"
# Crea lo scaffold di una nuova app (include tutti gli esempi per impostazione predefinita)
# Crea lo scaffold di una nuova app — la CLI offrirà di avviare un server locale di Twenty
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Avvia la modalità di sviluppo: sincronizza automaticamente le modifiche locali con il tuo workspace
yarn twenty app:dev
yarn twenty dev
```
### Gestione del server locale
L'SDK include comandi per gestire un server di sviluppo locale di Twenty (immagine Docker all-in-one con PostgreSQL, Redis, server e worker):
```bash filename="Terminal"
# Avvia il server locale (scarica l'immagine se necessario)
yarn twenty server start
# Verifica lo stato del server
yarn twenty server status
# Segui i log del server
yarn twenty server logs
# Arresta il server
yarn twenty server stop
# Reimposta tutti i dati e riparti da zero
yarn twenty server reset
```
Il server locale è preconfigurato con uno spazio di lavoro e un utente (`tim@apple.dev` / `tim@apple.dev`), così puoi iniziare a sviluppare immediatamente senza alcuna configurazione manuale.
### Autenticazione
Collega la tua app al server locale tramite OAuth:
```bash filename="Terminal"
# Autenticati tramite OAuth (apre il browser)
yarn twenty remote add --local
```
Lo strumento di scaffolding supporta due modalità per controllare quali file di esempio vengono inclusi:
@@ -167,6 +167,10 @@ plugins: [
Esegui `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` nel container del database per accedere al pannello di amministrazione.
#### Durante l'esecuzione di un workflow, l'esecuzione del workflow non riesce con "L'esecuzione delle funzioni logiche è disabilitata. Imposta LOGIC_FUNCTION_TYPE su LOCAL o LAMBDA per abilitarle."
In produzione, le funzioni logiche sono disabilitate per impostazione predefinita. Imposta la variabile d'ambiente `LOGIC_FUNCTION_TYPE` su `LOCAL` o `LAMBDA` per abilitarle. Questo può essere configurato tramite variabili d'ambiente o tramite le variabili del database del pannello di amministrazione. Consulta la [guida alla configurazione delle funzioni logiche](/l/it/developers/self-host/capabilities/setup#logic-functions-available-drivers) per dettagli.
### Composizione Docker a un clic
#### Impossibile connettersi
@@ -0,0 +1,142 @@
---
title: Server MCP
description: Collega gli assistenti AI al tuo spazio di lavoro di Twenty utilizzando il Model Context Protocol.
---
<Warning>
MCP è attualmente in **alpha** ed è disponibile solo su alcuni spazi di lavoro. Potrebbe non essere ancora abilitato per il tuo spazio di lavoro.
</Warning>
Twenty espone un server [MCP](https://modelcontextprotocol.io/) affinché gli assistenti AI — Claude Desktop, Claude Code, Cursor, ChatGPT e altri — possano leggere e scrivere i dati del tuo CRM in linguaggio naturale.
Usa l'**URL dello spazio di lavoro** (l'URL che usi per accedere a Twenty) come endpoint MCP. Su Twenty Cloud, l'URL del tuo spazio di lavoro potrebbe essere `https://{mycompany}.twenty.com` oppure un dominio personalizzato. Il server è disponibile all'indirizzo:
| Ambiente | Endpoint MCP |
| ----------------- | ------------------------------------------------------------------------------ |
| **Cloud** | `https://{your-workspace-url}/mcp` (ad es. `https://mycompany.twenty.com/mcp`) |
| **Auto-ospitato** | `https://{your-domain}/mcp` |
## Metodi di autenticazione
Hai due modi per autenticare il tuo client MCP: **OAuth** (consigliato) o **API Key**.
### Opzione A — OAuth (Consigliato)
Con OAuth, il tuo client MCP apre una finestra del browser per effettuare l'accesso. Nessun segreto viene archiviato nei file di configurazione e i token si rinnovano automaticamente.
<Note>
OAuth richiede un client MCP che supporti la [specifica MCP Authorization](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization). Claude Desktop, Claude Code, Cursor e ChatGPT lo supportano.
</Note>
Aggiungi questo alla configurazione del tuo client MCP, sostituendo `{your-workspace-url}` con l'host del tuo spazio di lavoro (ad es. `mycompany.twenty.com`):
```json
{
"mcpServers": {
"twenty": {
"type": "streamable-http",
"url": "https://{your-workspace-url}/mcp"
}
}
}
```
È tutto — non è necessaria alcuna chiave API. Quando il client si connette per la prima volta, eseguirà:
1. Scoprire i metadati OAuth di Twenty tramite `/.well-known/oauth-protected-resource` e `/.well-known/oauth-authorization-server`
2. Registrarsi come client OAuth tramite registrazione dinamica del client (RFC 7591)
3. Aprire il browser per autorizzare l'accesso
4. Ricevere i token e connettersi al server MCP
Le connessioni successive riutilizzano i token memorizzati e li rinnovano automaticamente.
### Opzione B — Chiave API
Se il tuo client MCP non supporta OAuth, o preferisci credenziali statiche, passa una chiave API nell'header `Authorization`:
```json
{
"mcpServers": {
"twenty": {
"type": "streamable-http",
"url": "https://{your-workspace-url}/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}
```
<Warning>
La tua chiave API concede l'accesso ai dati dello spazio di lavoro. Mantienila fuori dal controllo versione e dai dotfile condivisi.
</Warning>
Per creare una chiave API, vai su **Impostazioni > API e Webhook > + Crea chiave**. Vedi [API](/l/it/developers/extend/api#create-an-api-key) per i dettagli.
## Avvio rapido
### 1. Copia la configurazione
Vai su **Impostazioni > AI > Altro > MCP Server** in Twenty. Scegli il metodo di autenticazione (OAuth o Chiave API), copia lo snippet JSON (userà già l'URL del tuo spazio di lavoro) e incollalo nel file di configurazione del tuo client MCP.
| Client | Percorso del file di configurazione |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Claude Desktop** | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) o `%APPDATA%\Claude\claude_desktop_config.json` (Windows) |
| **Claude Code** | `~/.claude.json` (utente) o `.mcp.json` (progetto) |
| **Cursor** | `.cursor/mcp.json` nel tuo progetto, oppure `~/.cursor/mcp.json` globalmente |
| **ChatGPT** | Attiva la Modalità sviluppatore in **Impostazioni > App e Connettori > Impostazioni avanzate**, quindi usa **Crea** in **Impostazioni > App e Connettori** per aggiungere il server MCP |
### 2. Connetti
Riavvia il tuo client MCP (o ricarica la configurazione). Se usi OAuth verrai reindirizzato a Twenty per autorizzare l'accesso. Se usi una chiave API la connessione è immediata.
### 3. Inizia a usarlo
Chiedi al tuo assistente AI di interagire con il tuo CRM:
* *"Mostrami le 5 aziende create più di recente"*
* *"Crea una nuova persona di nome Jane Doe presso Acme Corp"*
* *"Trova tutte le opportunità aperte di valore superiore a $10k"*
## Strumenti disponibili
Una volta connesso, il server MCP espone strumenti che rispecchiano l'API di Twenty. Il flusso di lavoro consigliato è:
1. **`get_tool_catalog`** — scoprire tutti gli strumenti disponibili
2. **`learn_tools`** — ottenere lo schema di input per strumenti specifici
3. **`execute_tool`** — eseguire uno strumento
Non è necessario ricordare i nomi degli strumenti. Chiedi al tuo assistente AI cosa può fare e chiamerà `get_tool_catalog` automaticamente.
## Permessi
Le connessioni MCP ereditano le autorizzazioni dell'utente autenticato (OAuth) o il ruolo assegnato alla chiave API. Per limitare ciò che il server MCP può fare:
* **OAuth**: si applica il ruolo dell'utente nello spazio di lavoro.
* **Chiave API**: assegna un ruolo alla chiave API in **Impostazioni > Ruoli**. Vedi [Autorizzazioni](/l/it/user-guide/permissions-access/capabilities/permissions).
## Configurazione self-hosted
Per le istanze self-hosted, sostituisci `{your-workspace-url}` con l'URL del tuo server. Assicurati che `SERVER_URL` nel tuo ambiente corrisponda all'URL pubblico della tua istanza Twenty — viene utilizzato per generare i metadati di discovery OAuth.
```bash
SERVER_URL=https://twenty.yourcompany.com
```
L'endpoint MCP, gli endpoint OAuth e i metadati di discovery derivano tutti da questo valore.
## Risoluzione dei problemi
**Errori "Unauthorized" o 401**
* OAuth: esegui nuovamente l'autorizzazione eliminando i token memorizzati nel tuo client MCP e riconnettiti.
* Chiave API: verifica che la chiave sia valida e non sia scaduta. Rigenerala se necessario.
**Il flusso OAuth non apre il browser**
* Assicurati che il tuo client MCP supporti MCP Authorization. In caso contrario, ricorri al metodo con Chiave API.
**Timeout di connessione**
* Verifica che l'URL dell'endpoint MCP sia raggiungibile dalla tua macchina. Per le istanze self-hosted, controlla che il server sia in esecuzione e che `SERVER_URL` sia impostato correttamente.
@@ -0,0 +1,121 @@
---
title: Risposta automatica alle email in arrivo
description: Crea un flusso di lavoro che utilizza l'IA per smistare le email in arrivo e inviare automaticamente risposte in thread.
---
Rispondi alle email in arrivo in pochi secondi — non ore. Questo flusso di lavoro utilizza un agente IA per filtrare il rumore (newsletter, spam, risposte automatiche) e redigere una risposta personalizzata ai messaggi reali, quindi la invia come risposta in thread all'interno della conversazione originale.
## Come funziona il threading delle email
Ogni email include un'intestazione nascosta `Message-ID` — un'impronta univoca assegnata dal server di posta del mittente. Quando rispondi a un'email, il tuo client di posta imposta un'intestazione `In-Reply-To` che fa riferimento a quell'impronta. È così che Gmail, Outlook e tutti gli altri client raggruppano i messaggi nei thread.
In Twenty, quell'impronta è memorizzata come `headerMessageId` sull'oggetto Message. Il tuo flusso di lavoro la recupera e la passa al campo In-Reply-To dell'azione Send Email.
## Creazione del flusso di lavoro
### Passaggio 1: Crea un nuovo flusso di lavoro
Vai a **Impostazioni -> Flussi di lavoro** e fai clic su **+ New Workflow**.
### Passaggio 2: Imposta il trigger sui messaggi in arrivo
Scegli **When a Record is Created** e seleziona **Messages**.
Ogni volta che un'email arriva in Twenty, questo si attiva.
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}} />
### Passaggio 3: Cerca chi l'ha inviato
Aggiungi un'azione **Search Records**.
L'indirizzo del mittente non è presente sul messaggio stesso — si trova nel record Message Participant correlato.
| Campo | Valore |
| ----------- | ------------------------------- |
| **Oggetto** | Partecipanti al messaggio |
| **Filtro** | Message **is** `{{trigger.id}}` |
| **Filtro** | Role **is** From |
| **Limite** | 1 |
Questo ti fornisce l'email del mittente in `handle` e il suo nome in `displayName`.
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}} />
### Passaggio 4: Triage con AI e bozza di risposta
Aggiungi un'azione **AI Agent**. Questo singolo passaggio fa due cose: decide se l'email merita una risposta e, in tal caso, ne scrive una.
Usa un prompt come:
```
You are an email triage assistant for a sales team. Read the following
inbound email and decide if it deserves a reply.
Subject: {{trigger.subject}}
Body: {{trigger.text}}
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
If this email is spam, a newsletter, an automated notification, or
otherwise does not need a human reply, respond with exactly: SKIP
Otherwise, write a short, professional reply (3-4 sentences max) that:
- Acknowledges their specific message
- Lets them know someone from the team will follow up shortly
- Is warm but not overly casual
Respond with only the reply text, no subject line or greeting prefix.
```
AI Agent produce la sua risposta in un campo `response` a cui i passaggi successivi possono fare riferimento.
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}} />
### Passaggio 5: Ramifica in base alla decisione dell'AI
Aggiungi un'azione **If/Else** per verificare se l'AI ha deciso di rispondere o saltare.
| Campo | Valore |
| -------------- | ----------------------------------------------- |
| **Condizione** | AI Agent `response` **does not contain** `SKIP` |
| **Se vero** | Continua con Send Email |
| **Altrimenti** | Non fare nulla (il flusso di lavoro termina) |
Spam, newsletter e messaggi generati automaticamente vengono scartati. Tutto il resto passa al passaggio successivo.
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}} />
### Passaggio 6: Invia una risposta nello stesso thread
Aggiungi un'azione **Send Email** nel ramo "if true". Fai clic su **Advanced options**, quindi su **Add In-Reply-To**.
| Campo | Valore |
| --------------- | -------------------------------------- |
| **A** | `{{Find Sender.first.handle}}` |
| **Oggetto** | `Re: {{trigger.subject}}` |
| **Corpo** | `{{AI Triage & Draft Reply.response}}` |
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
Il campo In-Reply-To è ciò che rende questo un messaggio di risposta invece di una nuova conversazione. Il destinatario lo vede nel thread sotto l'email originale in Gmail, Outlook o qualsiasi altro client.
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}} />
<Tip>
**In-Reply-To** si aspetta un `message.headerMessageId` dal trigger — è l'impronta univoca dell'email, non un indirizzo del destinatario. Se lo lasci vuoto, l'email viene comunque inviata, solo come messaggio autonomo.
</Tip>
<Warning>
Gmail usa l'oggetto per raggruppare i messaggi nei thread. L'oggetto **deve** iniziare con `Re:` (inclusi i due punti e lo spazio) affinché Gmail visualizzi la risposta all'interno del thread originale. Senza di esso, la risposta apparirà come una conversazione separata — anche se l'intestazione In-Reply-To è impostata correttamente.
</Warning>
### Passaggio 7: Testa e attiva
Fai clic su **Test**, quindi controlla il tuo client di posta. La risposta dovrebbe comparire annidata sotto il messaggio originale.
Attivalo quando sei soddisfatto.
## Idee per sviluppare ulteriormente
* **Rispondi solo ai VIP** — aggiungi un ramo che controlli il dominio del mittente o se esiste come Contatto in Twenty
* **Instrada in base all'intento** — usa prompt distinti di AI Agent per gestire le richieste commerciali in modo diverso rispetto a quelle di supporto
* **Arricchisci prima di rispondere** — aggiungi un passaggio Search Records per inserire nel prompt dell'AI l'azienda del mittente o la cronologia delle trattative per risposte più personalizzate
@@ -21,19 +21,51 @@ Os aplicativos permitem criar e gerenciar personalizações do Twenty **como có
## Pré-requisitos
* Node.js 24+ e Yarn 4
* Um espaço de trabalho do Twenty e uma chave de API (crie uma em https://app.twenty.com/settings/api-webhooks)
* Docker (para o servidor de desenvolvimento local do Twenty)
## Primeiros passos
Crie um novo aplicativo usando o gerador oficial, depois autentique-se e comece a desenvolver:
Crie um novo app usando o gerador oficial de estrutura. Ele pode iniciar automaticamente uma instância local do Twenty para você:
```bash filename="Terminal"
# Criar a estrutura de um novo app (inclui todos os exemplos por padrão)
# Criar a estrutura de um novo app — a CLI oferecerá iniciar um servidor local do Twenty
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Iniciar modo de desenvolvimento: sincroniza automaticamente as alterações locais com seu workspace
yarn twenty app:dev
yarn twenty dev
```
### Gerenciamento do Servidor Local
O SDK inclui comandos para gerenciar um servidor de desenvolvimento local do Twenty (imagem Docker all-in-one com PostgreSQL, Redis, servidor e worker):
```bash filename="Terminal"
# Iniciar o servidor local (faz pull da imagem se necessário)
yarn twenty server start
# Verificar o status do servidor
yarn twenty server status
# Transmitir os logs do servidor
yarn twenty server logs
# Parar o servidor
yarn twenty server stop
# Redefinir todos os dados e começar do zero
yarn twenty server reset
```
O servidor local já vem pré-configurado com um espaço de trabalho e um usuário (`tim@apple.dev` / `tim@apple.dev`), para que você possa começar a desenvolver imediatamente, sem qualquer configuração manual.
### Autenticação
Conecte seu aplicativo ao servidor local usando OAuth:
```bash filename="Terminal"
# Authenticate via OAuth (opens browser)
yarn twenty remote add --local
```
O gerador de estrutura oferece suporte a dois modos para controlar quais arquivos de exemplo são incluídos:
@@ -166,6 +166,10 @@ plugins: [
Execute `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'você@seudominio.com';` no contêiner de banco de dados para obter acesso ao painel administrativo.
#### Ao executar um fluxo de trabalho, a execução do fluxo de trabalho falha com "A execução da função de lógica está desativada. Defina LOGIC_FUNCTION_TYPE como LOCAL ou LAMBDA para ativar."
Em produção, as funções de lógica estão desativadas por padrão. Defina a variável de ambiente `LOGIC_FUNCTION_TYPE` como `LOCAL` ou `LAMBDA` para ativá-las. Isso pode ser configurado por meio de variáveis de ambiente ou pelas variáveis de banco de dados do painel de administração. Veja o [guia de configuração de Funções de Lógica](/l/pt/developers/self-host/capabilities/setup#logic-functions-available-drivers) para obter detalhes.
### Docker compose com um clique
#### Impossível efetuar login
@@ -0,0 +1,142 @@
---
title: Servidor MCP
description: Conecte assistentes de IA ao seu espaço de trabalho do Twenty usando o Model Context Protocol.
---
<Warning>
O MCP está atualmente em **alfa** e está disponível apenas em alguns espaços de trabalho. O MCP pode ainda não estar ativado no seu espaço de trabalho.
</Warning>
O Twenty expõe um servidor [MCP](https://modelcontextprotocol.io/) para que assistentes de IA — Claude Desktop, Claude Code, Cursor, ChatGPT e outros — possam ler e escrever seus dados de CRM por meio de linguagem natural.
Use o **URL do espaço de trabalho** (o URL que você usa para acessar o Twenty) como o endpoint do MCP. Na Twenty Cloud, o URL do seu espaço de trabalho pode ser `https://{mycompany}.twenty.com` ou um domínio personalizado. O servidor está disponível em:
| Ambiente | Endpoint do MCP |
| ------------------ | ------------------------------------------------------------------------------------ |
| **Nuvem** | `https://{your-workspace-url}/mcp` (por exemplo, `https://mycompany.twenty.com/mcp`) |
| **Auto-hospedado** | `https://{your-domain}/mcp` |
## Métodos de autenticação
Você tem duas maneiras de autenticar seu cliente MCP: **OAuth** (recomendado) ou **Chave de API**.
### Opção A — OAuth (recomendado)
Com o OAuth, seu cliente MCP abre uma janela do navegador para você fazer login. Nenhum segredo é armazenado em arquivos de configuração, e os tokens são atualizados automaticamente.
<Note>
O OAuth requer um cliente MCP que ofereça suporte à [especificação de Autorização MCP](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization). Claude Desktop, Claude Code, Cursor e ChatGPT oferecem suporte.
</Note>
Adicione isto à configuração do seu cliente MCP, substituindo `{your-workspace-url}` pelo host do seu espaço de trabalho (por exemplo, `mycompany.twenty.com`):
```json
{
"mcpServers": {
"twenty": {
"type": "streamable-http",
"url": "https://{your-workspace-url}/mcp"
}
}
}
```
É isso — nenhuma chave de API é necessária. Quando o cliente se conectar pela primeira vez, ele irá:
1. Descobrir os metadados de OAuth do Twenty por meio de `/.well-known/oauth-protected-resource` e `/.well-known/oauth-authorization-server`
2. Registrar-se como um cliente OAuth por meio de registro dinâmico de cliente (RFC 7591)
3. Abrir seu navegador para autorizar o acesso
4. Receber tokens e conectar-se ao servidor MCP
Conexões subsequentes reutilizam os tokens armazenados e os atualizam automaticamente.
### Opção B — Chave de API
Se o seu cliente MCP não oferecer suporte a OAuth, ou se você preferir credenciais estáticas, passe uma chave de API no cabeçalho `Authorization`:
```json
{
"mcpServers": {
"twenty": {
"type": "streamable-http",
"url": "https://{your-workspace-url}/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}
```
<Warning>
Sua chave de API concede acesso aos dados do espaço de trabalho. Mantenha-a fora do controle de versão e de dotfiles compartilhados.
</Warning>
Para criar uma chave de API, vá em **Settings > APIs & Webhooks > + Create key**. Veja [APIs](/l/pt/developers/extend/api#create-an-api-key) para detalhes.
## Início rápido
### 1. Copie a configuração
Vá até **Settings > AI > More > MCP Server** no Twenty. Escolha seu método de autenticação (OAuth ou Chave de API), copie o trecho de JSON (ele já usará o URL do seu espaço de trabalho) e cole-o no arquivo de configuração do seu cliente MCP.
| Cliente | Local do arquivo de configuração |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Claude Desktop** | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) ou `%APPDATA%\Claude\claude_desktop_config.json` (Windows) |
| **Claude Code** | `~/.claude.json` (usuário) ou `.mcp.json` (projeto) |
| **Cursor** | `.cursor/mcp.json` no seu projeto, ou `~/.cursor/mcp.json` globalmente |
| **ChatGPT** | Ative o Modo Desenvolvedor em **Settings > Apps & Connectors > Advanced settings** e, em seguida, use **Create** em **Settings > Apps & Connectors** para adicionar o servidor MCP |
### 2. Conectar
Reinicie seu cliente MCP (ou recarregue a configuração). Se estiver usando OAuth, você será redirecionado ao Twenty para autorizar o acesso. Se estiver usando uma chave de API, a conexão é imediata.
### 3. Comece a usá-lo
Peça ao seu assistente de IA para interagir com seu CRM:
* *"Mostre-me as 5 empresas criadas mais recentemente"*
* *"Crie uma nova pessoa chamada Jane Doe na Acme Corp"*
* *"Encontre todas as oportunidades em aberto com valor superior a $10k"*
## Ferramentas disponíveis
Depois de conectado, o servidor MCP expõe ferramentas que refletem a API do Twenty. O fluxo de trabalho recomendado é:
1. **`get_tool_catalog`** — descobrir todas as ferramentas disponíveis
2. **`learn_tools`** — obter o esquema de entrada para ferramentas específicas
3. **`execute_tool`** — executar uma ferramenta
Você não precisa se lembrar dos nomes das ferramentas. Pergunte ao seu assistente de IA o que ele pode fazer e ele chamará `get_tool_catalog` automaticamente.
## Permissões
As conexões MCP herdam as permissões do usuário autenticado (OAuth) ou da função atribuída à chave de API. Para restringir o que o servidor MCP pode fazer:
* **OAuth**: Aplica-se à função do espaço de trabalho do usuário.
* **Chave de API**: Atribua uma função à chave de API em **Settings > Roles**. Veja [Permissões](/l/pt/user-guide/permissions-access/capabilities/permissions).
## Configuração auto-hospedada
Para instâncias auto-hospedadas, substitua `{your-workspace-url}` pelo URL do seu servidor. Certifique-se de que `SERVER_URL` no seu ambiente corresponda ao URL público da sua instância do Twenty — isso é usado para gerar os metadados de descoberta do OAuth.
```bash
SERVER_URL=https://twenty.yourcompany.com
```
O endpoint do MCP, os endpoints de OAuth e os metadados de descoberta derivam todos desse valor.
## Resolução de Problemas
**Erros "Unauthorized" ou 401**
* OAuth: autorize novamente limpando os tokens armazenados no seu cliente MCP e reconectando.
* Chave de API: verifique se a chave é válida e não expirou. Gere-a novamente, se necessário.
**O fluxo do OAuth não abre um navegador**
* Garanta que seu cliente MCP ofereça suporte à Autorização MCP. Se não oferecer, utilize o método de Chave de API.
**Tempo limite de conexão**
* Confirme que o URL do endpoint MCP é acessível a partir da sua máquina. Para instâncias auto-hospedadas, verifique se o servidor está em execução e se `SERVER_URL` está definido corretamente.
@@ -0,0 +1,121 @@
---
title: Resposta automática a e-mails de entrada
description: Crie um fluxo de trabalho que use IA para fazer a triagem dos e-mails de entrada e enviar respostas encadeadas automaticamente.
---
Responda a e-mails de entrada em segundos — não em horas. Este fluxo de trabalho usa um Agente de IA para filtrar o ruído (boletins informativos, spam, respostas automáticas) e redigir uma resposta personalizada para mensagens reais e, em seguida, enviá-la como uma resposta encadeada dentro da conversa original.
## Como funciona o encadeamento de e-mails
Cada e-mail contém um cabeçalho oculto `Message-ID` — uma impressão digital única atribuída pelo servidor de e-mail do remetente. Quando você responde a um e-mail, seu cliente de e-mail define um cabeçalho `In-Reply-To` que faz referência a essa impressão digital. É assim que o Gmail, o Outlook e todos os outros clientes agrupam as mensagens em conversas.
No Twenty, essa impressão digital é armazenada como `headerMessageId` no objeto Message. Seu fluxo de trabalho obtém isso e o passa para o campo In-Reply-To da ação Enviar e-mail.
## Criando o fluxo de trabalho
### Etapa 1: Criar um novo fluxo de trabalho
Vá para **Configurações -> Fluxos de trabalho** e clique em **+ Novo fluxo de trabalho**.
### Etapa 2: Acionar com mensagens recebidas
Escolha **When a Record is Created** e selecione **Messages**.
Sempre que um e-mail chega ao Twenty, isto é acionado.
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}} />
### Etapa 3: Procurar quem enviou
Adicione uma ação **Pesquisar Registros**.
O endereço do remetente não está na própria mensagem — está no registro relacionado Message Participant.
| Campo | Valor |
| ---------- | ------------------------------ |
| **Objeto** | Participantes da Mensagem |
| **Filtro** | Message **é** `{{trigger.id}}` |
| **Filtro** | Função **é** From |
| **Limite** | 1 |
Isso fornece o e-mail do remetente em `handle` e o nome dele em `displayName`.
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}} />
### Etapa 4: Triagem por IA e rascunho de resposta
Adicione uma ação **Agente de IA**. Esta única etapa faz duas coisas: decide se o e-mail merece uma resposta e, se sim, redige uma.
Use um prompt como:
```
You are an email triage assistant for a sales team. Read the following
inbound email and decide if it deserves a reply.
Subject: {{trigger.subject}}
Body: {{trigger.text}}
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
If this email is spam, a newsletter, an automated notification, or
otherwise does not need a human reply, respond with exactly: SKIP
Otherwise, write a short, professional reply (3-4 sentences max) that:
- Acknowledges their specific message
- Lets them know someone from the team will follow up shortly
- Is warm but not overly casual
Respond with only the reply text, no subject line or greeting prefix.
```
O Agente de IA produz sua resposta em um campo `response` ao qual as próximas etapas podem se referir.
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}} />
### Etapa 5: Ramificar com base na decisão da IA
Adicione uma ação **If/Else** para verificar se a IA decidiu responder ou pular.
| Campo | Valor |
| ------------------ | -------------------------------------------------- |
| **Condição** | A `response` do Agente de IA **não contém** `SKIP` |
| **Se verdadeiro** | Continuar para Enviar e-mail |
| **Caso contrário** | Não fazer nada (o fluxo de trabalho termina) |
Spam, boletins informativos e mensagens geradas automaticamente são descartados. Todo o restante segue para a próxima etapa.
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}} />
### Etapa 6: Enviar uma resposta encadeada
Adicione uma ação **Enviar e-mail** no ramo "se verdadeiro". Clique em **Opções avançadas** e depois em **Adicionar In-Reply-To**.
| Campo | Valor |
| --------------- | -------------------------------------- |
| **Para** | `{{Find Sender.first.handle}}` |
| **Assunto** | `Re: {{trigger.subject}}` |
| **Corpo** | `{{AI Triage & Draft Reply.response}}` |
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
O campo In-Reply-To é o que faz disso uma resposta em vez de uma nova conversa. O destinatário a vê encadeada sob o e-mail original no Gmail, Outlook ou qualquer outro cliente.
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}} />
<Tip>
**In-Reply-To** espera um `message.headerMessageId` do acionador — é a impressão digital única do e-mail, não um endereço do destinatário. Se você deixá-lo vazio, o e-mail ainda será enviado, apenas como uma mensagem independente.
</Tip>
<Warning>
O Gmail usa a linha de assunto para agrupar mensagens em conversas. O assunto **deve** começar com `Re:` (incluindo os dois-pontos e o espaço) para que o Gmail exiba a resposta dentro da conversa original. Sem isso, a resposta aparecerá como uma conversa separada — mesmo que o cabeçalho In-Reply-To esteja definido corretamente.
</Warning>
### Etapa 7: Testar e ativar
Clique em **Test** e, em seguida, verifique seu cliente de e-mail. A resposta deve aparecer aninhada sob a mensagem original.
Ative quando estiver satisfeito com isso.
## Ideias para desenvolver
* **Responder apenas a VIPs** — adicione um ramo que verifique o domínio do remetente ou se ele existe como um Contato no Twenty
* **Roteie por intenção** — use prompts separados do Agente de IA para lidar com consultas de vendas de forma diferente de solicitações de suporte
* **Enriquecer antes de responder** — adicione uma etapa Pesquisar Registros para trazer a empresa do remetente ou o histórico de negócios para o prompt de IA, obtendo respostas mais personalizadas
@@ -21,19 +21,51 @@ Aplicațiile vă permit să construiți și să gestionați personalizările Twe
## Cerințe
* Node.js 24+ și Yarn 4
* Un spațiu de lucru Twenty și o cheie API (creați una la https://app.twenty.com/settings/api-webhooks)
* Docker (pentru serverul local de dezvoltare Twenty)
## Începeți
Creați o aplicație nouă folosind generatorul oficial, apoi autentificați-vă și începeți să dezvoltați:
Creează o aplicație nouă folosind generatorul oficial. Poate porni automat o instanță Twenty locală pentru tine:
```bash filename="Terminal"
# Creează scheletul unei aplicații noi (include toate exemplele în mod implicit)
# Creează scheletul unei aplicații noi — CLI-ul îți va oferi opțiunea de a porni un server Twenty local
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Pornește modul de dezvoltare: sincronizează automat modificările locale cu spațiul tău de lucru
yarn twenty app:dev
yarn twenty dev
```
### Gestionarea serverului local
SDK-ul include comenzi pentru a gestiona un server local de dezvoltare Twenty (imagine Docker all-in-one cu PostgreSQL, Redis, server și worker):
```bash filename="Terminal"
# Pornește serverul local (descarcă imaginea dacă este necesar)
yarn twenty server start
# Verifică starea serverului
yarn twenty server status
# Afișează în timp real jurnalele serverului
yarn twenty server logs
# Oprește serverul
yarn twenty server stop
# Resetează toate datele și pornește de la zero
yarn twenty server reset
```
Serverul local vine preconfigurat cu un spațiu de lucru și un utilizator (`tim@apple.dev` / `tim@apple.dev`), astfel încât să poți începe să dezvolți imediat, fără nicio configurare manuală.
### Autentificare
Conectează-ți aplicația la serverul local folosind OAuth:
```bash filename="Terminal"
# Autentifică-te prin OAuth (se deschide browserul)
yarn twenty remote add --local
```
Generatorul de schelet acceptă două moduri pentru a controla ce fișiere de exemplu sunt incluse:
@@ -167,6 +167,10 @@ plugins: [
Rulați `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'tine@domeniultău.com';` în containerul de baze de date pentru a obține acces la panoul de administrare.
#### Când rulați un flux de lucru, rularea fluxului de lucru eșuează cu "Execuția funcțiilor logice este dezactivată. Setați LOGIC_FUNCTION_TYPE la LOCAL sau LAMBDA pentru a le activa."
În producție, funcțiile logice sunt dezactivate în mod implicit. Setați variabila de mediu `LOGIC_FUNCTION_TYPE` la `LOCAL` sau `LAMBDA` pentru a le activa. Acest lucru poate fi configurat prin variabile de mediu sau prin variabilele bazei de date din panoul de administrare. Consultați [Ghidul de configurare a funcțiilor logice](/l/ro/developers/self-host/capabilities/setup#logic-functions-available-drivers) pentru detalii.
### Docker compose cu un singur click
#### Nu se poate conecta
@@ -0,0 +1,142 @@
---
title: Server MCP
description: Conectați asistenți AI la spațiul dvs. de lucru Twenty folosind Model Context Protocol.
---
<Warning>
MCP este în prezent în **alpha** și este disponibil doar în unele spații de lucru. Este posibil să nu fie activat încă pentru spațiul dvs. de lucru.
</Warning>
Twenty expune un server [MCP](https://modelcontextprotocol.io/) astfel încât asistenții AI — Claude Desktop, Claude Code, Cursor, ChatGPT și alții — să poată citi și scrie datele tale din CRM prin limbaj natural.
Folosește **URL-ul spațiului de lucru** (URL-ul pe care îl folosești pentru a accesa Twenty) drept punct final MCP. Pe Twenty Cloud, URL-ul spațiului tău de lucru poate fi `https://{mycompany}.twenty.com` sau un domeniu personalizat. Serverul este disponibil la:
| Mediu | Punct final MCP |
| -------------------- | ------------------------------------------------------------------------------ |
| **Cloud** | `https://{your-workspace-url}/mcp` (de ex. `https://mycompany.twenty.com/mcp`) |
| **Găzduire proprie** | `https://{your-domain}/mcp` |
## Metode de autentificare
Ai două moduri de a-ți autentifica clientul MCP: **OAuth** (recomandat) sau **Cheie API**.
### Opțiunea A — OAuth (Recomandat)
Cu OAuth, clientul tău MCP deschide o fereastră de browser pentru a te autentifica. Nicio informație secretă nu este stocată în fișierele de configurare, iar tokenurile se reîmprospătează automat.
<Note>
OAuth necesită un client MCP care suportă [specificația MCP Authorization](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization). Claude Desktop, Claude Code, Cursor și ChatGPT o suportă.
</Note>
Adaugă asta în configurația clientului tău MCP, înlocuind `{your-workspace-url}` cu gazda spațiului tău de lucru (de ex. `mycompany.twenty.com`):
```json
{
"mcpServers": {
"twenty": {
"type": "streamable-http",
"url": "https://{your-workspace-url}/mcp"
}
}
}
```
Atât — nu este necesară nicio cheie API. Când clientul se conectează pentru prima dată, acesta va:
1. Va descoperi metadatele OAuth ale Twenty prin `/.well-known/oauth-protected-resource` și `/.well-known/oauth-authorization-server`
2. Se va înregistra ca un client OAuth prin înregistrare dinamică a clientului (RFC 7591)
3. Îți va deschide browserul pentru a autoriza accesul
4. Va primi tokenurile și se va conecta la serverul MCP
Conexiunile ulterioare reutilizează tokenurile stocate și le reîmprospătează automat.
### Opțiunea B — Cheie API
Dacă clientul tău MCP nu suportă OAuth sau preferi acreditări statice, furnizează o cheie API în antetul `Authorization`:
```json
{
"mcpServers": {
"twenty": {
"type": "streamable-http",
"url": "https://{your-workspace-url}/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}
```
<Warning>
Cheia ta API oferă acces la datele spațiului de lucru. Ține-o în afara controlului versiunilor și a dotfiles partajate.
</Warning>
Pentru a crea o cheie API, mergi la **Settings > APIs & Webhooks > + Create key**. Vezi [API-uri](/l/ro/developers/extend/api#create-an-api-key) pentru detalii.
## Pornire rapidă
### 1. Copiază configurația
Mergi la **Settings > AI > More > MCP Server** în Twenty. Alege metoda de autentificare (OAuth sau Cheie API), copiază fragmentul JSON (va folosi deja URL-ul spațiului tău de lucru) și lipește-l în fișierul de configurare al clientului tău MCP.
| Client | Locația fișierului de configurare |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Claude Desktop** | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) sau `%APPDATA%\Claude\claude_desktop_config.json` (Windows) |
| **Claude Code** | `~/.claude.json` (utilizator) sau `.mcp.json` (proiect) |
| **Cursor** | `.cursor/mcp.json` în proiectul tău sau `~/.cursor/mcp.json` global |
| **ChatGPT** | Activează Modul pentru dezvoltatori în **Settings > Apps & Connectors > Advanced settings**, apoi folosește **Create** în **Settings > Apps & Connectors** pentru a adăuga serverul MCP |
### 2. Conectează-te
Repornește clientul tău MCP (sau reîncarcă configurația). Dacă folosești OAuth, vei fi redirecționat către Twenty pentru a autoriza accesul. Dacă folosești o cheie API, conexiunea este imediată.
### 3. Începe să-l folosești
Roagă-ți asistentul AI să interacționeze cu CRM-ul tău:
* *"Arată-mi cele 5 companii create cel mai recent"*
* *"Creează o persoană nouă numită Jane Doe la Acme Corp"*
* *"Găsește toate oportunitățile deschise cu o valoare mai mare de $10k"*
## Instrumente disponibile
După conectare, serverul MCP expune instrumente care oglindesc API-ul Twenty. Fluxul de lucru recomandat este:
1. **`get_tool_catalog`** — descoperă toate instrumentele disponibile
2. **`learn_tools`** — obține schema de intrare pentru instrumente specifice
3. **`execute_tool`** — rulează un instrument
Nu este nevoie să reții numele instrumentelor. Întreabă-ți asistentul AI ce poate face și va apela `get_tool_catalog` automat.
## Permisiuni
Conexiunile MCP moștenesc permisiunile utilizatorului autentificat (OAuth) sau rolul atribuit cheii API. Pentru a restricționa ce poate face serverul MCP:
* **OAuth**: Se aplică rolul utilizatorului din spațiul de lucru.
* **Cheie API**: Atribuie un rol cheii API în **Settings > Roles**. Vezi [Permisiuni](/l/ro/user-guide/permissions-access/capabilities/permissions).
## Configurație pentru auto-găzduire
Pentru instanțele auto-găzduite, înlocuiește `{your-workspace-url}` cu URL-ul serverului tău. Asigură-te că `SERVER_URL` din mediul tău corespunde URL-ului public al instanței tale Twenty — acesta este folosit pentru a genera metadatele de descoperire OAuth.
```bash
SERVER_URL=https://twenty.yourcompany.com
```
Punctul final MCP, punctele finale OAuth și metadatele de descoperire derivă toate din această valoare.
## Depanare
**Erori "Unauthorized" sau 401**
* OAuth: reautorizează ștergând tokenurile stocate din clientul tău MCP și reconectează-te.
* Cheie API: verifică dacă cheia este validă și nu a expirat. Regenereaz-o dacă este necesar.
**Fluxul OAuth nu deschide un browser**
* Asigură-te că clientul tău MCP suportă MCP Authorization. Dacă nu, revino la metoda cu Cheie API.
**Timeout de conexiune**
* Confirmă că URL-ul punctului final MCP este accesibil de pe calculatorul tău. Pentru instanțele auto-găzduite, verifică faptul că serverul rulează și că `SERVER_URL` este setat corect.
@@ -0,0 +1,121 @@
---
title: Răspuns automat la e-mailurile primite
description: Creează un flux de lucru care folosește IA pentru a tria e-mailurile primite și pentru a trimite automat răspunsuri în același fir.
---
Răspunde la e-mailurile primite în câteva secunde — nu în ore. Acest flux de lucru folosește un Agent IA pentru a filtra zgomotul (newslettere, spam, răspunsuri automate) și pentru a redacta un răspuns personalizat la mesajele reale, apoi îl trimite ca răspuns în același fir, în cadrul conversației originale.
## Cum funcționează firele de e-mail
Fiecare e-mail are un antet ascuns `Message-ID` — o amprentă unică atribuită de serverul de e-mail al expeditorului. Când răspundeți la un e-mail, clientul dvs. de e-mail setează un antet `In-Reply-To` care face referire la acea amprentă. Astfel, Gmail, Outlook și orice alt client grupează mesajele în fire de discuție.
În Twenty, acea amprentă este stocată ca `headerMessageId` pe obiectul Message. Fluxul dvs. de lucru o preia și o transmite în câmpul In-Reply-To al acțiunii Send Email.
## Construirea fluxului de lucru
### Pasul 1: Creați un flux de lucru nou
Accesați **Settings -> Workflows** și faceți clic pe **+ New Workflow**.
### Pasul 2: Declanșare la mesajele primite
Alegeți **When a Record is Created** și selectați **Messages**.
De fiecare dată când un e-mail ajunge în Twenty, aceasta se declanșează.
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}} />
### Pasul 3: Căutați cine l-a trimis
Adăugați o acțiune **Search Records**.
Adresa expeditorului nu este în mesajul propriu-zis — se găsește în înregistrarea asociată Message Participant.
| Câmp | Valoare |
| ---------- | --------------------------------- |
| **Obiect** | Participanți la mesaj |
| **Filtru** | Mesajul **este** `{{trigger.id}}` |
| **Filtru** | Rolul **este** From |
| **Limită** | 1 |
Aceasta vă oferă e-mailul expeditorului în `handle` și numele acestuia în `displayName`.
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}} />
### Pasul 4: Triere cu AI și redactarea răspunsului
Adăugați o acțiune **AI Agent**. Acest singur pas face două lucruri: decide dacă e-mailul merită un răspuns și, dacă da, redactează unul.
Folosiți un prompt de tipul:
```
You are an email triage assistant for a sales team. Read the following
inbound email and decide if it deserves a reply.
Subject: {{trigger.subject}}
Body: {{trigger.text}}
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
If this email is spam, a newsletter, an automated notification, or
otherwise does not need a human reply, respond with exactly: SKIP
Otherwise, write a short, professional reply (3-4 sentences max) that:
- Acknowledges their specific message
- Lets them know someone from the team will follow up shortly
- Is warm but not overly casual
Respond with only the reply text, no subject line or greeting prefix.
```
AI Agent produce răspunsul în câmpul `response`, la care pot face referire pașii următori.
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}} />
### Pasul 5: Ramificați în funcție de decizia AI
Adăugați o acțiune **If/Else** pentru a verifica dacă AI a decis să răspundă sau să o omită.
| Câmp | Valoare |
| ---------------------- | -------------------------------------------- |
| **Condiție** | AI Agent `response` **nu conține** `SKIP` |
| **Dacă este adevărat** | Continuați la Send Email |
| **Altfel** | Nu faceți nimic (fluxul de lucru se încheie) |
Spam-ul, buletinele informative și mesajele generate automat sunt ignorate. Tot restul trece la pasul următor.
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}} />
### Pasul 6: Trimiteți un răspuns în același fir
Adăugați o acțiune **Send Email** pe ramura "if true". Faceți clic pe **Advanced options**, apoi pe **Add In-Reply-To**.
| Câmp | Valoare |
| --------------- | -------------------------------------- |
| **Către** | `{{Find Sender.first.handle}}` |
| **Subiect** | `Re: {{trigger.subject}}` |
| **Body** | `{{AI Triage & Draft Reply.response}}` |
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
Câmpul In-Reply-To este cel care face ca acesta să fie un răspuns, nu o conversație nouă. Destinatarul îl vede afișat în fir sub e-mailul original în Gmail, Outlook sau orice alt client.
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}} />
<Tip>
**In-Reply-To** necesită un `message.headerMessageId` din declanșator — este amprenta unică a e-mailului, nu o adresă a destinatarului. Dacă îl lăsați necompletat, e-mailul se trimite în continuare, doar ca un mesaj independent.
</Tip>
<Warning>
Gmail folosește subiectul pentru a grupa mesajele în fire de discuție. Subiectul **trebuie** să înceapă cu `Re:` (inclusiv două puncte și spațiu) pentru ca Gmail să afișeze răspunsul în cadrul firului original. Fără acesta, răspunsul va apărea ca o conversație separată — chiar dacă antetul In-Reply-To este setat corect.
</Warning>
### Pasul 7: Testați și activați
Apăsați **Test**, apoi verificați clientul de e-mail. Răspunsul ar trebui să apară sub mesajul original.
Activați când sunteți mulțumit(ă) de rezultat.
## Idei pe care să le dezvoltați
* **Răspundeți doar VIP-urilor** — adăugați o ramură care verifică domeniul expeditorului sau dacă acesta există ca Contact în Twenty
* **Dirijați în funcție de intenție** — folosiți prompturi AI Agent separate pentru a trata diferit solicitările de vânzări față de cererile de asistență
* **Îmbogățiți înainte de a răspunde** — adăugați un pas Search Records pentru a include compania expeditorului sau istoricul tranzacțiilor în promptul AI, pentru răspunsuri mai personalizate

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