This PR migrates the p-limit library to Native graph SDK batching fixing the concurrency and rate limit issues in production seen for some larger accounts.
## Summary
Round through bosiraphael's 31 review threads on the merged PR #21072
(discovery hero + ephemeral playground token). The user asked to apply
each suggestion only where it adds value, so this PR is split into three
buckets.
### Comments (~17 threads)
- Tightened security-rationale / CSS-gotcha / API-doc comments to one or
two factual lines
- Kept (shortened) the comments above `RequireAccessTokenGuard` call
sites — without them a future reader could remove the guard and silently
reopen the escalation hole
- Kept (shortened) the in-memory-only rationale on
`playgroundApiKeyState` for the same reason
- Kept `flex: 1 + min-height: 0` CSS gotcha on `SubMenuTopBarContainer`
— non-obvious and easy to break
### Structure / extraction
- Move `WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS` to its own constants
file (one-export-per-file)
- Split `SettingsAgentToolsTab` and `SettingsAgentToolsTable` across
queries/, hooks/, types/, utils/:
- `graphql/queries/findManyApplicationsForToolTable.ts`
- `graphql/queries/findManyMarketplaceAppsForToolTable.ts`
- `hooks/useSettingsAgentToolsTable.ts` (data loading + index merging)
- `types/SettingsAgentToolItem|Application|MarketplaceApp`
- `utils/getToolApplicationId|getToolLink`
- Extract `SettingsAiModelsTab` optimistic mutations into
`hooks/useSettingsAiModelsActions` (handleModelFieldChange,
handleUseRecommendedToggle, handleModelToggle,
handleToggleAllVisibleModels)
- Extract `SettingsAI.handleCreateTool` into `hooks/useCreateTool`
- Drop unnecessary `useMemo` wrappers on `heroTabs` arrays
(SettingsObjects, SettingsLayout)
- Simplify `MenuItemToggle` handler in SettingsAgentSkillsTab:
`onToggleChange={setShowDeactivated}` (no longer wrapping with arrow +
read of stale `!showDeactivated`)
### Hero assets
- Replace placeholder `customize-illustration` with per-page exports
- Rename `layout/customize-illustration-{light,dark}.png` →
`layout/cover-{light,dark}.png`
- Add `cover-{light,dark}.png` for **applications** and **members**
(they were both pointing at the layout placeholder as a TODO)
- Overwrite `data-model/cover-*.png`, `playground/cover-*.png`,
`ai/ai-tools-cover-*.png` with the new exports
## Test plan
- [ ] `npx nx typecheck twenty-front` ✅
- [ ] `npx nx typecheck twenty-server` ✅
- [ ] `npx nx lint twenty-front` ✅ (oxlint + oxfmt, 0 warnings/errors)
- [ ] `/settings/layout`, `/settings/data-model`,
`/settings/applications`, `/settings/ai`, `/settings/api-webhooks`,
`/settings/members` each render the new hero illustration (light + dark)
- [ ] AI tab: tool list still loads, search + Custom/Managed/Standard
filters still work, "New Tool" still navigates to detail
- [ ] AI tab: Models tab — smart/fast model select, "Use best models
only" toggle, per-model checkboxes, toggle-all all still
optimistic+revert on error
- [ ] Skills tab: "Deactivated" toggle still flips show/hide
- [ ] Webhooks table still uses the 1fr 28px grid
# Introduction
Followup https://github.com/twentyhq/twenty/pull/21001
Now that the typeorm entities provide grains over their
`encryptedString` value, we can strictly type the sitemaps of the
encrypted string to rotate in case of encryption key rotation and also
the integration tests tests cases
## Context
CI is broken on main, regression introduced in
https://github.com/twentyhq/twenty/pull/21072
Guard-rejected ApiKey mutations returned malformed GraphQL responses.
RequireAccessTokenGuard (and SettingsPermissionGuard) throw plain
AuthException/PermissionException classes, which are not GraphQLErrors.
ApiKeyResolver had no @UseFilters, so these exceptions were never
translated, they surfaced as request-level errors with no data key
(data: undefined) and a non-FORBIDDEN code, instead of data: null +
FORBIDDEN.
This broke the `createApiKey › should reject a non-ACCESS token even
with API key permission` integration test
(expect(res.body.data).toBeNull() received undefined). The sibling
generateApiKeyToken test passed only because it lives on AuthResolver,
which already declares these filters.
## Fix
Add the standard exception filters to ApiKeyResolver, matching the idiom
used by other guard-protected resolvers
```ts
@UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter)
```
## Summary
- Fixes#20970
- When we changed only a relation field (e.g. company on a person):
- The diff builder skipped all RELATION fields -> empty diff -> no
timeline row.
- If we change company and something else, only the scalar field
appeared; the company change was missing.
- Even with a diff, the UI validated keys by field name (`company`)
while join columns (`companyId`) could be filtered out.
## Solution
- For `MANY_TO_ONE`, compare join column values (`companyId`) and store
the diff under the relation field name (`company`):
```
"company": {
"before": { "id": "<old-id>" },
"after": { "id": "<new-id>" }
}
```
- Frontend
- `filterOutInvalidTimelineActivities`: resolve diff keys by field name
or join column via `findFieldMetadataItemByDiffKey`, so relation diffs
are not stripped.
- `EventRelationFieldDiffValues`: resolve related record labels by id;
show only the new value in the row (same pattern as other fields:
Company -> airSlate).
- Tooltip (relations only): on hover, show before -> after with readable
names (e.g. Microsoft -> Apple). Scalar and composite fields (e.g.
Updated by) are unchanged and do not get this tooltip.
- `EventFieldDiff`: route RELATION diffs to the relation renderer; all
other field types keep the existing FieldDisplay behavior.
### What you’ll see
On a person (or opportunity) timeline after changing company:
```
You updated Company → airSlate
(tooltip: Microsoft → Apple)
```
## Test plan
- Change only company on a person -> timeline shows a company update
with names.
- Change company and name in one save -> both appear in the diff.
- Clear company -> row shows Empty; tooltip reflects previous -> empty
if applicable.
- Same we can do for the Opportunities also
- Scalar / ACTOR fields (e.g. Updated by) - no new tooltip; display
unchanged.
## Screenshots
### Before
<img width="527" height="124" alt="Screenshot 2026-05-29 155123"
src="https://github.com/user-attachments/assets/e067f19a-8184-4e50-9cd0-9135e06188b8"
/>
<br><br>
<img width="535" height="181" alt="Screenshot 2026-05-29 155149"
src="https://github.com/user-attachments/assets/3c513a1e-c5c5-4cff-ae1c-5fed62837798"
/>
<br><br>
### After
<img width="564" height="200" alt="Screenshot 2026-06-01 154539"
src="https://github.com/user-attachments/assets/459ad6b4-af4c-4f7a-b749-30762c979627"
/>
<img width="567" height="187" alt="Screenshot 2026-06-01 154555"
src="https://github.com/user-attachments/assets/6bc4711c-afca-43d8-b874-f51fc0f374df"
/>
<img width="563" height="188" alt="Screenshot 2026-06-01 154639"
src="https://github.com/user-attachments/assets/f275b9fd-a1c3-4c4b-9538-8042657eb593"
/>
<img width="556" height="180" alt="Screenshot 2026-06-01 154654"
src="https://github.com/user-attachments/assets/2e614368-8fd7-4c73-8876-2223f2f98e67"
/>
<img width="560" height="237" alt="Screenshot 2026-06-01 154802"
src="https://github.com/user-attachments/assets/0437aef4-7b2c-4d35-a2e9-f617b90a1beb"
/>
---------
Signed-off-by: Parship Chowdhury <parshipchowdhury@gmail.com>
Co-authored-by: martmull <martmull@hotmail.fr>
## Summary
- Extract `findParentSteps` utility that recognizes IF-ELSE steps as
parents of their branch children (via
`settings.input.branches[].nextStepIds`), used in all parent detection
sites (`shouldSkipStepExecution`, `shouldExecuteStep`,
`shouldFailSafely`, and their iterator variants)
- Centralize next-step resolution in `getNextStepIdsToExecute` via
extracted `getNextStepIdsForIterator` and `getNextStepIdsForIfElse`
utils — Iterator now properly returns loop children as
`nextStepIdsToSkip`/`nextStepIdsToFailSafely` when skipped
- Refactor `skipAndFailSafelyStepsThenContinue` to delegate to
`getNextStepIdsToExecute` instead of duplicating type-specific
propagation logic
Fixes#20934
## Test plan
- [x] New unit tests for `findParentSteps` (7 tests covering IF-ELSE
branch parent detection)
- [x] New IF-ELSE-specific tests added to `shouldSkipStepExecution`,
`shouldExecuteStep`, `shouldFailSafely` test suites
- [x] Updated Iterator skip/fail-safely tests in
`workflow-executor.workspace-service.spec.ts`
- [x] All 300 workflow executor tests pass
- [x] `lint:ci` passes
## Context
Timeline activities never updated in real time. They were explicitly
excluded from the database-event pipeline
(formatTwentyOrmEventToDatabaseBatchEvent early-returned for the
timelineActivity object), so no SSE event was ever broadcast, and the
frontend timeline only refreshed on mount/manual refetch.
## Implementation
Backend
- Feat: Stop dropping timeline-activity events in
formatTwentyOrmEventToDatabaseBatchEvent.
Instead route them through EntityEventsToDbListener, which publishes
them directly to live subscriptions (but still skipping webhook/audit
handling).
- Fix: Harden ObjectRecordEventPublisher: wrap nested-relation
enrichment in try/catch so a failure broadcasts the event without
relations instead of dropping it (logs a warning).
- Fix: Skip unreadable relation targets in CommonSelectFieldsHelper when
the role lacks canReadObjectRecords, preventing errors while computing
selected fields.
- Fix: Support MORPH_RELATION alongside RELATION in RLS row-level
permission predicate matching (timeline activities use morph targets).
Frontend
- Feat: useTimelineActivities now registers the timeline query with the
SSE system via useListenToEventsForQuery and refetches on incoming
timeline-activity record operations.
- Feat: Add a skip option to useListenToEventsForQuery so the listener
isn't registered when the object has no timeline field.
## Test
https://github.com/user-attachments/assets/ed1d1c66-d6ea-434d-ac9c-9b83d2b78338
Note: "UpdatedBy" seems to be listen to and visible in the timeline
activity summary, this is probably a bug that we want to fix
In createImmediateUpgradeInvoice, the invoice is finalized with
auto_advance: true, which causes Stripe to automatically attempt payment
asynchronously. Then the explicit stripe.invoices.pay(invoice.id) call
races against that auto-payment — if Stripe already paid it, this throws
"Invoice is already paid".
The fix is to finalize with auto_advance: false and keep the explicit
pay call
## Problem
`any[]` type prevented value input:
<img width="544" height="349" alt="Screenshot 2026-06-01 at 14 08 47"
src="https://github.com/user-attachments/assets/956238d0-6fea-4be1-b75a-ab0e6e6424ac"
/>
In the workflow Code action (and the Logic Function action), parameters
typed as any[], string[], etc. rendered as an empty grey box instead of
an "Enter value" text input. After any debounced save, even a properly
initialised array field would also collapse into an empty container.
The Array<T> / ReadonlyArray<T> generic form fell through to a generic
text input by accident (which "looked" right, but for the wrong reason —
no schema info downstream).
## Root causes
Three places treated arrays as plain objects via @sniptt/guards'
isObject (which is true for arrays):
1. WorkflowEditActionCodeFields.tsx — arrays went into the nested-fields
branch; Object.entries([]) is empty → empty container, no placeholder.
2. mergeDefaultFunctionInputAndFunctionInput.ts — recursed into arrays
during merge, turning [] into {}. Triggered on every debounced save, so
the bug surfaced after any edit.
3. get-function-input-schema.ts — only handled T[]
(SyntaxKind.ArrayType); Array<T> (SyntaxKind.TypeReference) was
unrecognised, so the form lost any item-type info.
## Summary
Two intertwined streams of work:
### UI — discovery hero pattern, settings shell, AI/API redesign
- **Generalize `SettingsDiscoveryHeroCard`** and use it on Layout, Data
Model, Apps, AI, API/Webhooks, Members. Drops 4 per-page wrapper files
(`SettingsObjectCoverImage`, `SettingsLayoutCoverImage`,
`SettingsLayoutCustomizeVideoModal`,
`SettingsDataModelVisualizeVideoModal`). Each page now supplies cover
src, modal id, and tab list.
- **Modal**: swap `<video>` placeholder for the Vimeo iframe pattern
from `twenty-docs`, per-tab `vimeoId`. Drop the parallel border-bottom
on the header (TabList draws its own baseline) and the grey background
behind the video. Note: Vimeo's embed allowlist applies — the iframes
load with the correct URL on `localhost` but the player itself requires
the video owner to allow the dev/staging domains in Vimeo settings.
- **AI page** rebuilt into a Cockpit pattern (Overview / Models / Skills
/ Tools / Usage). New `SettingsAiOverviewTab` with default Smart/Fast
pickers, at-a-glance stats, and an MCP signpost that deep-links to
`/settings/api-webhooks#mcp`. System Prompt link moved under Models.
Advanced tab removed.
- **API & Webhooks** now has 4 tabs (Playground / MCP / API Keys /
Webhooks). Hero card above tabs. Playground tab inverted to "Core API" /
"Metadata API" sections, each containing REST + GraphQL cards — schema
is the meaningful axis, protocol is secondary. Hash deep-link sync
delegated to the shared `TabListFromUrlOptionalEffect`.
- **Settings shell**: unified drawer outer padding (kill `isSettings`
branch), extract `CollapsibleNavigationDrawerSection`, add `iconColor`
on settings nav items, fix Exit Settings button alignment, 880px content
cap.
### Backend — strategy C: ephemeral playground token
The legacy paste-your-API-key flow is replaced by an on-demand
short-lived token scoped to the calling user's permissions. No shared
"Playground" API key to manage or revoke.
- New `JwtTokenTypeEnum.PLAYGROUND`. `PlaygroundTokenJwtPayload =
Omit<AccessTokenJwtPayload, 'type' | impersonation fields>` so any
future ACCESS claim flows through automatically.
- `AccessTokenService.generatePlaygroundToken` signs an access-shaped
JWT with `type: PLAYGROUND` and a configurable short TTL. A shared
private `resolveTokenSubject` helper parallelizes the user / workspace /
userWorkspace lookups for both generators.
- `JwtAuthStrategy.validateAccessToken` widened to accept
`AccessTokenJwtPayload | PlaygroundTokenJwtPayload`; impersonation gated
on `payload.type === ACCESS` so the union narrows without `as unknown
as` casts. The two branches in `validate()` collapse into one.
- New `PLAYGROUND_TOKEN_EXPIRES_IN` config var (default `2h`).
- New `generatePlaygroundToken` mutation (`WorkspaceAuthGuard`, no args,
returns `AuthToken`).
- Frontend `useOpenPlayground` hook centralizes mint → atom write →
navigate, with Apollo `onError` snackbar and a "use cached PLAYGROUND
token if still fresh" short-circuit (decodes via `jwt-decode`, checks
both `type` AND `exp`). Old API_KEY tokens left in localStorage from the
prior paste-form flow are rejected on `type` alone and force a re-mint —
this is what was causing the "This API Key is revoked" symptom on stale
browsers.
### Drive-by cleanups
- `PlaygroundToken` DTO removed (identical shape to `AuthToken` already
in use).
- 5 `customize-sidebar.webm` imports and the dead placeholder pipeline
removed.
## Test plan
### Discovery hero
- [ ] `/settings/layout`, `/settings/data-model`,
`/settings/applications`, `/settings/ai`, `/settings/api-webhooks`,
`/settings/members` each render the discovery hero card with its
illustration + play button + tabbed modal
- [ ] Modal tabs show the correct Vimeo embed URL per tab; aspect ratio
stays at 1440/900; no parallel border-bottom jog at the tab baseline
- [ ] AI Overview tab shows Smart/Fast model pickers + stats grid + MCP
signpost card; the MCP card lands on `/settings/api-webhooks#mcp` with
the MCP tab active
### API playground (ephemeral token)
- [ ] With an empty `playgroundApiKeyState` in localStorage, clicking
REST or GraphQL playground card opens the playground and the cached
token has `type: "PLAYGROUND"` with ~2h exp
- [ ] Clicking the card again within the freshness window does **not**
re-mint (`iat` / fingerprint stable across visits)
- [ ] Planting a fake API_KEY-shaped JWT in localStorage and clicking
the card forces a fresh mint (old token rejected on `type`)
- [ ] `GET /rest/companies?limit=1` with the cached token returns 200 +
real data
- [ ] `POST /graphql { __typename }` returns 200
### Settings shell
- [ ] Settings nav matches main app drawer padding; sections collapse;
Exit Settings button aligns with the workspace links above
- [ ] Active nav items have a right-gap (cleaner active state)
- [ ] Content area capped at 880px
### Verify
- [ ] `npx nx typecheck twenty-front` passes
- [ ] `npx nx typecheck twenty-server` passes
- [ ] `npx nx lint:diff-with-main twenty-front` passes
- [ ] `npx nx lint:diff-with-main twenty-server` passes
Fixes#20109
The entry was repeating because in the database we store DateTime fields
with microsecond precision (timestamptz), but when JS parses timestamptz
into a Date object it only keeps millisecond precision.
### Example
If previous cursor was:
```
{
name: "Quick Lead",
createdAt: "2026-05-21T15:33:00.708Z",
}
```
The resulting query look something like:
```
...
WHERE (
"workflow"."name" > "Quick Lead"
OR (
"workflow"."name" = "Quick Lead"
AND "workflow"."createdAt" > "2026-05-21T15:33:00.708Z"
)
OR (
"workflow"."name" = "Quick Lead"
AND "workflow"."createdAt" = "2026-05-21T15:33:00.708Z"
AND "workflow"."id" > "8b213cac-a68b-4ffe-817a-3ec994e9932d"
)
)
```
So, when comparing the 2nd condition the `"workflow"."createdAt" >
"2026-05-21T15:33:00.708Z"` would always result to true because in db
the data for createdAt is `2026-05-21 21:03:00.708 +0530` which will
always be greater than `2026-05-21T15:33:00.708Z`
The second condition `"workflow"."createdAt" >
"2026-05-21T15:33:00.708Z"` always evaluates to true, because the value
actually stored in the DB for createdAt is something like `2026-05-21
21:03:00.708264 +0530`, which is always greater than
`2026-05-21T15:33:00.708Z` in the cursor. The row used to generate the
cursor therefore reappears on the next page.
### My solution
Truncate the column to milliseconds in the comparison so both sides have
the same precision: `date_trunc('milliseconds', ${fieldReference})`.
For the issue of nested sorting filters, when ordering by a composite
field (e.g. `createdBy.name`), `encodeCursor` stored the entire
composite object (`source`, `workspaceMemberId`, `name`, `context`). The
where-condition builder later iterated those sub-keys and threw "Invalid
cursor" because only name had an orderBy direction.
P.S: Duplicate of #20867 because last fork got polluted.
---------
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Updating an application variable in Workspace / Applications / <App> /
Settings persisted server-side but the Apollo cache kept the old value.
Switching tabs unmounted the settings tab, and remounting reseeded the
input from the stale cache — only a full refresh showed the new value.
Mutation now writes the new value into the ApplicationVariable entity
via cache.modify, so FindOneApplication reflects the change immediately.
Hook + table updated to pass the variable id through.
Adds a hook test that pre-seeds the cache and asserts the cached value
after the mutation.
NOTE: saving the plain value in Apollo's cache might not be the best
approach here
---------
Co-authored-by: martmull <martmull@hotmail.fr>
## Summary
`twenty deploy` (and `twenty build`) currently ship front-component
`.mjs` bundles **unminified**, with `process.env.NODE_ENV` undefined at
build time. Two missing options in
`get-base-front-component-build-options.ts` — fix is two lines.
## Why this matters
Each front-component bundle includes React + ReactDOM + the design
system AOT (only `twenty-client-sdk/{core,metadata}` are listed in
`FRONT_COMPONENT_EXTERNAL_MODULES`). A trivial widget measures **~2 MB**
unminified. Because every widget mount spawns a fresh Web Worker that
re-fetches and re-parses the bundle (`FrontComponentWorkerEffect.tsx`),
that 2× size translates directly into 2× cold-start latency on **every
record-page navigation and every browser refresh**. On a CRM with even a
handful of custom widgets this dominates perceived UI latency.
## Why it bites every app, not one user
Reference apps in this repo
(`packages/twenty-apps/fixtures/{minimal,rich}-app`,
`community/github-connector`, `internal/twenty-for-twenty`) ship the
same way — verified by inspecting the released `twenty-sdk@2.5.0`
bundle. There's no CLI flag, env var, or config option to opt into a
production build. A search of issues/PRs for "minify", "bundle size",
"production build" surfaces nothing tracking this.
## Fix
Enable `minify: true` and `define: { 'process.env.NODE_ENV':
'"production"' }` in the base front-component build options. These flow
through `build-application.ts` (the orchestrator for `twenty deploy` and
`twenty build`).
**Watch mode (`twenty dev`) is intentionally untouched.**
`esbuild-watcher.ts` has its own configuration path that doesn't consume
`getBaseFrontComponentBuildOptions()`; it stays unminified so local
rebuilds remain fast and stack traces remain readable during
development.
## Measured impact
Two production extension apps using `twenty-sdk@2.5.0`:
| File | Before | After | Δ |
|---|---:|---:|---:|
| `oapps-deal-items` (single widget) | 2,114,953 B | 863,444 B |
**−59%** |
| `oapps-document-hub/documents-panel` | 2,120,341 B | 872,478 B |
**−59%** |
| `oapps-document-hub/hub-document-record` | 2,077,013 B | 852,544 B |
**−59%** |
| `oapps-document-hub/field-mapping-editor` | 1,168,941 B | 255,787 B |
**−78%** |
End-user effect: opening an Opportunity record with two custom-widget
tabs went from ~3-4s widget paint to under 1s on the same machine, same
browser, same record.
## Risk / scope
- **No behavior change.** Minification is a transparent transform;
`NODE_ENV=production` is the standard signal libraries already gate on.
No app code changes needed.
- **No effect on `twenty dev`** — separate code path.
- **No effect on logic functions** — they use their own build-options
object.
- One file touched.
## Test plan
- [ ] `yarn twenty deploy` on
`packages/twenty-apps/fixtures/minimal-app` → output `.mjs` is mangled
and `process.env.NODE_ENV` no longer appears literally inside the
bundle.
- [ ] `yarn twenty dev` on the same app → output `.mjs` remains
readable.
- [ ] Existing CI green.
Happy to add a feature-flag (`TWENTY_BUILD_MODE` env var or `twenty
deploy --no-minify` escape hatch) if maintainers prefer that over
unconditional minification.
---------
Co-authored-by: 8Maverik8 <8maverik8@users.noreply.github.com>
Co-authored-by: martmull <martmull@hotmail.fr>
## Summary
Fixes scroll behavior in the rich text editor.
Long content was unbounded — the popup grew off-screen, the
expand-to-side-panel button became unreachable, and the side panel
itself didn't scroll either.
Closes#20309
## Changes
- `RichTextFieldInput`: cap the popup at `min(60vh, 500px)` and wrap the
editor in a scrollable region. Collapse button stays at the top via
`align-items: flex-start`.
- `RecordInlineCellEditMode`: add `shift()` middleware to keep the popup
inside the viewport after `flip()` triggers (previously the popup could
extend above the viewport top with the collapse button out of reach).
- `SidePanelContainer` / `SidePanelRouter`: add `min-height: 0` to the
flex-column chain so the existing `overflow-y: auto` on the content area
can actually clip and scroll long children.
The previous attempt in #20310 added the same `min-height: 0` plus a
nested overflow wrapper inside the rich-text page; the nested wrapper
turned out to be the reason scrolling didn't work.
## Test plan
- [x] Open a record with a long `RICH_TEXT` field
- [x] Click the field — popup opens bounded; long content scrolls inside
- [x] Click the expand button (top-right) — side panel opens
- [x] Side panel: long content scrolls vertically
- [x] Popup near the bottom of the viewport flips upward and stays fully
inside the viewport (collapse button remains visible)
- [x] `lint:diff-with-main`, `typecheck`, prettier — green
https://github.com/user-attachments/assets/827be881-aadd-49ed-9ddc-7566c00cf4be
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The SSE event stream could silently die from network partitions, NAT
table flushes, browser tab throttling, or server restarts. When this
happened:
1. The `error` callback only called `captureException` — no reconnection
was triggered
2. The `complete` callback was `() => {}` — a cleanly terminated stream
left the client permanently broken
3. No mechanism existed to detect a silently dead connection where no
FIN/RST was received
## Summary
- **Fix `error`/`complete` callbacks**: The `graphql-sse` subscription's
`error` callback only reported to Sentry, and `complete` was a no-op.
Both now set `shouldDestroyEventStreamState = true` to trigger the
destroy-recreate lifecycle, ensuring detected transport failures and
clean stream terminations lead to automatic reconnection.
- **Add server-side keepalive**: The existing heartbeat timer now runs
every 30s (instead of 6min) and publishes empty events through the Redis
pub/sub channel in addition to refreshing the Redis TTL (throttled to
~6min). Unlike GraphQL Yoga's opaque SSE comment pings, these are real
subscription events that flow through the client's `next`/`message`
handlers.
- **Add client-side keepalive monitor (`SSEKeepAliveEffect`)**: Tracks
the timestamp of the last received event. If no event arrives within 90
seconds (3x the keepalive interval), it clears query listeners and
triggers a stream destroy-recreate cycle.
## Test plan
- [x] Start the app, verify SSE events flow normally (workflow runs
update in real-time)
- [x] Leave the app idle for >90 seconds, then trigger a workflow run —
verify the stream auto-reconnects and events are delivered
- [x] Kill the server, restart it, verify the frontend recovers its
event stream
- [x] Verify keepalive events (empty
`objectRecordEventsWithQueryIds`/`metadataEvents`) appear in browser
network tab every ~30s
- [x] Verify no regressions in SSE-dependent features (record updates,
metadata changes, workflow run visualization)
xAI deprecated Live Search, so the `searchParameters` provider option
now returns 410. This routes all xAI models through the Responses API
and binds web/X search as native agent tools, matching how
Anthropic/OpenAI expose search.
- xAI provider now uses `provider.responses()` — its
`webSearch()`/`xSearch()` tools only run against the Responses endpoint,
not chat completions
- web/X search migrated from the `provider-option` variant to `sdk-tool`
(`web_search`/`x_search`); deleted the dead `searchParameters` path, the
`provider-option` variant, and `providerOptions` on `NativeModelBinding`
- dropped a dead `rolePermissionConfig` param on `getAgentRoleId`, left
over from #20331
---------
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
**i18n** — collapsed the ~22 scattered i18n files into a single module
and turned on Spanish alongside French.
**Sections** — dropped the old compound pattern (`Section.Root`,
`Section.Heading`, …). Reusable layout shells moved to `src/templates/`,
atomic bits stay in `design-system/`, and each page now owns its copy in
local `_components` blocks instead of pulling it out of shared sections.
Data files hold arrays only, no prose.
**Copy → `<Trans>`** — A lot of headings were split across several
`<HeadingPart>`s just for font styling, which meant each piece was a
separate translation string. A translator got "Build your Enterprise
CRM" and "at AI Speed" as two unrelated strings and had no way to
reorder them for their language. Those are now single `<Trans>` units
with placeholders. Same idea for the old `\n` + `white-space: pre-line`
line-break trick: replaced with a small `ResponsiveLineBreak` element so
the break is doesn't quietly rot, and did a dead-code pass.
The de-fragmentation changes the message IDs, so around 60 strings will
fall back to English in fr/es until Crowdin re-syncs.
## Problem
`generateFrontConfig()` writes `window._env_.REACT_APP_SERVER_BASE_URL =
process.env.SERVER_URL` unconditionally. The frontend then pins to that
absolute URL. For self-hosted deployments reachable from multiple
hostnames (Tailscale IP, LAN IP, internal DNS, SSH tunnel to localhost,
public DNS), only the one matching `SERVER_URL` works — others hit CORS
errors or unreachable hosts because the frontend tries to call the API
at the configured URL, not the one the user came in via.
The frontend already supports the right fallback:
`packages/twenty-front/src/config/index.ts:20-21` reads
`window._env_?.REACT_APP_SERVER_BASE_URL` and falls back to
`getDefaultUrl()` (which uses `window.location`) when the env var is
absent. But the server-side `generateFrontConfig` always populates
`_env_`, so the fallback never runs.
## Fix
One file: `packages/twenty-server/src/utils/generate-front-config.ts`.
Add a `FRONT_AUTO_BASE_URL=true` opt-in (also triggered when
`SERVER_URL` is unset entirely). When the toggle is on, inject
`window._env_ = {}` so the frontend's existing `getDefaultUrl()`
fallback resolves the origin from `window.location` at runtime.
## Backwards compatibility
When `SERVER_URL` is set AND `FRONT_AUTO_BASE_URL` is unset (or anything
other than `'true'`): unchanged — `REACT_APP_SERVER_BASE_URL:
process.env.SERVER_URL` is injected exactly as before.
The toggle is strictly additive. Existing single-hostname deployments
are not affected.
## Use case
Self-hosted Twenty reachable via:
- `http://100.115.12.29` over Tailscale
- `http://localhost:4440` over SSH tunnel
- `http://twenty.internal` over LAN DNS
- `http://crm.example.com` public
With `FRONT_AUTO_BASE_URL=true`, all four paths work without rebuilds or
per-hostname server processes.
## Test plan
- [ ] `SERVER_URL=http://x.com` (toggle unset) → `<script>window._env_ =
{"REACT_APP_SERVER_BASE_URL":"http://x.com"};</script>` (unchanged from
main)
- [ ] `SERVER_URL` unset → `<script>window._env_ = {};</script>` (new
fallback path)
- [ ] `SERVER_URL=http://x.com FRONT_AUTO_BASE_URL=true` →
`<script>window._env_ = {};</script>` (toggle wins)
- [ ] `FRONT_AUTO_BASE_URL=false SERVER_URL=http://x.com` → unchanged
(only `'true'` triggers the toggle)
---------
Co-authored-by: martmull <martmull@hotmail.fr>
Add a note to the command menu items docs explaining that
RECORD_SELECTION already guarantees a non-empty selection, so
numberOfSelectedRecords > 0 is redundant in
conditionalAvailabilityExpression.
## Summary
- Fix intermittent failure where the Quick Lead workflow form step did
not auto-open
- Root cause: race conditions between SSE events, Apollo cache writes,
and the `runWorkflowVersion` mutation timing
- Add generic monotonicity guard in the SSE handler that drops stale
updates for all records (not just WorkflowRun)
## Changes
- **`useTriggerOptimisticEffectFromSseUpdateEvents.ts`**: Compare
incoming `updatedAt` with cached record before writing — skip if stale.
Moved `upsertRecordsInStore` after the guard so neither Apollo cache nor
Jotai store receive stale data.
- **`useRunWorkflowVersion.tsx`**: Await mutation before opening side
panel; register SSE listener eagerly before mutation
- **`useWorkflowRun.ts`**: Simplified back to plain `useFindOneRecord` +
schema parse (no extra state needed)
- **`generateWorkflowRunDiagram.ts`**: `shouldOpenStep` matches both
PENDING and RUNNING for form steps (backend RUNNING means "waiting for
user input")
- **`WorkflowRunVisualizerEffect.tsx`**: Pass `runStatus` directly
without status mapping
- **`WorkflowRunStepNodeDetail.tsx`**: Form is interactive when step is
PENDING or RUNNING
- **Deleted `latestWorkflowRunFamilyState.ts`**: No longer needed — the
generic SSE guard replaces it
## Test plan
- [x] Hard refresh, run Quick Lead workflow 10+ times — form should
always auto-open
- [x] Complete the form and verify all subsequent steps execute without
getting stuck
- [x] Verify the workflow diagram is always visible (never disappears)
- [x] Verify other record types still update correctly via SSE (e.g.
edit a person in another tab)
Documents how a headless front component calls a server-side logic
function over HTTP via the /s/ route, so AI agents have a clear
reference for implementing this pattern.
Fixes https://github.com/twentyhq/twenty/issues/21044
## Summary
- Closes [#490](https://github.com/twentyhq/private-issues/issues/490) —
JumpCloud customers (and anyone else whose IdP only advertises
`HTTP-POST` for `SingleSignOnService`) could not upload their SAML
metadata; the parser silently rejected them with a generic `Invalid
file` toast.
- The SAML IdP metadata parser now falls back to `HTTP-POST` when
`HTTP-Redirect` is not advertised. Both are valid SAML 2.0 bindings.
- The parser now returns a descriptive `reason` string (Zod issues +
custom errors) instead of an opaque `error: unknown`, and the upload
snack bar surfaces it so the customer can self-diagnose (e.g. `entityID:
entityID is not a valid URL` if they forgot to fill in their IdP Entity
ID).
- Added unit tests for HTTP-POST-only metadata, HTTP-Redirect
preference, and each descriptive-error path.
## Test plan
- [x] `npx jest parseSAMLMetadataFromXMLFile
--config=packages/twenty-front/jest.config.mjs` — 8/8 pass
- [x] `npx oxlint -c packages/twenty-front/.oxlintrc.json` on changed
files — clean
- [x] `npx oxfmt --check` on changed files — clean
- [ ] Manual: upload the customer's JumpCloud metadata (HTTP-POST only,
placeholder `entityID`) and confirm the error now says `Invalid file:
entityID: entityID is not a valid URL` instead of `Invalid file`
- [ ] Manual: upload metadata with a real `entityID` and HTTP-POST-only
binding, confirm the form populates correctly
## Changes
**Provisioning idempotent** (`aws-ses-register-domain.service.ts`)
- Each SES create call (`CreateConfigurationSet`, event destination,
contact list, tenant association) now swallow `AlreadyExistsException`
via `.send().catch()`.
- Retry after partial failure re-run every step, no blow up on "already
exists". Before: one existing resource kill whole provision.
**Workspace delete clean up cloud** (`workspace.service.ts`,
`emailing-domain-workspace-cleanup.job.ts`,
`emailing-domain.service.ts`)
- On workspace delete, fetch domain list first, pass domains to cleanup
job.
- Cleanup now loop `driver.cleanupDomain(domain)` per domain +
`deprovisionWorkspace`. Tear down SES identity/tenant/config-set, not
just delete DB rows.
- Before: DB rows gone, SES resources orphaned forever. Now: cloud match
DB.
**Inbound replay dedupe** (`ses-inbound-mail-handler.service.ts`)
- Use `snsMessageId` as job id. SNS deliver same message twice → second
is no-op. No duplicate inbound email import.
Creating a workflow on a table with with a filter on status (eg: status
is "active") failed because it added the status to createOneWorkflow (in
order to have the record belonging to the view) - while
createOneWorkflow throwed a 400 exception when attempting to create a
workflow with a status (does not correpsond to a valid behaviour).
Silently stripping status rom create workflow endpoints.
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>
## Fixes#20084
### Problem
A saved address is visible in the **table view** but shows **"Empty"**
in the **record detail page** when `addressStreet1` is `null`.
This reproduces with the default seed data out of the box — e.g.
**Google** (city "Mountain View", no street), **Microsoft** (Redmond),
**Meta** (Menlo Park) — which is why several users reported hitting it
immediately.
### Root cause
The frontend zod schema required `addressStreet1` to be a **non-null**
string:
```ts
// isFieldAddressValue.ts
export const addressSchema = z.object({
addressStreet1: z.string(), // ← required non-null
addressStreet2: z.string().nullable(),
...
});
```
…but the backend composite type marks it `isRequired: false`
(`address.composite-type.ts`), and the DB column is nullable. So the API
legitimately returns `addressStreet1: null` when only other subfields
are filled.
The two views diverge on how they render:
- **Record detail** gates the value behind `useIsFieldEmpty()` →
`isFieldValueEmpty()`, which for addresses calls
`isFieldAddressValue()`. With `addressStreet1: null` the `safeParse`
**fails**, so `isFieldValueEmpty` returns `true` and the `"Empty"`
placeholder is shown (`RecordInlineCellDisplayMode`).
- **Table view** (`RecordTableCellDisplayMode`) renders
`AddressFieldDisplay` directly with **no** empty check, so the address
stays visible.
This was a latent mismatch since the address guard was introduced.
### Fix
Make `addressStreet1` nullable to match the backend and the other
subfields:
- `addressSchema` → `addressStreet1: z.string().nullable()`
- `FieldAddressValue.addressStreet1` → `string | null`
- `FieldAddressDraftValue.addressStreet1` → `string | null` (keeps the
input/draft type consistent; the text input already renders `?? ''`)
The change is strictly more permissive — persisting and the settings
default-value form still accept string values; they now also accept
`null`.
### Tests
- `isFieldAddressValue.test.ts` — guard returns `true` for
`addressStreet1: null` with other subfields filled.
- `isFieldValueEmpty.test.ts` — new address coverage: empty address is
empty; **`street1: null` + city filled is NOT empty**; normal address is
not empty. (Added an `addressFieldDefinition` mock.)
Both new assertions were confirmed to **fail before the fix** and pass
after.
### Verification
- `npx jest isFieldValueEmpty isFieldAddressValue
normalize-address-field-value-for-persist` → 17 passed
- `npx nx typecheck twenty-front` → pass
- `npx nx lint:diff-with-main twenty-front` → 0 warnings, 0 errors
## Summary
Fixes [#21014](https://github.com/twentyhq/twenty/issues/21014).
When two pie chart widgets shared the same group-by field (and therefore
the same slice ids) but used different aggregation operators (e.g.
`count` vs `sum`), the arc-link labels would mirror between the two
charts — both ending up showing either the count or the sum values,
depending on render order. Center metrics stayed correct.
**Root cause.** Nivo's `ArcLinkLabelsLayer` and `ArcsLayer` (from
`@nivo/arcs`) wire `react-spring`'s `useTransition` with `keys: e =>
e.id`. When two `<ResponsivePie>` instances render with overlapping ids,
the transitioned data bleeds across charts. The center metric is
unaffected because it's computed by a separate hook
(`usePieChartCenterMetricData`).
**Fix.** Namespace the Nivo-computed slice id per widget by passing an
`id` accessor to `<ResponsivePie>`:
```tsx
id={(datum) => `${id}:${String(datum.id)}`}
```
Lookups inside the widget switch to `datum.data.id` (the original,
un-namespaced id stored on the raw datum), so value/percentage
formatting, the custom tooltip, and the legend hover-dim behavior all
keep working.
Touched files:
- `GraphWidgetPieChart.tsx` — add `id` accessor
- `CustomArcsLayer.tsx` — compare legend highlight against
`datum.data.id`
- `getPieChartFormattedValue.ts`, `getPieChartTooltipData.ts` — match on
`datum.data.id`
- Tests for both utils get a regression case covering the namespaced
computed id
## Test plan
- [ ] `npx jest getPieChartFormattedValue` ✅
- [ ] `npx jest getPieChartTooltipData` ✅
- [ ] `npx tsc --noEmit` ✅
- [ ] Manual: dashboard with two pies on the same group-by field, one
`count` and one `sum`, "Display data label" on for both — confirm each
chart shows its own metric on the slices, and the central total is
unchanged.
- [ ] Manual: hover a legend item — the matching slice in that chart
stays solid while the others dim, and the sibling chart is not affected.
- [ ] Manual: clicking a slice still drills into the correctly filtered
view.
## Summary
Splits AI agent tool resolution into two independent rails:
- **Native tools** — capabilities baked into the model SDK
(Anthropic/OpenAI `web_search`, xAI `web`/`x` provider options). Bound
by `NativeToolBinderService`, controlled by per-agent
`modelConfiguration` toggles. Opaque to Twenty — executed on the model
provider's servers.
- **Action tools** — registry-scoped tools from `ToolRegistryService`
(code interpreter, send email, record CRUD, etc.). Permission-gated via
the agent's role. Executed on Twenty's server.
Both rails merge into a single `ToolSet` at call time. When both
surfaces expose a search tool the model picks at runtime — coexistence
is intentional (relevant once Exa returns as an action, see below).
## Notable changes worth calling out
**Contract change: `AgentAsyncExecutorService.executeAgent` no longer
accepts `rolePermissionConfig`.** Workflow agents now scope exclusively
by the agent's own permission-tab role (`unionOf: [agentRoleId]`). The
previous role-merging path (caller role intersected with agent role) is
removed. No agent role → no registry tools (fail-closed by design).
**`NativeToolBinderService` relocated** from
`core-modules/tool-provider/native/` →
`metadata-modules/ai/ai-models/services/`. The binder needs SDK-package
knowledge, which lives in `ai-models`. Old location created a backwards
module dependency.
**`NATIVE_MODEL_TOOLS_BY_SDK_PACKAGE` is exhaustive over
`AiSdkPackage`** (`Record<>`, not `Partial<Record<>>`). Adding a new SDK
without thinking about native tools now fails the build. SDKs without
native tools (Bedrock, Google, Mistral, Azure, OpenAI-compatible) get
explicit `{}` entries.
**Discriminated union `kind: 'sdk-tool' | 'provider-option'`** lets one
registry describe both function tools (Anthropic/OpenAI) and runtime
sources (xAI). Follows the local `tool-provider` convention from #19321.
## Deferred to follow-ups
- **Exa web search is dropped from this PR** (along with its
`WEB_SEARCH_TOOL` permission flag and the Exa-specific gating). Exa
comes back as an **action/app tool** once apps can define permission
flags through the SDK — ongoing work in #20481.
- **xAI native search currently errors.** xAI deprecated its Live Search
API (the `web`/`x` provider-option sources this rail maps to), so xAI
returns `410` when native search is actually exercised. The code path
itself is clear — it's only hit if you test xAI native tools. Fixed
separately alongside the broader xAI model fixes.
## Conscious non-decisions
- **No "twenty-native" category.** `native` is reserved for
model/provider SDK features; everything Twenty-owned is just a
tool/action.
- **Coexistence over precedence.** No rule forcing an action search tool
to override native search (or vice-versa) — when both exist, it's the
user's choice in workflow agents and the model's choice in chat.
---------
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
## Summary
Three related fixes to the auto-creation of People records from calendar
events and email messages, all centred on the data-quality problem of
contacts being created with missing or malformed names.
### 1. Enrich names on existing contacts (commit 1)
Previously: when an email or calendar import matched an existing Person
by email, the existing record was left untouched — even if the new
source carried a better name.
This is the root cause of contacts like `"Félix"` (no last name)
sticking around forever: the `To:`/`Cc:` headers of outbound emails
rarely include a display name, and Google Calendar only returns
`displayName` for attendees already in the organizer's address book. So
the first sighting often creates a Person as `{firstName: "felix",
lastName: ""}`, and a later inbound `From: "Félix Malfait"
<felix@twenty.com>` — which would have produced the right name — gets
silently dropped because the Person already exists.
The new `computePeopleToEnrichNames` bucket and
`CreatePersonService.enrichPeopleNames` method fill in missing
`firstName`/`lastName` fields from the new parsed name, with
conservative rules:
- Only enrich when the existing Person's `createdBy.source` is
`CALENDAR` or `EMAIL` — `MANUAL`, `IMPORT`, `API`, `WORKFLOW`, etc. are
never touched.
- Only fill empty fields. Non-empty `firstName`/`lastName` are never
overwritten.
- Soft-deleted contacts continue to be handled by the existing restore
path.
### 2. Handle multi-comma "Last, First, Suffix" display names (commit 2)
The comma-inverted swap in the parser previously required *exactly* one
comma. Names like `"Smith, Jane, Jr."`, `"O'Brien, Mary, MD"` or `"Doe,
John, Patrick"` fell through to the space-split fallback, which stored
the comma in `firstName` (e.g. `"Smith,"`) and produced garbled records
(the avatar shows a single "B" and the name reads `"Barbey, Julien"`
because the entire string lives in `firstName`).
The regex now splits on the first comma and treats the remainder as the
first name, collapsing any further commas to spaces. Single-comma
behaviour is unchanged.
### 3. Perf: skip the parser when an existing record is already
populated (commit 3)
`computePeopleToEnrichNames` runs on every cron-driven email/calendar
import batch. The first version called the display-name parser for every
matched existing person, even when both `firstName` and `lastName` were
already set — i.e. the steady-state case after the initial enrichment
pass.
Reordered so the cheap "both fields populated" check short-circuits
before any parsing happens. Same behaviour, fewer parser calls on the
hot path.
## Test plan
- [x] 8 new unit tests for the enrichment bucket: empty `lastName`
enrichment, both `EMAIL` and `CALENDAR` sources, non-overwrite of
non-empty fields, skip on `MANUAL`/`IMPORT`, skip when the new source
also has no last name, skip for soft-deleted, fill `firstName` while
preserving `lastName`, handle null `name` field
- [x] 5 new parser tests for multi-comma forms: `"Last, First, Suffix"`,
credential suffixes (`MD`), three-token forms, whitespace around inner
commas, `:GROUP` tag interaction
- [x] 1 new parser test on the single-comma path covering multi-word
first names (`"Smith, Mary Jane"`)
- [x] All 15 existing parser tests still pass
- [x] All 116 tests in `contact-creation-manager` pass
- [x] `npx nx typecheck twenty-server`
- [x] `npx oxlint --type-aware` + `npx oxfmt --check` on changed files
- [ ] Manual: trigger a fresh contact creation from an outbound email
with no display name, then a subsequent inbound email from the same
address with a full display name, and confirm the Person's last name
gets populated
## Summary
Follow-up to #20953. Migrates 23 of the 30 entities that were left in
`WORKSPACE_SCOPED_EXEMPTIONS` last time, so the lint rule's
workspaceId-enforcement default now covers most of the core/metadata
schema.
### Migrated (23 entities, 88 files, 22 commits)
| Family | Entities |
|---|---|
| Trivial caches | `NavigationMenuItem`, `Skill`, `DataSource`,
`Webhook`, `CommandMenuItem`, `IndexMetadata` |
| Views | `View`, `ViewField`, `ViewFieldGroup`, `ViewFilter`,
`ViewFilterGroup`, `ViewGroup`, `ViewSort` |
| Layouts | `PageLayout`, `PageLayoutTab`, `PageLayoutWidget` |
| Roles & permissions | `Role`, `RoleTarget`, `PermissionFlag`,
`ObjectPermission`, `FieldPermission`, `RowLevelPermissionPredicate`,
`RowLevelPermissionPredicateGroup` |
For each entity: swap `@InjectRepository(X)` →
`@InjectWorkspaceScopedRepository(X)` (and the field type →
`WorkspaceScopedRepository<X>`); rewrite every call site to pass
`workspaceId` as the first arg (stripped from `where`/criteria — the
wrapper throws if you include it now); register
`provideWorkspaceScopedRepository(X)` in every owning NestJS module;
update affected spec providers to
`getWorkspaceScopedRepositoryToken(X)`.
### Rule update
- `ApplicationRegistrationVariableEntity` was misclassified — moved to
`STRUCTURAL_EXEMPTIONS` (no `workspaceId` column; it's keyed on
`applicationRegistrationId` at the instance level).
- 22 of the 23 migrated entities removed from
`WORKSPACE_SCOPED_EXEMPTIONS` entirely (zero remaining raw
`@InjectRepository` sites).
- `RoleTargetEntity` also removed; one call site in
`user-workspace.service.ts` keeps a raw injection with an
`eslint-disable` + reason because `softRemove(...)` is not on the
wrapper API yet (the migration would require threading `workspaceId`
through `deleteUserWorkspace`'s three callers).
### Still exempted (7 entities, follow-up PRs)
| Entity | Why deferred |
|---|---|
| `ApplicationEntity` | ~50 sites with several cross-workspace lookups
by id (auth, OAuth, file-storage, cleanup) |
| `CalendarChannelEntity` / `MessageChannelEntity` | Use
`.increment(...)` (not on wrapper) and
`repository.manager.transaction(...)` — wrapper needs to grow
`.increment` + the transaction sites need `withManager` or dual-inject |
| `FieldMetadataEntity` / `ObjectMetadataEntity` | The metadata services
`extends TypeOrmQueryService<X>` and `super(rawRepo)` — requires
dual-inject or reworking the inheritance |
| `KeyValuePairEntity` | Allows `workspaceId: IsNull()` for
instance-level config; wrapper rejects null |
| `UpgradeMigrationEntity` | Same — instance-level + cross-workspace
ledger |
## Test plan
- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx nx lint twenty-server` — clean (0/0)
- [x] All 10 affected unit specs pass (115 tests) — api-key, agent-role,
permissions, workspace-roles-permissions-cache, view-filter-group,
workflow-version-step-operations, two-factor-authentication (service +
resolver), user-workspace, file
- [ ] Server integration tests in CI
## Summary
Closes#20662.
Two AI permission flags exist:
- **`AI`** (label "Ask AI") — user-facing: chat with AI agents, use AI
features
- **`AI_SETTINGS`** (label "AI") — admin: create and configure AI agents
After auditing every use of these flags I found:
### Frontend — chat UI gated by the admin permission (user-facing bug
from the issue)
A user granted only `Ask AI` could not see chat tabs, the "new chat"
button (desktop & mobile), or the chat content pane; thread
initialization was also skipped, leaving the chat in a half-initialized
state and producing intermittent `THREAD_NOT_FOUND` errors. Switched
these to `AI`:
- `MainNavigationDrawerTabsRow.tsx`
- `MainNavigationDrawer.tsx`
- `MobileNavigationBar.tsx`
- `AgentChatThreadInitializationEffect.tsx`
### Backend — admin-only resolvers gated by the user permission
(privilege escalation)
Two resolvers had a class-level guard of `AI`, letting any user with the
user-facing flag reach admin endpoints (skill CRUD, eval runs). Switched
the class-level guards to `AI_SETTINGS`:
- `SkillResolver` — create/update/delete/activate/deactivate skills
- `AgentTurnResolver` — read turns, run/grade evaluations
### Left as-is (already correct)
- `AgentResolver` — class-level `AI` for reads (workflow editors and
admin pages both need them), mutation-level `AI_SETTINGS` overrides for
writes
- `AgentChatResolver` & `AgentChatSubscriptionResolver` — already `AI`
- `AiGenerateTextController` — already `AI`
- Workspace AI config fields in `workspace.service.ts` — already
`AI_SETTINGS`
## Test plan
- [ ] As a user with `Ask AI` only (no `AI_SETTINGS`): chat tabs, "new
chat" button, and chat history pane are visible on desktop + mobile;
sending a message works; no `THREAD_NOT_FOUND` errors
- [ ] As a user with `AI_SETTINGS` but no `Ask AI`: chat UI is hidden
- [ ] As a user with `Ask AI` only: calling `skills` / `createSkill` /
`agentTurns` / `runEvaluationInput` via GraphQL returns permission
denied
- [ ] As an admin (`AI_SETTINGS`): skill settings and agent eval pages
still work
## Summary
closes https://github.com/twentyhq/core-team-issues/issues/2464
Introduces compile-time branded types to distinguish encrypted
ciphertext from plaintext strings, preventing mix-ups like the one fixed
in #20819 — but at the type level rather in addition to the one existing
at runtime.
### Branded string primitives
- Created `EncryptedString` and `PlaintextString` as hard nominal brands
using `z.string().brand(...)`, making them non-assignable to each other
or to raw `string`
- Created `isEncryptedString` type predicate to narrow `string` to
`EncryptedString` based on the `enc:v2:` envelope prefix
- Retyped `SecretEncryptionService`: `encryptVersioned` accepts
`PlaintextString`, `decryptVersioned` returns `PlaintextString`
### Entity typing
- Typed encrypted columns across entities:
`SigningKeyEntity.privateKey`,
`TwoFactorAuthenticationMethodEntity.secret`,
`ApplicationRegistrationVariableEntity.encryptedValue`,
`ApplicationVariableEntity.value`
- Parameterized JSONB types for connected account connection parameters
(`ImapSmtpCaldavParams<Pwd>`) with reusable aliases
`EncryptedImapSmtpCaldavParams` / `DecryptedImapSmtpCaldavParams`
- Typed DTOs (`CreateApplicationRegistrationVariableInput`,
`UpdateApplicationRegistrationVariablePayload`,
`UpdateApplicationVariableEntityInput`) with `PlaintextString`
### ApplicationVariable always-encrypt uniformization
- Retyped `ApplicationVariableEntity.value` to `EncryptedString | ''` —
all values are now encrypted regardless of `isSecret`
- Updated `ApplicationVariableEntityService` to always encrypt on write
and always decrypt on read
- Simplified `UpdateApplicationVariableActionHandlerService` by removing
conditional encrypt/decrypt-on-isSecret-toggle logic
- Added slow instance command (`2.9.0`) to backfill-encrypt existing
`isSecret=false` plaintext rows and tighten the `CHECK` constraint
### ConfigStorageService refactor
- Split `convertAndSecureValue` (which used `any`) into two well-typed
methods: `convertAndDecrypt` and `convertAndEncrypt`
- Introduced `isSensitiveStringValue` type predicate to narrow values
before encryption/decryption
### What's next
- Typeorm entity derivation to strictly type sitemap configuration as
code + handler logic for encryption rotation
- https://github.com/twentyhq/core-team-issues/issues/2465
## Context
The Install Playwright step ran npx playwright install with no
arguments, which downloads all browsers (Chromium + Firefox + WebKit +
ffmpeg, ~500MB+) on every run with no caching.
Fix:
- Install Chromium only — npx playwright install chromium instead of all
browsers.
- Cache the browser binaries — actions/cache on ~/.cache/ms-playwright,
keyed on the resolved Playwright version (v4-playwright-browsers-${{
runner.os }}-<version>). On a cache hit the install step is skipped
entirely; the cache invalidates automatically when the Playwright
version bumps.
Fixes https://github.com/twentyhq/twenty/issues/20225
The "Me" filter (current workspace member) worked in view filters but
not in dashboard widget filters — the server never resolved the
placeholder, and the widget side-panel UI had no "Me" option and only
allowed single selection.
Backend: `ChartDataQueryService` now forwards the current workspace
member id (from authContext) into filterValueDependencies, so the shared
filter logic resolves "Me" the same way it does for view filters. Added
unit tests for the converter.
Frontend: new multi-select picker for workspace member filters in the
widget side panel, mirroring the view filter's actor select: search
input, "Me" pinned item, and a multi-select workspace member list.
## Before
<img width="3024" height="1488" alt="CleanShot 2026-05-27 at 17 16
36@2x"
src="https://github.com/user-attachments/assets/b2cff46c-53e5-4e8a-a463-b106daf96c8c"
/>
## After
<img width="3024" height="1488" alt="CleanShot 2026-05-27 at 17 14
05@2x"
src="https://github.com/user-attachments/assets/8b3b5f11-44b9-4ae5-a2f3-9c7a689f4bb2"
/>
Fix two omissions from #19973 that prevented `twenty-sdk/billing` from
being a fully exported subpath:
- `package.json`: add `billing` to `typesVersions` (every other subpath
was listed; billing was the only one missing, breaking type resolution
for consumers using classic TS moduleResolution).
- `project.json`: add the billing vite build and `dist/billing` output
to the `build:sdk` target
Bumps [@apollo/client](https://github.com/apollographql/apollo-client)
from 4.1.6 to 4.2.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/apollographql/apollo-client/releases">@apollo/client's
releases</a>.</em></p>
<blockquote>
<h2><code>@apollo/client</code><a
href="https://github.com/4"><code>@4</code></a>.2.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/apollographql/apollo-client/pull/13132">#13132</a>
<a
href="https://github.com/apollographql/apollo-client/commit/f3ce805425d10a9666218a8e109288a2d46dcab1"><code>f3ce805</code></a>
Thanks <a
href="https://github.com/phryneas"><code>@phryneas</code></a>! -
Introduce "classic" and "modern" method and hook
signatures.</p>
<p>Apollo Client 4.2 introduces two signature styles for methods and
hooks. All signatures previously present are now "classic"
signatures, and a new set of "modern" signatures are added
alongside them.</p>
<p><strong>Classic signatures</strong> are the default and are identical
to the signatures before Apollo Client 4.2, preserving backward
compatibility. Classic signatures still work with manually specified
TypeScript generics (e.g.,
<code>useSuspenseQuery<MyData>(...)</code>). However, manually
specifying generics has been discouraged for a long time—instead, we
recommend using <code>TypedDocumentNode</code> to automatically infer
types, which provides more accurate results without any manual
annotations.</p>
<p><strong>Modern signatures</strong> automatically incorporate your
declared <code>defaultOptions</code> into return types, providing more
accurate types. Modern signatures infer types from the document node and
do not support manually passing generic type arguments; TypeScript will
produce a type error if you attempt to do so.</p>
<p>Methods and hooks automatically switch to modern signatures the
moment any non-optional property is declared in
<code>DeclareDefaultOptions</code>. The switch happens across all
methods and hooks globally:</p>
<pre lang="ts"><code>// apollo.d.ts
import "@apollo/client";
declare module "@apollo/client" {
namespace ApolloClient {
namespace DeclareDefaultOptions {
interface WatchQuery {
errorPolicy: "all"; // non-optional → modern signatures
activated automatically
}
}
}
}
</code></pre>
<p>Users can also manually switch to modern signatures without declaring
any <code>defaultOptions</code>, for example when wanting accurate type
inference without relying on global <code>defaultOptions</code>:</p>
<pre lang="ts"><code>// apollo.d.ts
import "@apollo/client";
declare module "@apollo/client" {
export interface TypeOverrides {
signatureStyle: "modern";
}
}
</code></pre>
<p>Users can do a global <code>DeclareDefaultOptions</code> type
augmentation and then manually switch back to "classic" for
migration purposes:</p>
<pre lang="ts"><code>// apollo.d.ts
import "@apollo/client";
declare module "@apollo/client" {
export interface TypeOverrides {
signatureStyle: "classic";
}
}
</code></pre>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/apollographql/apollo-client/blob/main/CHANGELOG.md">@apollo/client's
changelog</a>.</em></p>
<blockquote>
<h2>4.2.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/apollographql/apollo-client/pull/13132">#13132</a>
<a
href="https://github.com/apollographql/apollo-client/commit/f3ce805425d10a9666218a8e109288a2d46dcab1"><code>f3ce805</code></a>
Thanks <a
href="https://github.com/phryneas"><code>@phryneas</code></a>! -
Introduce "classic" and "modern" method and hook
signatures.</p>
<p>Apollo Client 4.2 introduces two signature styles for methods and
hooks. All signatures previously present are now "classic"
signatures, and a new set of "modern" signatures are added
alongside them.</p>
<p><strong>Classic signatures</strong> are the default and are identical
to the signatures before Apollo Client 4.2, preserving backward
compatibility. Classic signatures still work with manually specified
TypeScript generics (e.g.,
<code>useSuspenseQuery<MyData>(...)</code>). However, manually
specifying generics has been discouraged for a long time—instead, we
recommend using <code>TypedDocumentNode</code> to automatically infer
types, which provides more accurate results without any manual
annotations.</p>
<p><strong>Modern signatures</strong> automatically incorporate your
declared <code>defaultOptions</code> into return types, providing more
accurate types. Modern signatures infer types from the document node and
do not support manually passing generic type arguments; TypeScript will
produce a type error if you attempt to do so.</p>
<p>Methods and hooks automatically switch to modern signatures the
moment any non-optional property is declared in
<code>DeclareDefaultOptions</code>. The switch happens across all
methods and hooks globally:</p>
<pre lang="ts"><code>// apollo.d.ts
import "@apollo/client";
declare module "@apollo/client" {
namespace ApolloClient {
namespace DeclareDefaultOptions {
interface WatchQuery {
errorPolicy: "all"; // non-optional → modern signatures
activated automatically
}
}
}
}
</code></pre>
<p>Users can also manually switch to modern signatures without declaring
any <code>defaultOptions</code>, for example when wanting accurate type
inference without relying on global <code>defaultOptions</code>:</p>
<pre lang="ts"><code>// apollo.d.ts
import "@apollo/client";
declare module "@apollo/client" {
export interface TypeOverrides {
signatureStyle: "modern";
}
}
</code></pre>
<p>Users can do a global <code>DeclareDefaultOptions</code> type
augmentation and then manually switch back to "classic" for
migration purposes:</p>
<pre lang="ts"><code>// apollo.d.ts
import "@apollo/client";
declare module "@apollo/client" {
export interface TypeOverrides {
signatureStyle: "classic";
}
}
</code></pre>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/apollographql/apollo-client/commit/e010bdd239b5c10415d4b70ca791467cde12fc88"><code>e010bdd</code></a>
Version Packages (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13241">#13241</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/9c4c01a640b43bfb47bd52b25d5881c4ad7bec71"><code>9c4c01a</code></a>
Release 4.2 (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13129">#13129</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/222838e99bc6054120cc1f881bb225b1ef049de9"><code>222838e</code></a>
Exit prerelease mode</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/7d3a533c811a8423536ceeebccc06413ded5b6a3"><code>7d3a533</code></a>
Merge branch 'main' into release-4.2</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/f20d591bbf74cb4f0d87ec9a14b93a59fe46b039"><code>f20d591</code></a>
chore(deps): update actions/create-github-app-token digest to d72941d
(<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13239">#13239</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/d4a28b6142e47164c8a24bd8c05a8aa3f1ce4eee"><code>d4a28b6</code></a>
chore(deps): pin dependencies (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13237">#13237</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/c1f39cf5402b052ab92886a1857840a745aee02b"><code>c1f39cf</code></a>
ci: pin Actions@SHA and disable cache on workflows with elevated OIDC
permiss...</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/511048b7bd6253a38a6b7ebe58e9674a39c74273"><code>511048b</code></a>
Event-based refetching docs (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13228">#13228</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/d1f68f1a5fdb7c6915a72b2426cad373a0526c06"><code>d1f68f1</code></a>
Version Packages (rc) (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13234">#13234</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/f1b541fed4111028b6842727178288156582e669"><code>f1b541f</code></a>
Prepare for rc release (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13232">#13232</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/apollographql/apollo-client/compare/@apollo/client@4.1.6...@apollo/client@4.2.0">compare
view</a></li>
</ul>
</details>
<br />
[](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>
## Summary
- Fixes a bug where `noteTarget` (and any other morph-relation join
object) created via AI/MCP would land with `targetCompanyId` /
`targetPersonId` / `targetOpportunityId` left null, even though the tool
reported success.
- Root cause: the Zod schema generators for the AI tools only branched
on `FieldMetadataType.RELATION`. MORPH_RELATION fields fell through to
the default case — for `create_*` they were exposed as `targetCompany:
string` instead of `targetCompanyId: uuid`, and for `group_by_*` they
were silently skipped entirely. Downstream
(`data-arg-processor.service.ts` and the group-by arg processor) already
accept the join-column form for both kinds of relations via
`computeMorphOrRelationFieldJoinColumnName` and
`isMorphOrRelationFlatFieldMetadata`, so the fix is purely in the schema
generators.
## Changes
- `record-properties.zod-schema.ts` — extend the existing RELATION
MANY_TO_ONE / ONE_TO_MANY branches to also match MORPH_RELATION.
- `group-by-tool.zod-schema.ts` — replace the silent MORPH_RELATION skip
with the same treatment as RELATION MANY_TO_ONE (exposes `${name}Id` as
a groupBy option).
- `test/integration/ai/suites/mcp-tool-execution.integration-spec.ts` —
new file. First integration test for tool execution end-to-end. Drives
the real MCP JSON-RPC endpoint with the seeded API key (`learn_tools`
for schema introspection, `execute_tool` for invocation):
- asserts `create_note_target`'s schema exposes `targetCompanyId` /
`targetPersonId` / `targetOpportunityId` as UUIDs and does **not**
expose `targetCompany` / `targetPerson` / `targetOpportunity`.
- creates a company + note + noteTarget via MCP, then queries the
workspace schema to confirm `targetCompanyId` is actually persisted in
the FK column.
- asserts `group_by_note_targets` schema accepts `targetCompanyId` as a
groupBy key.
- sets up 3 noteTargets (2 → company A, 1 → company B), calls
`group_by_note_targets` by `targetCompanyId`, and asserts the counts.
Out of scope: `record-filter.zod-schema.ts` has the same pattern (only
RELATION) — left for a follow-up so this PR stays focused on what was
reported.
## Test plan
- [x] `npx nx typecheck twenty-server`
- [x] `npx oxlint --type-aware` on changed files — clean
- [x] `npx oxfmt --check` on changed files — clean
- [x] Integration tests pass (4/4) after `database:reset`:
- `should expose the morph-relation join columns as \`${name}Id\` UUID
parameters`
- `should persist targetCompanyId when create_note_target is invoked via
MCP`
- `should expose targetCompanyId as a valid groupBy option`
- `should group noteTargets by targetCompanyId via MCP`
## Summary
Both **Email Handles** and **Emailing Domains** were rendered on the
General workspace settings page, but they're workspace-level *email
infrastructure* (inbound shared addresses + outbound sender
authentication) and don't belong with the workspace name, picture, and
domain config.
- New `SettingsWorkspaceEmail` page at `/settings/email`
- Nav item under **Workspace**, hidden when `IS_EMAIL_GROUP_ENABLED` is
off (and gated by `WORKSPACE` permission)
- Related sub-routes (`email-group/:messageChannelId`,
`emailing-domain/:domainId`, etc.) moved from `general/` to `email/` so
the URL space stays consistent with the page
- General page now only contains name, picture, workspace domain, and
the delete-workspace section
No behavior changes to the underlying section components — they're
imported as-is into the new page.
## Test plan
- [ ] With `IS_EMAIL_GROUP_ENABLED` enabled: **Email** appears in the
Workspace nav and the page renders both sections
- [ ] With the flag disabled: **Email** is hidden from nav; navigating
to `/settings/email` directly renders nothing
- [ ] General page no longer shows Email Handles / Emailing Domains
- [ ] Clicking a shared inbox row navigates to
`/settings/email/email-group/:id` (was `general/...`)
- [ ] "Add emailing domain" navigates to
`/settings/email/emailing-domain/new`
## Notes
- Pre-existing `twenty-front` typecheck error in
`FrontComponentRendererProvider.tsx` (React types mismatch between
sibling packages) reproduces on `main` and is unrelated to this PR.
## Summary
- Add `refetchQueries` to the `updateWorkspaceFeatureFlag` mutation in
the admin workspace detail page so the toggle reflects the new value
after toggling.
- Rename `useFeatureFlagState` → `useAdminUpdateFeatureFlag` since the
hook lives under `admin-panel` and is only consumed by the admin
workspace detail page.
## Bug
In the admin panel, toggling a feature flag for a workspace other than
the admin's own workspace sent the backend mutation successfully, but
the toggle in the UI remained unchanged.
The displayed value is derived from:
```tsx
const currentWorkspaceValue =
currentWorkspace?.id === workspaceId
? currentWorkspace?.featureFlags?.find((f) => f.key === flag.key)?.value
: undefined;
const displayedValue = currentWorkspaceValue ?? flag.value;
```
When viewing a different workspace, `currentWorkspaceValue` is
`undefined` so the toggle reads `flag.value` from the
`WORKSPACE_LOOKUP_ADMIN_PANEL` query. That query was never refetched
after the mutation, so the displayed value stayed stale.
The existing optimistic Jotai update on `currentWorkspaceState` still
runs — it is needed so the rest of the app (anything consuming
`useIsFeatureEnabled`) reacts immediately when an admin toggles a flag
on their own workspace.
## Test plan
- [ ] Open the admin panel → pick a workspace that is not your own →
Feature Flags tab → toggle a flag → toggle visually flips after the
mutation completes.
- [ ] Same flow on your own workspace → toggle flips, and any UI gated
on that flag also reacts.
- [ ] If the mutation fails, the toggle reverts (existing `onError`
rollback path).
This pull request unifies outbound with inbound under the new feature
and the new email groups feature.
These are workspace level shared inboxes that are shared between all
workspace members.
outbound sending with SES works, we only listen for tenant status
events, rest is managed by AWS
PR refactors old code and webhook to be split for outbound and inbound
for proper separation
| Area | Change |
|---|---|
| AWS SES driver | Split into `AwsSesRegisterDomainService` (tenant +
identity + DKIM + MAIL FROM + configuration-set + EventBridge dest +
contact list) and `AwsSesSendEmailService` (SendEmail). |
| Reputation webhook | New `/webhooks/messaging/ses/outbound` route. SES
→ EventBridge (`Sending Status Enabled/Disabled` on default bus) → SNS →
router → `SesOutboundSendingStateHandlerService` updates
`emailing_domain.tenantStatus`. |
| Inbound webhook | Refactored into `SesInboundWebhookRouterService` +
`SesInboundMailHandlerService`. Shared `SnsSignatureVerifierService` +
`SnsSubscriptionConfirmerService` across both routes. |
| Global uniqueness | New migration + instance command:
`emailing_domain.domain` is now globally unique (one tenant per domain
across workspaces). |
| Tenant status | New `emailing_domain.tenantStatus` column (`ACTIVE` /
`PAUSED`) + `EmailingDomainTenantStatusService`. |
| Send-email mutation | New `sendEmailViaDomain` GraphQL mutation +
DTOs. |
| Cleanup | `EmailingDomainWorkspaceCleanupJob` wired into
`WorkspaceService.deleteWorkspace` — tears down SES tenant association +
identity on workspace delete. |
| Settings UI | Rewritten around reusable `SettingsTableListSection`.
"Email Group" → "Email Handle" rename. New cells for
status/source/forwarding. Outbound domains surfaced on workspace
settings page. |
### Env vars (new)
All in `config-variables.ts`, group `AWS_SES_SETTINGS`, all optional:
- `AWS_SES_REGION` — `@IsAWSRegion`, consumed by `AwsSesClientProvider`
+ driver factory
- `AWS_SES_ACCOUNT_ID` — used for ARN construction in driver factory
- `SES_SNS_TOPIC_ARN_ALLOWLIST` — **shared** by inbound + outbound
webhook routers, comma-separated list of accepted SNS topic ARNs
(verified via `sns-payload-validator`)
### Migrations
- `1778862608620-add-emailing-domain-tenant-status` (fast) — adds
`tenantStatus` column.
- `1778865501791-unique-emailing-domain-globally` (slow, idempotent) —
enforces global uniqueness on `domain`.
- Instance commands bumped to `2.5`.
### Infra dependency
Two coupled twenty-infra PRs:
- `ses-inbound-email` — receipt-rule + inbound SNS topic + S3 bucket
policy + KMS grant + `email_group_*` outputs.
- `ses-outbound-tf` — EventBridge rule + outbound SNS topic + SES IAM
policy + outbound `webhook_url` subscription. **Based on
`ses-inbound-email`.**
Merge order: inbound first, then outbound. Outbound PR's chart edit owns
the comma-joined `SES_SNS_TOPIC_ARN_ALLOWLIST` value (both ARNs).
Features lives under `/settings/general`
<img width="1496" height="845" alt="SCR-20260519-ofhi-2"
src="https://github.com/user-attachments/assets/a025485a-09f7-4131-91cd-0067690ff18d"
/>
---------
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
## Summary
Adds a third tenancy enforcement layer for entities that live in shared
schemas (`core`, `metadata`) and carry a `workspaceId` column —
previously the only safeguard at this layer was developer discipline
(remembering to put `workspaceId` in every WHERE clause).
### The three layers, after this PR
| Layer | Scope | How it's enforced |
|---|---|---|
| 1. Workspace data | per-workspace schema (companies, people, custom
objects) | `twentyORMManager.getRepository(workspace, E)` — physical
isolation (own data source) |
| 2. Metadata | shared `metadata` schema (objectMetadata, fieldMetadata,
views, roles…) | Flat-entity-maps cache — workspace-scoped in-memory
map, lookups by id within it |
| 3. Core (new) | shared `core` schema (agent threads/turns/messages,
app tokens, etc.) | `WorkspaceScopedRepository<T>` — `workspaceId` is a
required positional argument on every read/write |
## What's in the PR
### The wrapper
(`packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/`)
- `WorkspaceScopedRepository<T extends WorkspaceScopedEntity>` — wraps a
TypeORM `Repository<T>`, requires `workspaceId` on every
`find`/`findOne`/`findOneOrFail`/`update`/`delete`/`softDelete`/`insert`/`save`/`count`
call, merging it into the WHERE or stamping it on the entity.
`createQueryBuilder` is an explicit escape hatch (caller scopes
manually).
- Provided via Nest DI with
`@InjectWorkspaceScopedRepository(EntityClass)` and the
`provideWorkspaceScopedRepository(EntityClass)` provider factory.
- 19 unit tests cover the merge behavior, override-on-conflict, and the
array-where (OR) case.
### Lint enforcement
(`packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts`)
- New `twenty/prefer-workspace-scoped-repository` rule (level:
**error**).
- Blacklist of entity names: raw `@InjectRepository(E)` is rejected if
`E` is on the list.
- Initial list: `AgentTurnEntity`, `AgentMessageEntity`,
`AgentMessagePartEntity`, `AgentChatThreadEntity`,
`AgentTurnEvaluationEntity`, `AgentEntity`.
- Designed to grow over time as more consumers are migrated.
- 5 rule tests.
### Migration in this PR
All consumers of the six blacklisted entities, including:
- AI agent / chat / monitor resolvers, services, and jobs
- `AgentService`, `AiAgentRoleService`, `AiAgentWorkflowAction`,
`ApplicationService`, `WorkspaceFlatAgentMapCacheService`
- Admin-panel chat (migrated where the lookup is workspace-known; one
documented `eslint-disable` on the threadId-discovery lookup that
necessarily precedes the `allowImpersonation` permission check)
- `AiAgentRoleService` unit spec updated to mock the scoped wrapper
## Future work (deliberately not in this PR)
A standalone audit identified ~14 additional `core`/`metadata` entities
with `workspaceId` that currently use raw `@InjectRepository` and could
be added to the blacklist. Notable candidates: `UserWorkspaceEntity` (42
sites), `AppTokenEntity` (10), `FileEntity` (7),
`BillingCustomerEntity`/`BillingSubscriptionEntity` (~22 combined). Each
should be its own PR — the migration is mechanical but the surface is
wide.
## Test plan
- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx nx lint twenty-server` — 0 warnings, 0 errors
- [x] `npx jest workspace-scoped-repository` — 19/19 pass
- [x] `npx nx test twenty-oxlint-rules` — 215/215 pass
- [x] `npx jest src/engine/metadata-modules/ai` — 44/44 pass
- [ ] Manual smoke: end-to-end AI agent chat send/receive (reviewer)
- [ ] Manual smoke: AI agent monitor — list turns, run evaluation
(reviewer)
- [ ] Manual smoke: admin-panel chat thread inspection (reviewer)
## Summary
When messages are imported, Twenty auto-creates a Person record for any
recipient that doesn't exist yet. The display-name parser used at that
point is `displayName.split(' ')[0] / [1]`, which silently mangles
several common header shapes:
| Header | Old result |
|-------------------------------------------------|-----------------------------------------|
| `"Doe, John" <...>` | `firstName="Doe,"`, `lastName="John"` |
| `"John.Doe Doe" <...>` | `firstName="John.Doe"`, `lastName="Doe"`|
| `"Mary Jane Watson" <...>` | `lastName="Jane"` ("Watson" dropped) |
| `"john.doe@x.com" <john.doe@x.com>` (forwarder) | full address in
`firstName` |
| `"Doe, John:GROUP" <...>` (group-tag servers) |
`firstName="John:GROUP"` |
This PR rewrites `getFirstNameAndLastNameFromHandleAndDisplayName` to
handle each pattern. Behaviour in order:
1. Trim + strip wrapping quotes
2. Swap `"Last, First"` comma form
3. Fall back to handle parsing when display name contains `@` (real
names don't)
4. Split single dotted tokens (`"john.doe"` → `"John"`, `"Doe"`)
5. Preserve multi-word last names (`tokens.slice(1).join(' ')`)
6. De-synthesize dot-glued first names (`"John.Doe Doe"` → `"John"`,
`"Doe"`)
7. Strip `:XXX` trailing tag suffix from each parsed field
## Test plan
- [x] 16 new unit test cases covering each shape
(`__tests__/get-first-name-and-last-name-from-handle-and-display-name.util.spec.ts`)
- [x] Lint + typecheck clean
- [ ] No regression in the messaging import flow
---------
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: neo773 <neo773@protonmail.com>
Follow-up to #20634. Removes three desktop design changes that bled in
unintentionally:
- `font-weight: regular` → restored to `medium` on nav item labels
- `MenuItemIconBoxContainer` wrap around bare icons → removed
- Section title `padding-right/top` tweak → restored to original values
Mobile-specific fixes from #20634 (slide-over drawer width, min-width
overflow fixes, breadcrumb cleanup, etc.) are preserved.
## Summary
- **Fix `callWithTimeout` timer leak**: the `setTimeout` was never
cleared when the callback resolved first, leaving orphaned timers (up to
15 minutes) in the Node.js event loop. Now uses `try/finally` with
`clearTimeout`.
- **Properly classify timeout errors**: introduced
`ExecutionTimedOutError` so the Lambda driver can throw
`LOGIC_FUNCTION_EXECUTION_TIMEOUT` instead of
`LOGIC_FUNCTION_EXECUTION_FAILED` — users now see "Function execution
timed out" instead of the generic "Function execution failed."
## Test plan
- [x] Execute a logic function normally and verify it still works
- [x] Trigger a logic function timeout and verify the error message says
"Function execution timed out" (not "Function execution failed")
- [x] Verify that a deleted logic function invocation logs a specific
"was deleted" warning
- [x] Verify no orphaned timers remain after logic function execution
completes
## Summary
Follow-up to #20966 (Stripe fetch client). axios's default Node http
adapter on workerd has the same TLS hang the Stripe SDK had — it just
doesn't surface today because all three call sites are wrapped in
\`unstable_cache(revalidate: 3600)\` and the cache is populated at build
time, so misses are rare and the failure mode is a silent \`null\` to
the layout.
This swaps the three remaining axios calls in \`twenty-website\` for
native \`fetch\` and removes axios from
\`packages/twenty-website/package.json\`. The package is still used by
other workspaces, so yarn.lock keeps the other resolution.
Touched call sites:
- \`src/lib/releases/fetch-latest-release-tag.ts\` (GitHub releases —
runs at build time, cosmetic)
- \`src/lib/community/fetch-github-star-count.ts\` (GitHub star count in
menu)
- \`src/lib/community/fetch-discord-member-count.ts\` (Discord member
count in menu)
## Test plan
- [ ] After merge + deploy: confirm GitHub star + Discord member counts
render in the site menu (non-zero, formatted)
- [ ] Confirm \`/releases\` shows the latest tag-gated visible release
notes
- [ ] No \`axios\` in worker bundle (\`grep axios .open-next/worker.js\`
should be empty)
Fixes: #20742
# Issue:
In the timeline activity inside the side panel, when we scroll down it
fetches more data and it displays a skeleton and after fetching finishes
the scroll position always jumps back to the top. Because of this, we
have to scroll to the bottom again to load more data.
https://github.com/user-attachments/assets/40d99df7-bdfb-4351-bc4f-baec2a035f13
## Root Cause
`useTimelineActivities` exposed a single `loading` state from
`useFindManyRecords`, which became true for both:
- Initial timeline fetch
- Pagination / fetchMore requests
`loadingTimelineActivities` becomes true whenever a network request is
triggered, including pagination requests where timeline records are
already available.
Because of this, the UI could not distinguish between the first query
loading state and subsequent fetchMore loading states.
## Fix
Added a separate firstQueryLoading state to detect only the first
timeline request.
The first query is identified by checking:
- the request is still loading
- and no timeline activities have been loaded yet
Once activities are already available, any future loading state is
treated as pagination/loading more records instead of initial loading.
This allows the UI to correctly handle:
- Skeleton loaders for first load
- Infinite scroll loaders for pagination
- Empty states after loading finishes
https://github.com/user-attachments/assets/48e8e078-82e2-43d8-823f-2f71e4f4f6e1
## What
Pressing Enter to confirm an IME (CJK) composition no longer submits the
input. The Enter / Escape / Tab handlers now ignore key events fired
while a composition is in progress (`isComposing`, or the legacy
`keyCode === 229`).
Fixes#20954
## Why
`isComposing` was not checked anywhere in `twenty-front`, so the Enter
that confirms a Japanese / Chinese / Korean conversion was also consumed
as a submit / escape / tab hotkey — making it very hard to type CJK text
into any input that submits on Enter.
## Changes
- `useHotkeysOnFocusedElement` — central guard; covers every input wired
through `useRegisterInputEvents` (~13 components) and all hotkeys routed
through this hook.
- Direct `onKeyDown` Enter handlers: `CreateWorkspace`,
`SettingsDevelopersApiKeysNew`, `SettingsAccountsBlocklistInput`.
## Notes
- No effect on non-IME (Latin) typing — `isComposing` is only true
during an active composition. It also improves accented / dead-key input
on Latin layouts.
- `react-hotkeys-hook@4` does not handle IME composition on its own, so
the guard is explicit.
## Testing
Manually verified with a Japanese IME on Chrome (macOS) against the
v2.8.3 self-hosted image: romaji + Enter now only confirms the
conversion; a second Enter on committed text submits as expected. The
GIF in #20954 shows the original buggy behavior.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
## Summary
Every `/api/enterprise/*` route on `twenty-website-prod` is currently
timing out at exactly 80s with `Request aborted due to timeout being
reached (80000ms)` — that's `Stripe.DEFAULT_TIMEOUT` aborting itself,
not Cloudflare.
The Stripe SDK's default transport uses Node's `http`/`https` module.
Under workerd, even with `nodejs_compat`, the outbound TLS connection to
`api.stripe.com` hangs and the SDK eventually times out at its built-in
80s ceiling. Worked on EKS (real Node), breaks on Cloudflare Workers.
Fix is one line: pass `httpClient: Stripe.createFetchHttpClient()` so
the SDK uses workerd's native `fetch` instead of the polyfilled Node
transport.
## Blast radius
`getStripeClient()` is shared across every enterprise route. All of
these are silently broken on prod right now:
- `POST /api/enterprise/checkout` (confirmed in logs)
- `POST /api/enterprise/portal`
- `POST /api/enterprise/seats`
- `GET /api/enterprise/status`
- `POST /api/enterprise/activate`
- `POST /api/enterprise/validate`
Single change in `stripe-client.ts` unblocks all of them.
## Test plan
- [ ] After merge + deploy to prod: `wrangler tail twenty-website-prod`
while hitting the enterprise checkout flow; same call should complete in
<2s instead of 80s
- [ ] Verify a real Stripe checkout session is created (Stripe dashboard
→ Payments → Checkout sessions)
- [ ] Spot-check `/api/enterprise/status` and `/api/enterprise/portal`
are no longer timing out
Remove usage of hasValidEnterpriseKey in FE (replaced by
hasValidSignedEnterpriseKey)
To avoid breaking change at deploy time, we will wait until after this
has been deployed in prod, to remove hasValidEnterpriseKey in the BE.
**Context**
Fix empty marketplace at /partners/list: page was being statically
prerendered with getPartners() returning [] (build-time fetch failure),
then served from OpenNext's R2 cache forever — so the partners API was
never actually called in production.
**Change**
Wrap getPartners in unstable_cache with revalidate: 300, matching the
existing fetchGithubStarCount / fetchDiscordMemberCount ISR pattern.
After deploy, the cached empty result expires within 5 minutes and the
worker refetches from the partners API at runtime (where the env vars
actually exist), populating the page. Also drops the now-redundant
cache: 'no-store' from partnersApiFetch.
## Customer-reported bug
A customer hit this when using the AI chat:
```json
{
"message": "API key 760d4822-da40-4b3f-9031-40563d7ed6c9 has no role assigned",
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"userFriendlyMessage": "This API key has no role assigned."
}
}
```
Their integration authenticates via API key. Somewhere along the way,
the role bound to that API key was deleted, leaving the API key
authenticated but role-less. Any request that hits a permission check
(`getRoleIdForApiKeyId`) blows up.
## Root cause
In `RoleService.deleteManyRoles`, the pre-deletion cleanup
(`assignDefaultRoleToMembersWithRoleToDelete`) only rebinds **user
workspaces** to the workspace default role. API keys and agents pointing
at the role are ignored. Because `RoleTargetEntity.role` declares
`onDelete: 'CASCADE'`, the FK then drops the role_target rows for those
API keys / agents — but the API keys themselves stay in `api_key`, now
orphaned in `apiKeyRoleMap`.
A previous read-side workaround
([2767ddac44](https://github.com/twentyhq/twenty/commit/2767ddac44) —
make the `role` ResolveField nullable) handled the API-key-details page,
but did not address the write paths (`getRoleIdForApiKeyId`).
## Fix
- Rename `assignDefaultRoleToMembersWithRoleToDelete` →
`rebindTargetsOfRoleToDeleteToDefaultRole` and extend it to rebind API
keys (via `ApiKeyRoleService.assignRoleToApiKey`) and agents (via
`AiAgentRoleService.assignRoleToAgent`) in the same step, before the
role is deleted.
- If the workspace default role doesn't satisfy `canBeAssignedToApiKeys`
/ `canBeAssignedToAgents`, the inner `assignRoleTo*` validation throws.
We catch that and rethrow as a `PermissionsException` with a
role-deletion-context message and two new codes —
`ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS` /
`ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS` — so the admin sees a clear
"reassign these first" prompt rather than a confusing inner error.
## Scope / non-goals
- **Already-orphaned API keys are not auto-healed.** The customer still
needs to reassign a role to their existing orphan API key via the UI
(Settings > API Keys > [the key] > role). A separate cleanup command for
existing orphans is a follow-up.
- I did not investigate *why* the customer's session was authenticated
via API key in the AI chat — that may be their integration setup. Worth
confirming with them separately.
## Test plan
- [ ] Workspace with default role `Admin` (which has
`canBeAssignedToApiKeys: true`): create an API key with a custom role,
delete the custom role → API key is rebound to Admin, requests keep
working.
- [ ] Workspace with default role `Member` (default, has
`canBeAssignedToApiKeys: false`): create an API key with a custom role,
delete the custom role → role deletion fails with the new
`ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS` error explaining the admin must
reassign first. API key + custom role are both unchanged.
- [ ] Same two scenarios for agents (`canBeAssignedToAgents`).
- [ ] Existing user-workspace rebind behavior is unchanged.
- [ ] Role deletion with no dependent API keys / agents still works.
## Summary
Raises the artificial hardcoded ceiling on `maxNumberOfValues` for
custom FILES fields from `10` to `60` so users can attach more files per
record.
- Bumped `FILES_FIELD_MAX_NUMBER_OF_VALUES` constant in `twenty-shared`
from `10` to `60`
- Updated validator unit test (inline snapshots + "exceeds max" case)
- Updated create/update files-field metadata integration tests and Jest
snapshots
The frontend Zod schema only enforces a `min`, so no frontend changes
are required — the backend constant is the single source of truth for
the upper bound.
Refs #20942🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
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>
## Introduction
Should rely on custom typeorm entity loader layer that inspects the
upgradeMigration that has bene run to dynamically request existing col
only
## Summary
- Fixes https://github.com/twentyhq/twenty/issues/20906
- Moves `markStepForRecomputation` from `useUpdateStep` into the
lower-level `useUpdateWorkflowVersionStep` hook, so **every** caller
that updates a step also triggers output schema recomputation.
- Previously, renaming a step via the side panel title input called
`useUpdateWorkflowVersionStep` directly (bypassing `useUpdateStep`), so
the variable picker kept showing the initial default name (e.g. "Create
Record") instead of the user's custom name.
## Test plan
- [x] Rename a workflow step via the side panel title input
- [x] Verify the variable picker dropdown shows the updated name
- [x] Verify variable tags/chips in subsequent steps reflect the updated
name
- [x] Verify that updating step settings (e.g. changing object type)
still refreshes the output schema correctly
# Introduction
closes https://github.com/twentyhq/private-issues/issues/484
This PR refactors the writeFile API to never expect to be passed a
mimetype, its extract is done programmatically low level so any callers
will pass through
Same for the file sanitization
## IANA override
Disclaimer for consistency we existing behavior we wanted to always have
`application/typescript`
- should we rather consider fallbacking to octect-steam instead ?
- Any pulbic assets that has .ts will now also fallback to
`application/typescript` instead of the official IANA
## Integration
Added coverage
## Summary
- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version
- Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same
version
## Checklist
- [ ] Verify version constants are correct
- [ ] Verify npm package versions match
Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
[#20836](https://github.com/twentyhq/twenty/pull/20836) dropped the
channel objects but even though
calendarChannelId/messageChannelId/messageFolderId already existed in
compute standard flat field, there was never an upgrade command to readd
them on the surviving association objects
so existing workspaces lost the field metadata (columns survived) and
import workers throw
```Error: Unknown error importing calendar events for calendar channel <REDACTED> in workspace <REDACTED>: Query runner already released. Cannot run queries anymore.```
This PR adds that command
---------
Co-authored-by: prastoin <paul@twenty.com>
App permissions tab:
- The fallback uuidv4() for a marketplace field was generated twice, so
id and universalIdentifier could diverge; it's now computed once and
reused as it seemed to be the intention (even though I don't really
think it's a good idea)
- Renamed buildobjectMetadataItemsFromMarketplaceApp →
buildObjectMetadataItemsFromMarketplaceApp to follow camelCase.
Morph relation validation:
- Fixed the user-facing message "At least one relation is require" →
"...is required"
- Typos in the related test descriptions (Morh → Morph, samefield → same
field) and their snapshots.
Docs
- The UUID field-type row in views.mdx only listed IS; updated to the
full set supported by FILTER_OPERANDS_MAP (IS, IS_NOT, IS_EMPTY,
IS_NOT_EMPTY).
Removed the releases page’s runtime dependency on `fs` and
`process.cwd()` by introducing a build-time manifest generator: release
notes still live as markdown under `src/content/releases`, but a new
script now parses their frontmatter/content, validates that each note
has a release, title, and preview image (and that the image actually
exists), sorts the notes, and emits a typed `generated-release-notes.ts`
file that the app imports at runtime.
Updated the releases loader to return that generated data, changed the
menu releases preview and release JSON-LD to use explicit typed fields
(`title`, `previewImage`) instead of scraping markdown with regex at
runtime, wired the generator into Nx so it runs automatically before
`dev`, `build`, and `typecheck`, and fixed two stale image references in
the release MDX files that the new validation exposed.
---------
Co-authored-by: prastoin <paul@twenty.com>
## Summary
Two related threads for the internal `twenty-partners` app:
1. **Redesign `partnerQuote` → `partnerContent`.** The object was
mis-modeled as a sales/pre-invoice doc (`amount`, opportunity link). In
TFT it's actually a marketing-content catalog — customer quotes, case
studies, partner quotes, logos — moving through a production lifecycle.
This renames it in place and reshapes it to mirror TFT's
`CustomerContent`.
2. **Import tooling improvements** to the TFT importer + multi-env
workflow.
## Changes
**Schema (`partnerContent`)**
- Rename `partnerQuote` → `partnerContent` (object, view, nav, relation
fields, identifiers).
- Add `contentType` MULTI_SELECT `[CUSTOMER_QUOTE, CASE_STUDY,
PARTNER_QUOTE, LOGO]` and `interview` LINKS.
- Add `customerCompany` / `customerPerson` relations; keep `partner`;
drop the `opportunity` link (TFT has none).
- Drop `amount`; rename the FILES field `quoteFile` → `documents`
(`attachments` is a reserved morph-relation name).
**Importer (`import-from-tft.ts`)**
- Import the full content catalog (all types), not just `PARTNER_QUOTE`.
- Map TFT `partnerTimezone` → `region`, default
`languagesSpoken=[ENGLISH]`, and set `deploymentExpertise=[SELF_HOST]`
when scope includes `HOSTING_ENVIRONMENT`.
- Filter to partner-relevant records only: opportunities linked to a
partner (20 of 164), content linked to a partner (10 of 22). Drops
general sales-pipeline / customer-only noise.
- Dedupe companies by **normalized domain** (Twenty's unique key), not
just name — fixes duplicate-entry crashes when the same company arrives
under different names.
- Progress logging throughout.
**Tooling**
- `purge-soft-deleted` script (soft-deleted rows block re-imports via
unique constraints).
- Multi-env script variants (`*:prod`) selected via `ENV_FILE`.
## Testing
Verified on a local Twenty instance and on `partner.twenty.com`:
- 122 partners, 20 partner-linked opportunities, 10 partner-linked
content (all types), 229 domain-deduped companies.
- Schema confirmed via metadata introspection; `yarn twenty typecheck`
clean.
## Notes
- Renaming an installed object isn't a pure in-place migration on a
server that already had `partnerQuote` — the working path is `uninstall
→ deploy → install` (safe here: prod had no data).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
Follow-up to #20876. That PR bumped `esbuild` to `^0.27.3` to address
the Go-stdlib CVEs the self-hoster reported, but only one of the two Go
CVEs is actually fixed at that level. This PR closes the remaining gap.
### Why 0.27.3 wasn't enough
`esbuild` ships a Go-built binary inside the `@esbuild/<platform>`
packages. The vulnerability lives in the bundled Go toolchain, not in
any JavaScript. Verified by reading the Go `buildinfo` section from
`node_modules/@esbuild/<platform>/bin/esbuild`:
- `esbuild@0.27.7` → built with **Go 1.23.8**
- `esbuild@0.28.0` → built with **Go 1.26.1**
CVE-2024-24790 (IPv6 zone parsing) is fixed in Go 1.21.11 / 1.22.4, so
0.27.x covers it.
**CVE-2025-68121** (crypto/tls cert validation bypass via TLS session
resumption, **CVSS 10.0 / Critical** per
[NVD](https://nvd.nist.gov/vuln/detail/cve-2025-68121)) is fixed only in
Go 1.24.13, 1.25.7, and 1.26.0-rc.3+. Go 1.23.x is past Go's support
window and will not receive this fix. So `esbuild@0.27.x` still ships a
Go binary that Trivy correctly flags as vulnerable.
### Reachable risk in Twenty
Low. `esbuild` does not use `crypto/tls` at runtime — it reads files,
parses, transforms, and writes. The vulnerable code path is dead code
inside the binary, present but never executed. The scan finding is what
we are clearing, not an exploitation risk.
### Fix
Bump `twenty-client-sdk`'s `esbuild` from `^0.27.3` to `^0.28.0`
(resolves to 0.28.0, built with Go 1.26.1).
### Verification
Ran `yarn workspaces focus --production twenty twenty-server
twenty-emails twenty-shared twenty-client-sdk` (the same install the
Dockerfile uses) and confirmed:
- `node_modules/esbuild/` resolves to `esbuild@0.28.0` (single copy)
- The bundled `node_modules/@esbuild/<platform>/bin/esbuild` binary
reports `go1.26.1` in its `buildinfo`
## Test plan
- [x] `nx typecheck twenty-server` passes
- [x] `nx build twenty-client-sdk` passes (esbuild's `build()` API is
stable across 0.27 → 0.28)
- [x] Production focus install shows Go 1.26.1 in the shipped binary
- [ ] CI green
- [ ] Re-run Trivy against the resulting image; confirm CVE-2025-68121
no longer appears
## Summary
Recovers most of the TTFB the EKS→Cloudflare migration lost on
`twenty.com`. OpenStatus's P50 chart shows the regression clearly: TTFB
went from ~50–80ms (pre-migration, CF edge cache HIT) to ~250–350ms
(post-migration, every request hits Worker → R2 → respond).
## Why the existing Cache Rule stopped working
The zone-level `Twenty Website - Aggressive cache` Cache Rule was
correctly configured and was the reason pre-migration TTFB was low. It
still exists, still has `cache: true`, Edge TTL 1d. But it doesn't apply
to Worker responses on a Worker custom domain:
- **Pre-migration** request flow: `edge → Cache Rule lookup → HIT
(~20ms) / MISS → origin → cache the response`
- **Post-migration**: `edge → Worker runs first (custom domain) → Worker
generates synthetic response from R2 → return`
Cache Rules cache responses obtained via `fetch()` from the Worker, not
synthetic responses constructed inside the Worker. OpenNext for SSG
pages reads prerendered HTML from R2 and returns it — that's synthetic.
So the rule has no insertion point.
This is structural to how CF Workers handle custom domains; not a
misconfiguration on your side.
## The fix
`open-next.config.ts`:
```ts
const incrementalCache = withRegionalCache(r2IncrementalCache, {
mode: 'long-lived',
});
const baseConfig = defineCloudflareConfig({ incrementalCache });
```
OpenNext-native wrapper. The Worker still runs per request (~5–20ms
execution), but the ISR cache lookup goes through CF's per-region Cache
API (~5–20ms) instead of R2 (~50–150ms). For pages whose prerender
doesn't change between requests, that's the bulk of the TTFB recovered.
## Measured impact (live before/after on twenty.com today)
| URL | Before (avg of 3) | After cold (first 2 hits/region) | After
warm |
|---|---|---|---|
| `/` | 322ms | 600–640ms | **110–125ms** |
| `/pricing` | 267ms | 630–690ms | **104–110ms** |
| `/why-twenty` | 250ms | 175–270ms | **100–175ms** |
First 1–2 hits per CF region after this deploys will be slower than
baseline (regional Cache API populating from R2), then it sustains.
Steady state is significantly better than pre-fix.
## What this doesn't recover
Pre-migration `cf-cache-status: HIT` was ~20–30ms because the Worker
wasn't invoked at all. We can't get there without either:
- Moving SSG hosting off the Worker (back to a static origin Cache Rules
would cover)
- OpenNext gaining a "publish responses to caches.default" mode (doesn't
exist today)
Realistic-best on CF Workers + OpenNext is around the ~80–130ms range
we're now seeing.
## Live state
Already deployed to both prod (Version `40dfaa1a-...`) and dev (Version
`b45cc2de-...`) ahead of opening this PR, so the OpenStatus chart should
start improving immediately. This PR makes `main` reflect the change.
## Context
Adds the SDK plumbing for apps to declare custom permission flags and
the server-side manifest pipeline to persist them.
```typescript
import { definePermissionFlag } from 'twenty-sdk/define';
export const MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER = '…';
export default definePermissionFlag({
universalIdentifier: MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER,
key: 'MANAGE_INVOICES',
label: 'Manage Invoices',
description: 'Create, edit, and delete invoices',
icon: 'IconReceipt',
});
```
```typescript
import { defineApplicationRole, SystemPermissionFlag } from 'twenty-sdk/define';
import { MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER } from './permission-flags/manage-invoices';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default function role`,
// ...
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.UPLOAD_FILE,
MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER,
],
});
```
The flag can then be referenced by UUID in a role's
permissionFlagUniversalIdentifiers. On sync, the catalog row lands in
core.permissionFlag and the link in core.rolePermissionFlag.
## Not in this PR
- Runtime permission checks.
PermissionsService.getUserWorkspacePermissions still builds its result
from Object.values(PermissionFlagType), so custom flags are stored but
not yet enforced, code asking "does this role have MANAGE_INVOICES?"
won't get a meaningful answer. Widening PermissionsService and
UserWorkspacePermissions.permissionFlags to support arbitrary flag keys
is the next PR.
- PermissionFlag from apps can only define "tool" permissions and not
"settings" as a permissionType, this parameter is not mutable. This is
because "settings" are for settings page (until we might decide to
separate both type of permissions into 2 different entities) and apps
can't declare settings page or interact with them so this parameter
would be unnecessary.
console.log(`[import] opportunities: ${tftOpps.length} partner-linked of ${tftOppsAll.length} total (skipping ${tftOppsAll.length-tftOpps.length} unlinked)`);
- When defining fields **inline inside `defineObject()`**, you do **not** need `objectUniversalIdentifier` — it's inherited from the parent object.
- `defineField()` is the only way to add fields to objects you didn't create with `defineObject()`.
- File location is up to you. The convention is `src/fields/<name>.field.ts`, but the SDK detects fields anywhere in `src/`.
- To add a tab to a standard page layout (e.g. the Task or Company detail page), use [`definePageLayoutTab`](/developers/extend/apps/layout/page-layouts#definepagelayouttab) with `STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS` from `twenty-sdk/define`.
Front components run browser-side in a sandboxed Web Worker, while [logic functions](/developers/extend/apps/logic/logic-functions) run server-side. There is no direct in-process call between the two — instead, a front component reaches a logic function over HTTP.
A logic function declared with `httpRouteTriggerSettings` is exposed under the `/s/` endpoint at `${TWENTY_API_URL}/s<path>`. Your front component calls that route with `fetch`, authenticating with the `TWENTY_APP_ACCESS_TOKEN` that Twenty injects into the worker.
A small reusable helper keeps the call sites clean:
`TWENTY_API_URL` and `TWENTY_APP_ACCESS_TOKEN` are injected automatically — see [Application variables](#application-variables). Because secret application variables are never exposed to front components, keep API keys and other sensitive logic in the logic function, not in the front component.
</Note>
## Accessing runtime context
Inside your component, use SDK hooks to access the current user, record, and component instance:
- `pageLayoutUniversalIdentifier` is **required** and must point to a page layout that already exists at install time — either a standard Twenty layout or one defined by your own app. Cross-app references to layouts owned by another installed app are not supported today. When the parent layout is missing, installation fails with a clear validation error.
- For standard Twenty layouts, import identifiers from `twenty-sdk/define`:
```ts
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
- `widgets` are scoped to this tab only — they reference [front components](/developers/extend/apps/layout/front-components), views, etc. exactly like widgets defined inline in `definePageLayout`.
- `position` controls ordering against existing tabs on the targeted layout. Pick a value that places your tab where you want it relative to built-in tabs.
- Use this instead of `definePageLayout` when you only want to add to an existing layout. Use `definePageLayout` when you own the entire layout.
- **httpRoute**: Exposes your function on an HTTP path and method **under the `/s/` endpoint**:
> e.g. `path: '/post-card/create'` is callable at `https://your-twenty-server.com/s/post-card/create`
<Note>
To invoke a route-triggered logic function from a (headless) front component, see [Calling a logic function](/developers/extend/apps/layout/front-components#calling-a-logic-function).
</Note>
- **cron**: Runs your function on a schedule using a CRON expression.
- **databaseEvent**: Runs on workspace object lifecycle events. When the event operation is `updated`, specific fields to listen to can be specified in the `updatedFields` array. If left undefined or empty, any update will trigger the function.
* Der Speicherort der Datei liegt bei Ihnen. Die Konvention ist `src/fields/\<name>.field.ts`, aber das SDK erkennt Felder überall in `src/`.
* Um eine Registerkarte zu einem Standard-Seitenlayout hinzuzufügen (z. B. der Aufgaben- oder Unternehmensdetailseite), verwenden Sie [`definePageLayoutTab`](/l/de/developers/extend/apps/layout/page-layouts#definepagelayouttab) mit `STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS` aus `twenty-sdk/define`.
## Hinzufügen einer Relation zu einem bestehenden Objekt
Um ein Relationsfeld hinzuzufügen (z. B. zur Verknüpfung Ihres benutzerdefinierten Objekts mit einer Standard-`Person`), verwenden Sie `defineField()` mit `FieldType.RELATION`. Das Muster ist dasselbe wie bei Inline-Relationen, jedoch mit explizit gesetztem `objectUniversalIdentifier`. Siehe [Relations](/l/de/developers/extend/apps/data/relations) für das bidirektionale Muster.
@@ -44,8 +44,53 @@ Die **Datenebene** einer Twenty-App umfasst die Daten, die Ihre App zu einem Wor
| **Objekt** | Ein neuer benutzerdefinierter Datensatztyp (z. B. PostCard, Invoice) mit eigenen Feldern | `defineObject()` |
| **Feld** | Eine Spalte in einem Objekt. Eigenständige Felder können Objekte erweitern, die Sie nicht erstellt haben (z. B. `loyaltyTier` zu Company hinzufügen) | `defineField()` |
| **Beziehung** | Eine bidirektionale Verknüpfung zwischen zwei Objekten – beide Seiten werden als Felder deklariert | `defineField()` mit `FieldType.RELATION` |
| **Indizes** | Ein Datenbankindex, um eine wiederkehrende Abfrage für eines Ihrer Objekte zu beschleunigen | `defineIndex()` |
Das SDK erkennt diese zur Build-Zeit über eine AST-Analyse, sodass die Dateiorganisation Ihnen überlassen ist – die Konvention ist `src/objects/` und `src/fields/`. Stabile `universalIdentifier`-UUIDs verknüpfen alles über Deploys hinweg.
Das SDK erkennt diese zur Build-Zeit über eine AST-Analyse, sodass die Dateiorganisation Ihnen überlassen ist – die Konvention ist `src/objects/`, `src/fields/` und `src/indexes/`. Stabile `universalIdentifier`-UUIDs verknüpfen alles über Deploys hinweg.
## Indizes (optional)
Apps können Indizes gemeinsam mit ihren Objekten ausliefern, um wiederkehrende Abfragen schnell zu halten. Der häufigste Fall ist eine Status- oder Fremdschlüsselspalte, die Sie häufig lesen.
`defineIndex` akzeptiert `isUnique: true` sowohl für Einspalten- als auch Mehrspalteneindeutigkeit. Dies ist das empfohlene Primitive – `defineField({ isUnique: true })` ist veraltet und wird in einer zukünftigen Version entfernt.
* Partielle `WHERE`-Klauseln bleiben unter Kontrolle der Administratoren – Apps können sie nicht deklarieren.
* Jedes Objekt ist auf 10 benutzerdefinierte Indizes begrenzt (die Indizes des Frameworks selbst werden nicht mitgezählt).
Ordnen Sie das `fields`-Array so an, wie Postgres es verwenden soll – die ganz linke Spalte zuerst, wie in einem Telefonbuch. Indizes sind nicht kostenlos: Jeder Schreibvorgang in die Tabelle aktualisiert sie. Fügen Sie einen nur dann hinzu, wenn Sie eine Abfrage haben, die ihn benötigt.
<Note>
Suchen Sie nach **Application Config** oder **Roles & Permissions**? Diese beschreiben die App selbst und nicht die Daten, die sie hinzufügt – sie befinden sich unter [Config](/l/de/developers/extend/apps/config/overview). Suchen Sie nach **Connections** (Linear, GitHub, Slack OAuth)? Diese existieren, um *von* Logikfunktionen aufgerufen zu werden, und befinden sich unter [Logic](/l/de/developers/extend/apps/logic/connections).
Front-Komponenten laufen browserseitig in einem isolierten Web Worker, während [Logikfunktionen](/l/de/developers/extend/apps/logic/logic-functions) serverseitig ausgeführt werden. Es gibt keinen direkten In-Process-Aufruf zwischen beiden – stattdessen ruft eine Front-Komponente eine Logikfunktion über HTTP auf.
Eine mit `httpRouteTriggerSettings` deklarierte Logikfunktion wird unter dem `/s/`-Endpunkt unter `${TWENTY_API_URL}/s\<path>` bereitgestellt. Ihre Front-Komponente ruft diese Route mit `fetch` auf und authentifiziert sich dabei mit dem `TWENTY_APP_ACCESS_TOKEN`, das Twenty in den Worker injiziert.
Ein kleiner wiederverwendbarer Helper hält die Aufrufstellen übersichtlich:
throw new Error(`Logic function failed (${res.status})`);
}
return res.json();
}
```
Eine headless Front-Komponente kann den Aufruf beim Mounten über die `Command`-Komponente ausführen und sich anschließend automatisch unmounten:
```tsx src/front-components/sync-prs.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/command';
import { callAppRoute } from 'src/shared/call-app-route';
const SyncPrs = () => {
const execute = async () => {
await callAppRoute('/github/fetch-prs', {
owner: 'twentyhq',
repo: 'twenty',
});
};
return <Command execute={execute} />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'sync-prs',
description: 'Triggers the fetch-prs logic function',
isHeadless: true,
component: SyncPrs,
});
```
Der an `callAppRoute` übergebene `path` muss dem `httpRouteTriggerSettings.path` der Logikfunktion entsprechen (das `/s`-Präfix wird vom Helper hinzugefügt):
`TWENTY_API_URL` und `TWENTY_APP_ACCESS_TOKEN` werden automatisch injiziert – siehe [Anwendungsvariablen](#application-variables). Da geheime Anwendungsvariablen niemals in Front-Komponenten offengelegt werden, sollten API-Schlüssel und andere sensible Logik in der Logikfunktion verbleiben und nicht in der Front-Komponente.
</Note>
## Zugriff auf den Laufzeitkontext
Verwenden Sie innerhalb Ihrer Komponente SDK-Hooks, um auf den aktuellen Benutzer, den Datensatz und die Komponenteninstanz zuzugreifen:
* `pageLayoutUniversalIdentifier` ist **erforderlich** und muss auf ein Seitenlayout verweisen, das zum Installationszeitpunkt bereits existiert – entweder ein standardmäßiges Twenty-Layout oder eines, das von Ihrer eigenen App definiert wurde. App-übergreifende Verweise auf Layouts, die einer anderen installierten App gehören, werden derzeit nicht unterstützt. Wenn das übergeordnete Layout fehlt, schlägt die Installation mit einem eindeutigen Validierungsfehler fehl.
* Für Standard-Twenty-Layouts importieren Sie die Bezeichner aus `twenty-sdk/define`:
```ts
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
* `widgets` sind ausschließlich auf diesen Tab beschränkt – sie verweisen auf [Frontend-Komponenten](/l/de/developers/extend/apps/layout/front-components), Ansichten usw., genau wie Widgets, die inline in `definePageLayout` definiert sind.
* `position` steuert die Reihenfolge im Zielseitenlayout relativ zu den vorhandenen Registerkarten. Wählen Sie einen Wert, der Ihre Registerkarte relativ zu integrierten Registerkarten an die gewünschte Position bringt.
* Verwenden Sie dies anstelle von `definePageLayout`, wenn Sie einem vorhandenen Layout nur etwas hinzufügen möchten. Verwenden Sie `definePageLayout`, wenn Sie das gesamte Layout besitzen.
* **httpRoute**: Stellt Ihre Funktion unter einem HTTP-Pfad und einer Methode **unter dem Endpunkt `/s/`** bereit:
> z. B. `path: '/post-card/create'` ist unter `https://your-twenty-server.com/s/post-card/create` aufrufbar
<Note>
Um eine routenausgelöste Logikfunktion von einer (headless) Front-Komponente aus aufzurufen, siehe [Aufrufen einer Logikfunktion](/l/de/developers/extend/apps/layout/front-components#calling-a-logic-function).
</Note>
* **cron**: Führt Ihre Funktion nach Zeitplan mithilfe eines CRON-Ausdrucks aus.
* **databaseEvent**: Wird bei Lebenszyklusereignissen von Workspace-Objekten ausgeführt. Wenn die Ereignisoperation `updated` ist, können bestimmte zu überwachende Felder im Array `updatedFields` angegeben werden. Wenn das Array undefiniert oder leer ist, löst jede Aktualisierung die Funktion aus.
> z. B. `person.updated`, `*.created`, `company.*`
@@ -101,6 +101,10 @@ Machen Sie ein Feld einzigartig, um sicherzustellen, dass sich keine verschieden
Wenn beim Einstellen der Einzigartigkeit ein Fehler auftritt, überprüfen Sie auf doppelte Werte in Ihren Daten (einschließlich gelöschter Datensätze).
## Indizes (Erweitert)
Datenbankindizes werden automatisch verwaltet – eigene hinzuzufügen ist selten erforderlich und kann leicht schiefgehen. Wenn der Erweiterte Modus aktiviert ist, hat jedes Objekt unter `Einstellungen → Datenmodell → <object>` einen Abschnitt **Indizes** für die Fälle, in denen du weißt, dass du einen brauchst.
* A localização do arquivo fica a seu critério. A convenção é `src/fields/\<name>.field.ts`, mas o SDK detecta campos em qualquer lugar dentro de `src/`.
* Para adicionar uma aba a um layout de página padrão (por exemplo, a página de detalhes de Task ou Company), use [`definePageLayoutTab`](/l/pt/developers/extend/apps/layout/page-layouts#definepagelayouttab) com `STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS` de `twenty-sdk/define`.
## Adicionando uma relação a um objeto existente
Para adicionar um campo de relação (por exemplo, vinculando seu objeto personalizado a um `Person` padrão), use `defineField()` com `FieldType.RELATION`. O padrão é o mesmo que para relações inline, mas com `objectUniversalIdentifier` definido explicitamente. Veja [Relações](/l/pt/developers/extend/apps/data/relations) para o padrão bidirecional.
@@ -44,8 +44,53 @@ A **camada de dados** de um app Twenty é o conjunto de dados que seu app *adici
| **Objeto** | Um novo tipo de registro personalizado (por exemplo, PostCard, Invoice) com seus próprios campos | `defineObject()` |
| **Campo** | Uma coluna em um objeto. Campos independentes podem estender objetos que você não criou (por exemplo, adicionar `loyaltyTier` ao objeto Company) | `defineField()` |
| **Relação** | Um vínculo bidirecional entre dois objetos — ambos os lados declarados como campos | `defineField()` com `FieldType.RELATION` |
| **Índice** | Um índice de banco de dados para acelerar uma consulta recorrente em um dos seus objetos | `defineIndex()` |
O SDK detecta esses elementos por meio de análise de AST em tempo de build, então a organização dos arquivos fica a seu critério — a convenção é `src/objects/` e `src/fields/`. UUIDs `universalIdentifier` estáveis conectam tudo em implantações diferentes.
O SDK detecta esses elementos por meio de análise de AST em tempo de build, então a organização dos arquivos fica a seu critério — a convenção é `src/objects/`, `src/fields/` e `src/indexes/`. UUIDs `universalIdentifier` estáveis conectam tudo em implantações diferentes.
## Índices (Opcional)
Os apps podem incluir índices junto com seus objetos para manter rápidas as consultas recorrentes. O caso mais comum é uma coluna de status ou de chave estrangeira que você lê com frequência.
`defineIndex` aceita `isUnique: true` tanto para unicidade de uma única coluna quanto de múltiplas colunas. Este é o recurso recomendado — `defineField({ isUnique: true })` está obsoleto e será removido em uma versão futura.
* Cláusulas `WHERE` parciais permanecem sob controle do administrador — os apps não podem declará-las.
* Cada objeto é limitado a 10 índices personalizados (os índices do próprio framework não contam).
Ordene o array `fields` da forma como o Postgres deve usá-lo — coluna mais à esquerda primeiro, como em uma lista telefônica. Índices não são gratuitos: cada gravação na tabela os atualiza. Adicione um apenas quando você tiver uma consulta que precise dele.
<Note>
Procurando por **Application Config** ou **Roles & Permissions**? Esses descrevem o próprio app em vez dos dados que ele adiciona — eles ficam em [Config](/l/pt/developers/extend/apps/config/overview). Procurando por **Connections** (Linear, GitHub, Slack OAuth)? Essas existem para serem chamadas *a partir de* funções de lógica e ficam em [Logic](/l/pt/developers/extend/apps/logic/connections).
Os componentes de front são executados no navegador em um Web Worker isolado, enquanto as [funções lógicas](/l/pt/developers/extend/apps/logic/logic-functions) são executadas no servidor. Não há chamada direta no mesmo processo entre os dois — em vez disso, um componente de front acessa uma função lógica via HTTP.
Uma função lógica declarada com `httpRouteTriggerSettings` é exposta sob o endpoint `/s/` em `${TWENTY_API_URL}/s\<path>`. Seu componente de front chama essa rota com `fetch`, autenticando com o `TWENTY_APP_ACCESS_TOKEN` que Twenty injeta no worker.
Um pequeno helper reutilizável mantém os locais de chamada limpos:
`TWENTY_API_URL` e `TWENTY_APP_ACCESS_TOKEN` são injetados automaticamente — consulte [Variáveis de aplicação](#application-variables). Como as variáveis de aplicação secretas nunca são expostas aos componentes de front, mantenha as chaves de API e outra lógica sensível na função lógica, não no componente de front.
</Note>
## Acessando o contexto de execução
Dentro do seu componente, use hooks do SDK para acessar o usuário atual, o registro e a instância do componente:
* `pageLayoutUniversalIdentifier` é **obrigatório** e deve apontar para um layout de página que já exista no momento da instalação — seja um layout padrão da Twenty ou um definido pelo seu próprio aplicativo. Referências entre aplicativos para layouts pertencentes a outro aplicativo instalado não são compatíveis atualmente. Quando o layout pai estiver ausente, a instalação falha com um erro de validação claro.
* Para layouts padrão do Twenty, importe identificadores de `twenty-sdk/define`:
```ts
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
* `widgets` têm escopo apenas para esta aba — eles referenciam [front components](/l/pt/developers/extend/apps/layout/front-components), visualizações etc., exatamente como widgets definidos inline em `definePageLayout`.
* `position` controla a ordenação em relação às abas existentes no layout de destino. Escolha um valor que posicione sua aba onde você deseja em relação às abas nativas.
* Use isto em vez de `definePageLayout` quando você quiser apenas adicionar a um layout existente. Use `definePageLayout` quando você possuir todo o layout.
* **httpRoute**: Expõe sua função em um caminho e método HTTP **no endpoint `/s/`**:
> por exemplo, `path: '/post-card/create'` é acessível em `https://your-twenty-server.com/s/post-card/create`
<Note>
Para invocar uma função de lógica acionada por rota a partir de um componente de front-end (headless), consulte [Chamando uma função de lógica](/l/pt/developers/extend/apps/layout/front-components#calling-a-logic-function).
</Note>
* **cron**: Executa sua função em um agendamento usando uma expressão CRON.
* **databaseEvent**: Executa em eventos do ciclo de vida de objetos do espaço de trabalho. Quando a operação do evento é `updated`, campos específicos a serem observados podem ser especificados no array `updatedFields`. Se deixar indefinido ou vazio, qualquer atualização acionará a função.
> por exemplo, `person.updated`, `*.created`, `company.*`
@@ -101,6 +101,10 @@ Torne um campo único para garantir que registros distintos não possam ter o me
Se você receber um erro ao definir exclusividade, verifique se há valores duplicados nos seus dados (incluindo registros excluídos).
## Índices (Avançado)
Os índices do banco de dados são gerenciados automaticamente — adicionar os seus próprios raramente é necessário e é fácil cometer erros. Com o modo Avançado ativado, cada objeto tem uma seção **Índices** em `Settings → Data Model → <object>` para os casos em que você sabe que precisa de um índice.
* Locația fișierului depinde de dumneavoastră. Convenția este `src/fields/\<name>.field.ts`, dar SDK-ul detectează câmpuri oriunde în `src/`.
* Pentru a adăuga o filă într-un layout standard de pagină (de ex. pagina de detalii pentru Task sau Company), folosește [`definePageLayoutTab`](/l/ro/developers/extend/apps/layout/page-layouts#definepagelayouttab) cu `STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS` din `twenty-sdk/define`.
## Adăugarea unei relații la un obiect existent
Pentru a adăuga un câmp de tip relație (de ex. pentru a lega obiectul personalizat de un `Person` standard), folosiți `defineField()` cu `FieldType.RELATION`. Modelul este același ca pentru relațiile inline, dar cu `objectUniversalIdentifier` setat explicit. Consultați [Relații](/l/ro/developers/extend/apps/data/relations) pentru modelul bidirecțional.
@@ -44,8 +44,53 @@ Stratul de **date** al unei aplicații Twenty reprezintă datele pe care aplica
| **Obiect** | Un nou tip de înregistrare personalizat (de ex. PostCard, Invoice) cu propriile sale câmpuri | `defineObject()` |
| **Câmp** | O coloană pe un obiect. Câmpurile independente pot extinde obiecte pe care nu le-ați creat (de ex. adăugați `loyaltyTier` la Company) | `defineField()` |
| **Relație** | O legătură bidirecțională între două obiecte — ambele părți declarate ca câmpuri | `defineField()` cu `FieldType.RELATION` |
| **Indice** | Un indice de bază de date pentru a accelera o interogare recurentă asupra unuia dintre obiectele tale | `defineIndex()` |
SDK-ul detectează acestea prin analiza AST la momentul build-ului, astfel încât organizarea fișierelor ține de dumneavoastră — convenția este `src/objects/` și `src/fields/`. UUID-urile stabile `universalIdentifier` leagă totul în toate implementările.
SDK-ul detectează acestea prin analiza AST la momentul build-ului, astfel încât organizarea fișierelor ține de tine — convenția este `src/objects/`, `src/fields/` și `src/indexes/`. UUID-urile stabile `universalIdentifier` leagă totul în toate implementările.
## Indici (opțional)
Aplicațiile pot livra indici împreună cu obiectele lor pentru a menține rapide interogările recurente. Cel mai comun caz este o coloană de status sau o coloană cu cheie străină pe care o citești frecvent.
`defineIndex` acceptă `isUnique: true` atât pentru unicitatea pe o singură coloană, cât și pe mai multe coloane. Aceasta este primitiva recomandată — `defineField({ isUnique: true })` este învechită (deprecated) și va fi eliminată într-o versiune viitoare.
* Clauzele `WHERE` parțiale rămân sub controlul administratorului — aplicațiile nu le pot declara.
* Fiecare obiect este limitat la 10 indici personalizați (indicii proprii ai framework-ului nu se pun la socoteală).
Ordonează array-ul `fields` în modul în care Postgres ar trebui să îl folosească — coloana din stânga prima, ca într-o agendă telefonică. Indicii nu sunt gratuiți: fiecare scriere în tabel îi actualizează. Adaugă unul doar atunci când ai o interogare care are nevoie de el.
<Note>
Căutați **Application Config** sau **Roles & Permissions**? Acestea descriu aplicația în sine, mai degrabă decât datele pe care le adaugă — se află la [Config](/l/ro/developers/extend/apps/config/overview). Căutați **Connections** (Linear, GitHub, Slack OAuth)? Acestea există pentru a fi apelate *din* funcții de logică și se află la [Logic](/l/ro/developers/extend/apps/logic/connections).
Componentele de front rulează în browser într-un Web Worker izolat, în timp ce [funcțiile logice](/l/ro/developers/extend/apps/logic/logic-functions) rulează pe server. Nu există un apel direct în același proces între cele două — în schimb, o componentă de front apelează o funcție logică prin HTTP.
O funcție logică declarată cu `httpRouteTriggerSettings` este expusă sub endpoint-ul `/s/` la `${TWENTY_API_URL}/s\<path>`. Componenta ta de front apelează acea rută cu `fetch`, autentificându-se cu `TWENTY_APP_ACCESS_TOKEN` pe care Twenty îl injectează în worker.
Un mic utilitar reutilizabil menține locurile de apel curate:
throw new Error(`Logic function failed (${res.status})`);
}
return res.json();
}
```
O componentă de front headless poate efectua apelul la montare prin componenta `Command`, apoi se demontează automat:
```tsx src/front-components/sync-prs.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/command';
import { callAppRoute } from 'src/shared/call-app-route';
const SyncPrs = () => {
const execute = async () => {
await callAppRoute('/github/fetch-prs', {
owner: 'twentyhq',
repo: 'twenty',
});
};
return <Command execute={execute} />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'sync-prs',
description: 'Triggers the fetch-prs logic function',
isHeadless: true,
component: SyncPrs,
});
```
`path` transmis către `callAppRoute` trebuie să corespundă cu `httpRouteTriggerSettings.path` al funcției logice (prefixul `/s` este adăugat de utilitar):
`TWENTY_API_URL` și `TWENTY_APP_ACCESS_TOKEN` sunt injectate automat — vezi [Application variables](#application-variables). Deoarece variabilele de aplicație secrete nu sunt niciodată expuse componentelor de front, păstrează cheile API și altă logică sensibilă în funcția logică, nu în componenta de front.
</Note>
## Accesarea contextului de rulare
În interiorul componentei, folosiți hook-urile SDK pentru a accesa utilizatorul curent, înregistrarea curentă și instanța componentei:
* `pageLayoutUniversalIdentifier` este **obligatoriu** și trebuie să indice către un layout de pagină care există deja la momentul instalării — fie un layout standard Twenty, fie unul definit de propria ta aplicație. Referințele cross-app către layouturi deținute de o altă aplicație instalată nu sunt acceptate în prezent. Când lipsește layoutul părinte, instalarea eșuează cu o eroare clară de validare.
* Pentru layout-urile standard Twenty, importați identificatorii din `twenty-sdk/define`:
```ts
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
* `widgets` sunt limitate doar la această filă — fac referire la [front components](/l/ro/developers/extend/apps/layout/front-components), vizualizări etc., exact ca widgeturile definite inline în `definePageLayout`.
* `position` controlează ordonarea în raport cu filele existente din layoutul țintă. Alege o valoare care să plaseze fila ta acolo unde dorești, relativ la filele predefinite.
* Folosește aceasta în loc de `definePageLayout` atunci când vrei doar să adaugi la un layout existent. Folosește `definePageLayout` atunci când deții întregul layout.
* **httpRoute**: Expune funcția pe o cale și metodă HTTP **sub endpoint-ul `/s/`**:
> de ex. `path: '/post-card/create'` este apelabil la `https://your-twenty-server.com/s/post-card/create`
<Note>
Pentru a apela o funcție logică declanșată de o rută dintr-o componentă front-end (headless), consultă [Apelarea unei funcții logice](/l/ro/developers/extend/apps/layout/front-components#calling-a-logic-function).
</Note>
* **cron**: Rulează funcția pe un program folosind o expresie CRON.
* **databaseEvent**: Rulează la evenimentele ciclului de viață ale obiectelor din spațiul de lucru. Când operațiunea evenimentului este `updated`, câmpurile specifice de urmărit pot fi specificate în array-ul `updatedFields`. Dacă este lăsat nedefinit sau gol, orice actualizare va declanșa funcția.
> de ex. `person.updated`, `*.created`, `company.*`
@@ -101,6 +101,10 @@ Faceți un câmp unic pentru a vă asigura că înregistrările distincte nu pot
Dacă primiți o eroare când setați unicitatea, verificați prezența valorilor duplicate în datele dumneavoastră (inclusiv în cele șterse).
## Indexuri (Avansat)
Indexurile bazei de date sunt gestionate automat — adăugarea unor indexuri proprii este rareori necesară și se greșește ușor. Cu modul Avansat activat, fiecare obiect are o secțiune **Indexuri** sub `Settings → Data Model → <object>` pentru cazurile în care știi că ai nevoie de unul.
## Cele mai bune practici pentru configurarea câmpurilor
* Расположение файла зависит от вас. Принятое соглашение — `src/fields/\<name>.field.ts`, но SDK обнаруживает поля в любом месте внутри `src/`.
* Чтобы добавить вкладку в стандартную компоновку страницы (например, на страницу сведений о задаче или компании), используйте [`definePageLayoutTab`](/l/ru/developers/extend/apps/layout/page-layouts#definepagelayouttab) с `STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS` из `twenty-sdk/define`.
## Добавление связи к существующему объекту
Чтобы добавить поле связи (например, связать ваш пользовательский объект со стандартным `Person`), используйте `defineField()` с `FieldType.RELATION`. Шаблон тот же, что и для встроенных связей, но с явным указанием `objectUniversalIdentifier`. Смотрите раздел [Relations](/l/ru/developers/extend/apps/data/relations) для двунаправленного шаблона.
| **Объект** | Новый пользовательский тип записей (например, PostCard, Invoice) с собственными полями | `defineObject()` |
| **Поле** | Столбец в объекте. Отдельные поля могут расширять объекты, которые вы не создавали (например, добавьте `loyaltyTier` к объекту Company) | `defineField()` |
| **Связь** | Двусторонняя связь между двумя объектами — обе стороны объявлены как поля | `defineField()` с `FieldType.RELATION` |
| **Индекс** | Индекс базы данных для ускорения повторяющегося запроса к одному из ваших объектов | `defineIndex()` |
SDK обнаруживает их с помощью анализа AST во время сборки, поэтому организация файлов остается на ваше усмотрение — по соглашению используются `src/objects/` и `src/fields/`. Стабильные UUID `universalIdentifier` связывают все воедино между развертываниями.
SDK обнаруживает их с помощью анализа AST во время сборки, поэтому организация файлов остается на ваше усмотрение — по соглашению используются `src/objects/`, `src/fields/` и `src/indexes/`. Стабильные UUID `universalIdentifier` связывают все воедино между развертываниями.
## Индексы (необязательно)
Приложения могут поставлять индексы вместе со своими объектами, чтобы повторяющиеся запросы выполнялись быстро. Наиболее распространенный случай — столбец статуса или внешнего ключа, к которому вы часто обращаетесь при чтении.
`defineIndex` принимает `isUnique: true` как для уникальности по одному столбцу, так и по нескольким столбцам. Это рекомендуемый примитив — `defineField({ isUnique: true })` устарел и будет удален в одном из будущих релизов.
* Частичные предложения `WHERE` остаются под контролем администратора — приложения не могут объявлять их.
* Для каждого объекта допускается не более 10 пользовательских индексов (индексы самого фреймворка не учитываются).
Упорядочьте массив `fields` в том порядке, в котором Postgres должен его использовать — сначала самый левый столбец, как в телефонной книге. Индексы не бесплатны: при каждой записи в таблицу они обновляются. Добавляйте индекс только тогда, когда у вас есть запрос, которому он действительно нужен.
<Note>
Ищете **Application Config** или **Roles & Permissions**? Они описывают само приложение, а не данные, которые оно добавляет, — их можно найти в разделе [Config](/l/ru/developers/extend/apps/config/overview). Ищете **Connections** (Linear, GitHub, Slack OAuth)? Они существуют для вызова *из* логических функций и находятся в разделе [Logic](/l/ru/developers/extend/apps/logic/connections).
`RECORD_SELECTION` уже подразумевает, что есть выбранные записи — используйте `numberOfSelectedRecords` только для конкретных количеств (например, `>= 2`).
Front-компоненты выполняются в браузере в изолированном Web Worker, в то время как [логические функции](/l/ru/developers/extend/apps/logic/logic-functions) выполняются на стороне сервера. Между ними нет прямого внутрипроцессного вызова — вместо этого front-компонент обращается к логической функции по HTTP.
Логическая функция, объявленная с `httpRouteTriggerSettings`, доступна по эндпоинту `/s/` по адресу `${TWENTY_API_URL}/s\<path>`. Ваш front-компонент вызывает этот маршрут с помощью `fetch`, аутентифицируясь с использованием `TWENTY_APP_ACCESS_TOKEN`, который Twenty внедряет в worker.
Небольшой переиспользуемый хелпер делает места вызова чище:
`TWENTY_API_URL` и `TWENTY_APP_ACCESS_TOKEN` внедряются автоматически — см. [переменные приложения](#application-variables). Поскольку секретные переменные приложения никогда не раскрываются front-компонентам, храните ключи API и другую конфиденциальную логику в логической функции, а не во front-компоненте.
</Note>
## Доступ к контексту времени выполнения
Внутри вашего компонента используйте хуки SDK для доступа к текущему пользователю, записи и экземпляру компонента:
* `pageLayoutUniversalIdentifier` является **обязательным** и должен указывать на макет страницы, который уже существует на момент установки — либо стандартный макет Twenty, либо определённый вашим собственным приложением. Кросс-приложенческие ссылки на макеты, которыми владеет другое установленное приложение, на данный момент не поддерживаются. Если родительский макет отсутствует, установка завершается с понятной ошибкой проверки.
* Для стандартных макетов Twenty импортируйте идентификаторы из `twenty-sdk/define`:
```ts
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
* `widgets` ограничены только этой вкладкой — они ссылаются на [front components](/l/ru/developers/extend/apps/layout/front-components), представления и т. п. точно так же, как виджеты, определённые непосредственно в `definePageLayout`.
* `position` управляет порядком относительно существующих вкладок в целевом макете. Выберите значение, которое поместит вашу вкладку в нужное место относительно встроенных вкладок.
* Используйте это вместо `definePageLayout`, когда вы хотите только добавить к существующему макету. Используйте `definePageLayout`, когда вы управляете всем макетом.
* **httpRoute**: Публикует вашу функцию по HTTP-пути и методу **под конечной точкой `/s/`**:
> например, `path: '/post-card/create'` вызывается по адресу `https://your-twenty-server.com/s/post-card/create`
<Note>
Чтобы вызвать логическую функцию, запускаемую маршрутом, из фронтенд-компонента (без интерфейса), см. раздел [Вызов логической функции](/l/ru/developers/extend/apps/layout/front-components#calling-a-logic-function).
</Note>
* **cron**: Запускает вашу функцию по расписанию с использованием выражения CRON.
* **databaseEvent**: Запускается при событиях жизненного цикла объектов рабочего пространства. Когда операция события — `updated`, можно указать конкретные поля для отслеживания в массиве `updatedFields`. Если оставить не заданным или пустым, любое обновление будет вызывать функцию.
> например, `person.updated`, `*.created`, `company.*`
@@ -101,6 +101,10 @@ Twenty поддерживает различные типы полей:
Если вы получаете ошибку при установке уникальности, проверьте дублирующиеся значения в ваших данных (включая удаленные записи).
## Индексы (расширенный режим)
Индексы базы данных управляются автоматически — добавлять собственные почти никогда не требуется и при этом легко допустить ошибку. При включенном расширенном режиме у каждого объекта есть раздел **Индексы** в `Settings → Data Model → <object>` для случаев, когда вы знаете, что вам нужен индекс.
* Dosya konumu size bağlıdır. Genel kabul gören yapı `src/fields/\<name>.field.ts` şeklindedir, ancak SDK `src/` içinde herhangi bir yerdeki alanları algılar.
* Standart bir sayfa yerleşimine (örneğin, Görev veya Şirket detay sayfası) bir sekme eklemek için, `twenty-sdk/define` içindeki `STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS` ile birlikte [`definePageLayoutTab`](/l/tr/developers/extend/apps/layout/page-layouts#definepagelayouttab) kullanın.
## Mevcut bir nesneye ilişki ekleme
Bir ilişki alanı eklemek için (örneğin özel nesnenizi standart bir `Person` nesnesine bağlamak), `FieldType.RELATION` ile `defineField()` kullanın. Desen, satır içi ilişkilerle aynıdır ancak `objectUniversalIdentifier` açıkça ayarlanır. Çift yönlü desen için [Relations](/l/tr/developers/extend/apps/data/relations) bölümüne bakın.
@@ -44,8 +44,53 @@ Bir Twenty uygulamasının **veri katmanı**, uygulamanızın bir çalışma ala
| **Nesne** | Kendi alanlarına sahip yeni bir özel kayıt türü (ör. PostCard, Invoice) | `defineObject()` |
| **Alan** | Bir nesne üzerindeki sütun. Bağımsız alanlar, oluşturmadığınız nesneleri genişletebilir (ör. Company nesnesine `loyaltyTier` ekleyin) | `defineField()` |
| **İlişki** | İki nesne arasında, her iki tarafı da alan olarak bildirilmiş çift yönlü bir bağlantı | `defineField()` ile `FieldType.RELATION` |
| **Dizin** | Nesnelerinizden biri üzerinde yinelenen bir sorguyu hızlandırmak için bir veritabanı dizini | `defineIndex()` |
SDK bunları derleme zamanında AST analiziyle algılar, bu yüzden dosya organizasyonu size kalmıştır — kullanılan gelenek `src/objects/` ve `src/fields/` dizinleridir. Kararlı `universalIdentifier` UUID’leri, dağıtımlar arasında her şeyi birbirine bağlar.
SDK bunları derleme zamanında AST analiziyle algılar, bu yüzden dosya organizasyonu size kalmıştır — kullanılan gelenek `src/objects/`, `src/fields/` ve `src/indexes/` dizinleridir. Kararlı `universalIdentifier` UUID’leri, dağıtımlar arasında her şeyi birbirine bağlar.
## Dizinler (İsteğe bağlı)
Uygulamalar, yinelenen sorguları hızlı tutmak için nesneleriyle birlikte dizinler sunabilir. En yaygın durum, sık okuduğunuz bir durum ya da yabancı anahtar sütunudur.
`defineIndex`, hem tek sütunlu hem çok sütunlu benzersizlik için `isUnique: true` kabul eder. Önerilen yöntem budur — `defineField({ isUnique: true })` kullanımdan kaldırılmıştır ve gelecekteki bir sürümde kaldırılacaktır.
* Kısmi `WHERE` koşulları yönetici kontrolü altında kalır — uygulamalar bunları tanımlayamaz.
* Her nesne, 10 özel dizin ile sınırlandırılmıştır (framework'ün kendi dizinleri buna dahil değildir).
`fields` dizisini, Postgres'in kullanması gereken şekilde sıralayın — en soldaki sütun ilk, bir telefon rehberinde olduğu gibi. Dizinler bedava değildir: tabloya yapılan her yazma işlemi bunları günceller. Bir dizini yalnızca ona ihtiyaç duyan bir sorgunuz olduğunda ekleyin.
<Note>
**Application Config** veya **Roles & Permissions** mı arıyorsunuz? Bunlar, ekledikleri verilerden çok uygulamanın kendisini tanımlar — [Config](/l/tr/developers/extend/apps/config/overview) altında bulunurlar. **Connections** (Linear, GitHub, Slack OAuth) mı arıyorsunuz? Bunlar, mantık fonksiyonları *içinden* çağrılmak için vardır ve [Logic](/l/tr/developers/extend/apps/logic/connections) altında bulunurlar.
`RECORD_SELECTION` zaten boş olmayan bir seçim anlamına gelir — `numberOfSelectedRecords` ifadesini yalnızca belirli sayılar için kullanın (örneğin `>= 2`).
Ön bileşenler, tarayıcı tarafında izole bir Web Worker içinde çalışırken, [mantık işlevleri](/l/tr/developers/extend/apps/logic/logic-functions) sunucu tarafında çalışır. İkisi arasında doğrudan, işlem içi bir çağrı yoktur — bunun yerine, bir ön bileşen bir mantık işlevine HTTP üzerinden erişir.
`httpRouteTriggerSettings` ile bildirilen bir mantık işlevi, `${TWENTY_API_URL}/s\<path>` altındaki `/s/` uç noktasında sunulur. Ön bileşeniniz bu rotayı, Twenty tarafından Worker'a enjekte edilen `TWENTY_APP_ACCESS_TOKEN` ile kimlik doğrulayarak `fetch` ile çağırır.
Küçük, yeniden kullanılabilir bir yardımcı işlev çağrı noktalarını sade tutar:
throw new Error(`Logic function failed (${res.status})`);
}
return res.json();
}
```
Başsız bir ön bileşen, çağrıyı `Command` bileşeni aracılığıyla mount sırasında çalıştırabilir ve ardından otomatik olarak unmount olabilir:
```tsx src/front-components/sync-prs.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/command';
import { callAppRoute } from 'src/shared/call-app-route';
const SyncPrs = () => {
const execute = async () => {
await callAppRoute('/github/fetch-prs', {
owner: 'twentyhq',
repo: 'twenty',
});
};
return <Command execute={execute} />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'sync-prs',
description: 'Triggers the fetch-prs logic function',
isHeadless: true,
component: SyncPrs,
});
```
`callAppRoute` fonksiyonuna geçirilen `path`, mantık işlevinin `httpRouteTriggerSettings.path` değeriyle eşleşmelidir (`/s` öneki yardımcı işlev tarafından eklenir):
`TWENTY_API_URL` ve `TWENTY_APP_ACCESS_TOKEN` otomatik olarak enjekte edilir — bkz. [Uygulama değişkenleri](#application-variables). Gizli uygulama değişkenleri asla ön bileşenlere açığa çıkarılmadığından, API anahtarlarını ve diğer hassas mantığı ön bileşende değil, mantık işlevinin içinde tutun.
</Note>
## Çalışma zamanı bağlamına erişme
Bileşeninizin içinde, geçerli kullanıcıya, kayda ve bileşen örneğine erişmek için SDK hook'larını kullanın:
* `pageLayoutUniversalIdentifier` **zorunludur** ve kurulum anında zaten var olan bir sayfa düzenini işaret etmelidir — standart bir Twenty düzeni veya kendi uygulamanız tarafından tanımlanan bir düzen olabilir. Başka yüklü bir uygulamaya ait düzenlere uygulamalar arası referanslar bugün desteklenmemektedir. Üst düzen eksik olduğunda, kurulum net bir doğrulama hatasıyla başarısız olur.
* Standart Twenty yerleşimleri için tanımlayıcıları `twenty-sdk/define` içinden içe aktarın:
```ts
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
* `widgets` yalnızca bu sekmeyle sınırlıdır — satır içi olarak `definePageLayout` içinde tanımlanan widget'larda olduğu gibi, [ön uç bileşenlerine](/l/tr/developers/extend/apps/layout/front-components), görünümlere vb. tam olarak aynı şekilde referans verirler.
* `position`, hedeflenen düzende mevcut sekmelere göre sıralamayı kontrol eder. Yerleşik sekmelere göre sekmenizi istediğiniz konuma yerleştirecek bir değer seçin.
* Yalnızca mevcut bir düzene ekleme yapmak istediğinizde `definePageLayout` yerine bunu kullanın. Tüm düzene sahip olduğunuzda `definePageLayout` kullanın.
Arayüzsüz bir ön uç bileşeninden rota tarafından tetiklenen mantık fonksiyonunu çağırmak için bkz. [Mantık fonksiyonu çağırma](/l/tr/developers/extend/apps/layout/front-components#calling-a-logic-function).
</Note>
* **cron**: Bir CRON ifadesi kullanarak işlevinizi bir zamanlamayla çalıştırır.
* **databaseEvent**: Çalışma alanı nesnesi yaşam döngüsü olaylarında çalışır. Olay işlemi `updated` olduğunda, dinlenecek belirli alanlar `updatedFields` dizisinde belirtilebilir. Tanımsız veya boş bırakılırsa, herhangi bir güncelleme işlevi tetikler.
@@ -101,6 +101,10 @@ Farklı kayıtların aynı değere sahip olamaması için bir alanı benzersiz y
Benzersizliği ayarlarken bir hata alırsanız, verilerinizde (silinen kayıtlar dahil) yinelenen değerleri kontrol edin.
## Dizinler (Gelişmiş)
Veritabanı dizinleri otomatik olarak yönetilir — kendi dizinlerinizi eklemeniz nadiren gereklidir ve yanlış yapmak da kolaydır. Gelişmiş mod açıkken, bir dizine ihtiyacınız olduğunu bildiğiniz durumlar için her nesnenin `Ayarlar → Veri Modeli → <object>` altında bir **Dizinler** bölümü bulunur.
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.