Commit Graph
15532 Commits
Author SHA1 Message Date
Weiko f0492f94cf Share CSV export cleanup job options 2026-09-15 18:18:23 +02:00
Weiko bf28af8cb9 Merge branch 'c-csv-export-shared' into c-async-csv-export-server
# Conflicts:
#	packages/twenty-client-sdk/src/metadata/generated/types.ts
2026-09-15 17:48:45 +02:00
Weiko 79f00bbd06 Merge commit 'ef6afd5a20' into c-csv-export-shared 2026-09-15 17:47:54 +02:00
Weiko 5f0faa94b7 Cover CSV export column mapping edge cases 2026-09-15 17:40:54 +02:00
Weiko e0ac181852 Merge branch 'c-csv-export-shared' into c-async-csv-export-server 2026-09-15 17:37:16 +02:00
Weiko 75175ff555 Merge remote-tracking branch 'origin/main' into c-csv-export-shared 2026-09-15 17:37:12 +02:00
Abdul Rahman ef6afd5a20 Let runAgent messages carry file attachments (#25835)
Follow-up to #25695, and the platform half of
https://github.com/twentyhq/core-team-issues/issues/2895. Not Slack
specific: it affects every app that calls `runAgent`.

## Why

The in-app chat already gets files to a model.
`buildFilePartsFromAttachments` validates ids against
`FileFolder.AgentChat`, `loadMessagesFromDB` swaps in a signed URL, and
`convertToModelMessages` emits a file part.

An app cannot reach that path for one reason: `RunAgentMessage` is
`{role, content: string}`, and `agent-async-executor.service.ts` mapped
it straight through. So an app could tell an agent a screenshot exists
and nothing more.

Apps can already write the bytes. `createFileUpload` /
`completeFileUpload` are `@MetadataResolver()` mutations, which is where
the SDK already sends everything, and `permissions.service.ts` has an
application-token branch that resolves the app's default role, so an app
declaring `UPLOAD_FILE` can upload into `agent-chat` today. Only the
message contract was missing.

## What

- `RunAgentMessage` gains an optional `attachments` list of `{fileId,
filename?}`, with a matching GraphQL input.
- `RunAgentAttachmentService` resolves each fileId against uploaded
files in the caller's workspace under `agent-chat`, signs each distinct
file once, and emits `{type: 'file', data, mediaType, filename}` parts
alongside the text. The shape mirrors what `convertToModelMessages`
produces for chat, including for images.
- The executor builds its messages through that service.

No SDK change was needed: `runAgent` forwards the input object and the
mutation does not enumerate input fields.

## Behaviour worth reviewing

- **A message with no attachments still reaches the model as a bare
string**, not a one-element parts array. This path runs for every app
and workflow agent, and widening it must not perturb prompt caching or
model behaviour for callers who never asked for files. There is a test
pinning this.
- **An unresolvable fileId throws** rather than quietly answering
without the file. Chat filters silently, but for an API an app calls, a
dropped attachment that nobody reports is much harder to debug, and a
caller that wants to degrade can catch and retry without it.
- **Attachments on an assistant message throw.** They cannot be
represented in an assistant model message, so accepting and dropping
them would be the same trap.
- Capped at 10 attachments per message and 255 characters of filename,
enforced in the resolution path.

Resolution stays workspace-scoped and folder-scoped, and only `UPLOADED`
files resolve, so a fileId referenced before `completeFileUpload` fails
here rather than producing a URL that 404s inside the provider.

## A pre-existing gap this surfaced

`AgentRunResolver` installs no `ResolverValidationPipe`, so **nothing**
on `RunAgentInput` is validated today, including the existing
`@ArrayMaxSize(100)` on `messages` and `@IsNotEmpty` on `prompt`. That
is why the new limits are enforced in `RunAgentAttachmentService` rather
than by decorators alone.

Adding the pipe here would switch on every dormant decorator at once,
and one of them breaks a shipped caller:
`build-slack-conversation-messages.ts` replays thread history, and an
assistant turn whose text strips to nothing with no files attached
reaches `runAgent` with `content: ''`. That passes today and would start
failing `@IsNotEmpty`. Since the Slack app ships separately from the
server, an old app build against a new server would break. Worth fixing,
with its callers first, but not as a side effect of this PR.

## Testing

- 9 unit tests on the resolver service; full `ai-agent-execution` and
`ai` module suites pass.
- `tsgo --noEmit` clean on twenty-server, twenty-shared and twenty-sdk;
oxlint and oxfmt clean.
- Metadata GraphQL artifacts regenerated against a locally running
server. The schema diff is the new input type; the churn in the client
SDK `types.ts` is index renumbering from inserting it.

Not verified end to end: that an image actually lands in front of a
model. That needs a live run against a real provider. The message shape
matches what chat already sends successfully, but that is inference
rather than observation.

## Next

The Slack consumer is a separate app-package change: declare
`UPLOAD_FILE`, add the `files:read` bot scope (which forces existing
installations to reauthorise), download from `url_private` server-side,
upload, and pass the fileIds here. The names-only path from #25695 stays
as the fallback.
2026-09-15 15:36:27 +00:00
Abdul Rahman 6d2e8c3591 Let every application admin manage a workspace-shared connection (#25773)
Follow-up to #25708, taking the first option from the product note in
that review: creator ownership is the wrong UI boundary for a
workspace-scoped connection.

On the connection detail page, the Reconnect, Share with workspace and
Disconnect actions only rendered for the member who originally created
the connection. That gating was presentation only, and everyone who can
open the page holds the Applications permission. It meant an admin could
not repair or remove a connection the whole workspace depends on without
chasing whoever happened to create it.

Disconnect was already authorized server-side for this:
`deleteConnectedAccount` goes through `verifyAdministrableByCaller`
(`verifyOwnership` when this PR opened), which accepts any member who
can administrate a workspace-shared connection. Reconnect was not.
`startAuthorizationFlow` only checked that the reconnect target lived in
the caller's workspace for the requested provider, so
`/auth/apps/authorize` (a `NoPermissionGuard` endpoint) let any member
pass `reconnectingConnectedAccountId` for *another member's private*
connection and have the callback overwrite its tokens, handle and
visibility. This PR closes that before removing the front-end gate that
was hiding it.

## Changes

- `SettingsApplicationConnectionDetail`: the actions render for every
viewer. "Share with workspace" still only appears on a personal
connection, and the query only returns personal connections owned by the
caller, so nothing changes for those.
- `startAuthorizationFlow` scopes the reconnect lookup with
`buildConnectedAccountUsableByCallerWhere`, the same predicate the list
query already uses, so the SQL enforces it and the rule has one
definition.
- `isOwnedByCurrentUser` on `ApplicationConnectedAccountDTO` is marked
deprecated and the front stops selecting it, since nothing consumed it
once the front stopped gating on ownership. The field and its type stay
in the schema: removing them fails `api-breaking-changes`, and the repo
retires API surface with `deprecationReason` rather than deletion.
- `generateTransientToken` moved out of
`connect-messaging-account.util.ts` into the common integration-test
layer.
- Detail page tests: the owner and non-owner cases collapse into one
that checks a failed workspace-shared connection offers Reconnect and
Disconnect, with exact call-count assertions. Integration spec asserts
the returned ids only.

### Managing a shared connection now needs the Applications permission

Widening the UI boundary to "any admin" made the missing server-side
boundary worth closing in the same PR. `/auth/apps/authorize` sits
behind `NoPermissionGuard`, so a member without the Applications
permission could create a workspace-shared connection or, by passing
`visibility=user` with a shared connection's id, have the callback
convert it into their own private one. The settings page never offered
either, but the API did.

#25777 has since landed the same boundary on `deleteConnectedAccount`,
so what remains here is the authorize flow.

- New `AppConnectionAccessService` states the rule once: a
workspace-shared connection is workspace infrastructure and managing it
takes `PermissionFlagType.APPLICATIONS`; a private one stays under its
owner's control. Gating on the row rather than the endpoint is what
keeps personal email and calendar accounts, which are always
`visibility: 'user'`, manageable without the settings permission.
- `startAuthorizationFlow` checks it, covering both creating a shared
connection and reconnecting one. The check belongs at authorize rather
than callback because the signed state carries the visibility decision
for its ten-minute lifetime.
- It lives in `core-modules/application/connection-provider/` because
`ConnectedAccountMetadataModule` already imports
`ConnectionProviderModule`; putting it on the metadata side would need
the reverse edge.

### The administration flag follows the provider

#25777 landed the same boundary on `deleteConnectedAccount` while this
was open, choosing `PermissionFlagType.WORKSPACE` because a shared
connected account there is a group mailbox, and deleting one destroys
its whole message history. That is right for a group mailbox and wrong
for an app connection, which is administered from Settings >
Applications. With one flag serving both rows, this PR's front-end
change would have shown Disconnect to an Applications admin on an
`APPLICATIONS`-gated page and had the server refuse it.

`getConnectedAccountAdministrationPermissionFlag` states the mapping
once, keyed on the provider, and both `isAdministrableByCaller` and
`AppConnectionAccessService` read it, so the two paths cannot drift
apart again. Group mailboxes keep the `WORKSPACE` requirement #25777
gave them.

### Reconnect re-attributes the connection

Reconnect rewrote tokens, handle and visibility but left
`userWorkspaceId` at the original creator. Until this PR only the owner
could reconnect, so the two always matched. Now an admin can repair a
shared connection, and `ConnectedAccountOwnershipTransferService`
archives and revokes by `userWorkspaceId`: leaving it stale means the
creator off-boarding revokes credentials the admin authorized under
their own identity, breaking a connection the workspace still depends
on. The reconnect update now sets `userWorkspaceId` to whoever completed
the flow. The reconnect guard already restricts the target to
workspace-shared or the caller's own, so on a private connection this is
a no-op.

Off-boarding taking a workspace-shared connection down with its owner is
pre-existing and left alone here: `transferOwnership` archives and
revokes by `userWorkspaceId` with no visibility filter, so it already
happens when the creator leaves. Discussed on [this
thread](https://github.com/twentyhq/twenty/pull/25773#discussion_r4004484226);
the fix belongs in the off-boarding flow and needs a product call on
whether a departed member's grant should outlive their deprovisioning.

## Schema

One line, non-breaking: `isOwnedByCurrentUser` gains `@deprecated`. An
earlier revision of this PR deleted the field and
`ApplicationConnectedAccountDTO` with it, which `api-breaking-changes`
correctly rejected.

## Test

- `app-connection-management-guards.integration-spec.ts` (renamed from
`app-oauth-authorize-reconnect.integration-spec.ts`), 6 tests, all
passing: Jane is refused on Tim's private connection and passes the
guard on Tim's workspace-shared one; Jony, who holds no Applications
permission, is refused when reconnecting a shared connection, when
requesting `visibility=workspace`, and when promoting his own private
connection to workspace visibility, and is allowed reconnecting his own
private connection. Each refusal case passes on the branch and fails
without the corresponding change. The delete cases moved to #25777's own
spec.
- `get-connected-account-administration-permission-flag.util.spec.ts`
pins the provider-to-flag mapping.
- The `userWorkspaceId` re-attribution has no automated test: nothing in
the repo drives the app OAuth callback, since the token exchange calls
the real provider through
`secureHttpClientService.createSsrfSafeFetch()`, and the only seam would
be a util extracted for a one-line spread.
- The connected-account, connection-provider and message-channel
integration suites pass (17 suites, 89 tests) against a freshly seeded
database, including the `connected-account-resolver` spec #25777 added.
`successful-save-imap-smtp-caldav-account` fails locally for want of a
Dovecot container, identically with the branch stashed.
- `npx jest src/pages/settings/applications
--config=packages/twenty-front/jest.config.mjs` passes (12 tests).
- `graphql:generate --configuration=metadata` and
`twenty-client-sdk:generate-metadata-client` run against the server
built from this branch produce exactly the committed generated diff.
- `tsgo --noEmit` clean on twenty-front and twenty-server. oxlint and
oxfmt clean on the changed files.
2026-09-15 15:35:57 +00:00
Weiko 563eb2886f Fix CSV export connection and download lifecycle 2026-09-15 17:31:54 +02:00
github-actions[bot]andgithub-actions 3033965952 i18n - translations (#25990)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-09-15 17:11:57 +02:00
Thomas Trompette de26eb10ab fix(record-form): render rich text fields with the BlockNote editor (#25920)
## The bug

Creating a record through the creation form with a rich text field
containing a bullet list — typically pasted — saves a value the record
page cannot read. The field renders `Invalid Configuration` instead of
the content.

Console: `Error creating document from blocks passed as
'initialContent'` → `Cannot read properties of undefined (reading
'isInGroup')`, thrown in `RichTextFieldEditor` and caught by the
page-layout widget error boundary.

## Why

The two editors for the same field disagree on the format.

The record page uses a BlockNote editor and writes BlockNote blocks. The
creation form used a TipTap editor and passed its output through
`convertTipTapDocumentToBlockNote`, which does not convert: it parses
the TipTap document, strips the outer `doc` wrapper and returns
`document.content` unchanged.

So the column ends up holding TipTap nodes while every reader treats it
as BlockNote:

```
seeded note (renders fine)     [{"id":"block-1","type":"paragraph","props":{...
created via the form (broken)  [{"type":"bulletList","content":[{"type":"listItem"...
```

Plain text survives because `paragraph` exists in both schemas, which is
why this went unnoticed. A bullet list does not: TipTap nests
`bulletList > listItem > paragraph`, BlockNote uses flat
`bulletListItem` blocks.

## The change

`FormFieldInput` now routes rich text to a BlockNote editor when the
caller supplies no `VariablePicker`, and keeps the TipTap editor when it
does.

That split matters because the component is shared. Workflow step
editors pass a picker and need variables: their value lives in step
settings, is read back by the same TipTap editor, and the existing
stories pin an `onChange` payload containing `variableTag` nodes.
Changing their format would break that round trip. The creation form and
Update Multiple Records pass no picker; their value goes into a record
column, so they get BlockNote and write what the record page reads.

The picker is a capability switch rather than a guess about storage:
only the TipTap editor can host variable tags, so any surface offering
variables must use it. A RICH_TEXT field is not filterable
(`FILTERABLE_FIELD_TYPES` excludes it), so the advanced-filter and
role-permission panels — where the picker comes from a context that does
not always supply one — cannot reach this branch.

`FormRecordRichTextFieldInput` is the BlockNote input. It reuses
`parseInitialBlocknote` for parsing and a new
`filterBlocksSupportedByBlockSchema` — modelled on the dashboard
widget's `filterSupportedBlocks` — to drop blocks the schema does not
know, per block and recursing into children, with the allow-list derived
from `BLOCK_SCHEMA` rather than hand-maintained. A stored TipTap value
therefore mounts as an empty editor instead of throwing.

It also pushes onto the focus stack on focus and pops on blur, matching
the record page editor, so global single-key hotkeys do not fire while
typing.

## Scope

Only the rich text branch changes. Every other field type in
`FormFieldInput` is untouched, as is the record page editor and the
TipTap path.

Update Multiple Records switches too, since it also writes a record
column and passes no picker. Same bug, same fix, not separately tested
here.

## Deliberately not fixed

**Workflow Create / Update / Upsert Record steps still write TipTap into
record columns.** They pass a `VariablePicker`, so they keep the TipTap
editor, and `resolveRichTextFieldsInRecord` on the server substitutes
variables inside the string and stores it verbatim. Pasting a bullet
list there reproduces the same crash. Fixing it needs a real TipTap →
BlockNote conversion applied after variable resolution, which is a
larger change; this PR is scoped to the reported path.


Records already created through the form keep their TipTap value and
will keep erroring until repaired. No backfill here.

## Failing loudly instead of silently

Three ways the new editor could lose or swallow something quietly, all
closed:

- **A stored value it cannot fully read.** The block filter drops
unknown types, so a legacy TipTap value would mount as an empty editor
and the first keystroke would persist the blank over it. A recursive
block count before and after filtering detects any drop — a document
mixing supported and unsupported blocks included, where survivors would
otherwise mask the loss — and the editor goes readonly saying so, rather
than letting content be overwritten by accident.
- **The `markdown` fallback.** The TipTap input read `blocknote ??
markdown`; the BlockNote one now does too, instead of only `blocknote`.
- **File and image blocks.** The slash menu offers File, Image, Video
and Audio, and `FileBlock` calls `editor.uploadFile?.()` — with no
handler supplied that silently did nothing. The record-page editor
attaches uploads to an existing record, which a creation form has no id
for, so the handler now says that instead and returns an empty string —
the no-op path `FileBlock` already handles, since it has no try/catch
and a rejection would go unhandled.

## Testing

- `twenty-front` typecheck clean; oxlint on the touched modules and
oxfmt across the package clean.
- 62 specs pass across `blocknote-editor` and
`record-field/ui/form-types`, including new unit specs for
`filterBlocksSupportedBySchema` (unknown blocks, missing types,
unsupported children, whole dropped subtrees, a stored TipTap document)
and `countBlocksDeep`.
- The `onChange` story assertion was checking
`stringContaining('"type":"paragraph"')`, which TipTap output also
satisfies, so it could not tell the two formats apart; it now parses the
payload and asserts the BlockNote shape (`props` on the block, `styles`
on the text).
- Verified end to end on a branch instance: a bullet list built in the
creation form is stored as `bulletListItem` blocks with
`props`/`styles`/`children`, and the record page renders it instead of
`Invalid Configuration`, with no `isInGroup` error in the console.
- Stories cover a seeded bullet list rendering, the label, readonly, and
that `onChange` emits BlockNote-shaped blocks.
- The bug was reproduced before the fix: pasting a nested bullet list
into the form's Body and saving produced `Invalid Configuration`, with
the column holding raw TipTap JSON.
- Not done: a browser pass on the fix. Both local dev slots are held by
other work, so the editor's height and styling inside the side panel are
unverified.
2026-09-15 15:01:01 +00:00
Weiko 5097f92ecd Add asynchronous CSV export pipeline behind a feature flag 2026-09-15 16:47:12 +02:00
Weiko 9d3fd32616 Share CSV formatting and composite field labels 2026-09-15 16:43:22 +02:00
github-actions[bot]andgithub-actions 78443d6561 i18n - translations (#25985)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25985?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-09-15 16:40:24 +02:00
Charles Bochet 4f3f65c27e feat(server): validate workflow versions through exceptions, malformed at write and non-activable on activate or validate (#24964)
Second of 3 stacked PRs (stacked on #24963, merged). Fixes prod Sentry
`TWENTY-SERVER-H9K` / `TWENTY-SERVER-HCF`:
`resolveRichTextFieldsInRecord` crashes at run time on RICH_TEXT record
fields persisted as bare strings by the AI workflow builder. Both issues
are still firing (14K and 11K events over the last 30 days, last seen
today).

## TL;DR

Workflow version validation is now exceptions only. There is one issue
set, computed by one set of check utils, two explicit code sets over it
in `twenty-shared`, and two exceptions:

| Exception code | Thrown by | When | Effect |
|---|---|---|---|
| `MALFORMED_WORKFLOW_VERSION` | the write chokepoint,
`writeWorkflowVersionAndMirror` | on every version-content write | the
transaction rolls back; nothing malformed is ever stored |
| `NON_ACTIVABLE_WORKFLOW_VERSION` | `activateWorkflowVersion`, the new
`validateWorkflowVersion` mutation, the `validate_workflow` tool | when
asked to activate, or to check without activating | the version stays a
draft; the error lists every blocking issue |

Both are `WorkflowVersionValidationException`, carry `issues` (code,
message, stepId), map to `BAD_USER_INPUT` with a Lingui
userFriendlyMessage for the builder (the per-issue list is joined into
the error message; it is not on the GraphQL extensions yet), and come
back as `{ success: false, error, issues }` from the AI tools. There is
no advisory report anymore: the former `validate_workflow` report
service and the incomplete-codes set are deleted, and the tool now runs
the exact activation check.

**Malformed** means present-but-invalid data that crashes the executor:
a bare string where a rich text object belongs, a step targeting an
object that does not exist, a broken graph. It is refused at write time
so incremental drafting keeps working (an empty draft, a skeleton step,
a half-configured record step all save) while nothing that can crash a
run is ever durable.

**Non-activable** means a well-formed draft a run could not proceed
through: no trigger or trigger type, no steps, a trigger that leads
nowhere, an if/else with fewer than two branches, an iterator without a
loop body, an unfinished PICK_RECORD config, an AI agent step without an
agent, a variable reference that is malformed or points at an unknown or
downstream step. Those are reported only when someone asks, through
activation or the validate endpoint, and as a whole list rather than the
first failing assertion.

## How it fits together

Three flows, one issue set. Every check util returns
`WorkflowValidationIssue` values; `twenty-shared` holds two explicit
code sets over them, `MALFORMED_WORKFLOW_VALIDATION_ISSUE_CODES` and
`NON_ACTIVABLE_WORKFLOW_VALIDATION_ISSUE_CODES`; and each flow throws
one exception on the subset that matters to it.

### 1. Updating a workflow version

```mermaid
flowchart TB
    subgraph ENTRY["Entrypoints"]
        direction LR
        UI["Builder UI"]
        GQL["GraphQL dedicated mutations<br/>createWorkflowVersionStep · updateWorkflowVersionStep · deleteWorkflowVersionStep<br/>updateWorkflowVersionTrigger · edges · positions · createDraftFromWorkflowVersion"]
        MCP["AI · MCP tools<br/>create_complete_workflow · update_workflow_version_step"]
        REST["Generic ORM mutations<br/>(REST and generic GraphQL)"]
    end

    UI --> GQL
    GQL --> SERVICES["workflow-builder write services"]
    MCP -- update_workflow_version_step --> SERVICES
    MCP -- create_complete_workflow --> APPLY
    SERVICES --> APPLY
    REST -- "createWorkflow → post-hook seeds the v1 draft" --> APPLY
    REST -- "any workflowVersion create / updateMany,<br/>or updateOne touching steps · trigger · status · …" --> FORBID["pre-query hook → FORBIDDEN"]

    subgraph TX["writeWorkflowVersionAndMirror — one DB transaction"]
        direction TB
        APPLY["apply write, uncommitted"] --> REFETCH["re-fetch merged row"]
        REFETCH --> CHECK{"assertWorkflowVersionIsNotMalformedOrThrow<br/>MALFORMED codes only"}
        CHECK -- "malformed" --> RB["rollback + throw<br/>MALFORMED_WORKFLOW_VERSION"]
        CHECK -- "well-formed" --> MIR["mirror to core · upsertToCore"]
        MIR --> CMT["commit → DRAFT version saved"]
    end

    RB --> ERR["BAD_USER_INPUT, issues joined in the message · GraphQL<br/>success:false + structured issues · AI / MCP"]
```

An empty draft, a skeleton step, a half-configured record step all
commit. Only content that would crash the executor is refused.

### 2. Validating a workflow version (new)

```mermaid
flowchart TB
    subgraph ENTRY["Entrypoints"]
        direction LR
        GQL["GraphQL mutation<br/>validateWorkflowVersion(workflowVersionId)"]
        MCP["AI · MCP tool<br/>validate_workflow"]
    end

    GQL --> LOAD["load the version<br/>getWorkflowVersionOrFail"]
    MCP --> LOAD
    LOAD --> CHECK{"assertWorkflowVersionIsActivableOrThrow<br/>MALFORMED ∪ NON_ACTIVABLE codes"}
    CHECK -- "issues remain" --> NA["throw NON_ACTIVABLE_WORKFLOW_VERSION<br/>with the full issue list · draft unchanged"]
    CHECK -- "none" --> OK["validateWorkflowVersion: true<br/>validate_workflow: success: true · draft unchanged"]
    NA --> ERR["BAD_USER_INPUT, issues joined in the message · GraphQL<br/>success:false + structured issues · AI / MCP"]
```

Same check as activation, same error, no side effect. This is what the
AI builder calls once before activating.

### 3. Activating a workflow version

```mermaid
flowchart TB
    subgraph ENTRY["Entrypoints"]
        direction LR
        UI["Builder UI · Activate"]
        GQL["GraphQL mutation<br/>activateWorkflowVersion(workflowVersionId)"]
        MCP["AI · MCP tool<br/>activate_workflow_version"]
    end

    UI --> GQL
    GQL --> LOAD["load the version and its workflow"]
    MCP --> LOAD
    LOAD --> CHECK{"assertWorkflowVersionIsActivableOrThrow<br/>MALFORMED ∪ NON_ACTIVABLE codes"}
    CHECK -- "issues remain" --> NA["throw NON_ACTIVABLE_WORKFLOW_VERSION<br/>with the full issue list · nothing changed"]
    CHECK -- "none" --> SYNC["pre-existing sync assertions<br/>assertVersionCanBeActivated · PICK_RECORD load-balance config"]
    SYNC --> BUILD["build code steps · switch logic functions to prebuilt"]
    BUILD --> ACT["performActivationSteps<br/>archive previous · status ACTIVE · mirror to core<br/>enable automated trigger (cron / webhook / database event) · command menu item"]
    ACT --> ACTIVE["ACTIVE version"]
    NA --> ERR["BAD_USER_INPUT, issues joined in the message · GraphQL<br/>success:false + structured issues · AI / MCP"]
```

The activability check runs before any side effect, so a refused
activation changes nothing.

## Where each exception is thrown

**Malformed, at the chokepoint.** Inside the transaction, after the
persisted row is re-fetched and before the core mirror and commit,
`assertWorkflowVersionIsNotMalformedOrThrow`
(`workflow-validation/utils/assert-workflow-version-is-not-malformed-or-throw.util.ts`)
runs the structure and record-step checks, keeps the codes in
`MALFORMED_WORKFLOW_VALIDATION_ISSUE_CODES`, and throws if any remain.
It is a pure util fed with the flat metadata maps, so the core-sync
module only needs the metadata read module from #24963 and stays out of
the `WorkflowCommon → CoreSync` cycle. It covers all surfaces at once:
GraphQL dedicated mutations, the AI/MCP tools, and the generic-path
draft creation. (The generic ORM path cannot write step or trigger
content at all: the pre-query hooks forbid every `workflowVersion`
create and updateMany, and an updateOne that touches steps, trigger,
status, position, workflowId or coreWorkflowVersionId.)

**Non-activable, on request.**
`WorkflowVersionValidationWorkspaceService.assertWorkflowVersionIsActivableOrThrow`
(`workflow-validation/workflow-version-validation.workspace-service.ts`)
runs the structure, AI-agent and record-step checks and throws with
every issue whose code is in `MALFORMED_WORKFLOW_VALIDATION_ISSUE_CODES`
or `NON_ACTIVABLE_WORKFLOW_VALIDATION_ISSUE_CODES` (malformed is
included because legacy versions written before the write gate can still
hold some). Its module depends on the metadata read module only. Three
callers, one piece of code:

- `activateWorkflowVersion` calls it before its existing sync assertions
and before any side effect, so a refused activation changes nothing.
- The new `validateWorkflowVersion(workflowVersionId)` mutation on
`WorkflowVersionResolver` calls it and returns `true`; a non-activable
version fails with the same error activation would give, and the version
stays a draft.
- The `validate_workflow` AI tool calls it and returns `{ success: true
}`, or `{ success: false, error, issues }` with the exception's issues.

Rejections surface as `BAD_USER_INPUT`, not a 500, through
`WorkflowVersionValidationGraphqlApiExceptionFilter` on the builder and
trigger resolvers, so the builder shows a clean message and Sentry is
not spammed with expected rejections.

One scoping note on "nothing malformed is ever stored": that holds for
workflow **version** content. `create_complete_workflow` inserts the
parent `workflow` row before it opens the gated transaction, so a
malformed payload leaves an empty workflow with no version behind. That
is the pre-existing behaviour of that tool and is harmless, but it is
not covered by the rollback.

## What counts as malformed (rejected at write)

`MALFORMED_WORKFLOW_VALIDATION_ISSUE_CODES` in `twenty-shared`:

- `INVALID_STEP_PARAMS`, `INVALID_TRIGGER_PARAMS` — the step or trigger
fails its Zod schema
- `INVALID_RICH_TEXT_FIELD` — a RICH_TEXT value is not `{ markdown,
blocknote? }` (the crash this fixes)
- `OBJECT_NOT_FOUND` — a record step targets an object that does not
exist in the workspace
- `DUPLICATE_STEP_ID`, `DANGLING_REFERENCE` — broken graph structure

`NON_ACTIVABLE_WORKFLOW_VALIDATION_ISSUE_CODES` in `twenty-shared`,
refused at activation and by the validate endpoint:

- `MISSING_TRIGGER`, `MISSING_TRIGGER_TYPE`, `NO_STEPS`,
`TRIGGER_HAS_NO_NEXT_STEP`
- `IF_ELSE_INSUFFICIENT_BRANCHES`, `ITERATOR_MISSING_LOOP_BODY`
- `INCOMPLETE_PICK_RECORD_CONFIG`, `AI_AGENT_MISSING_AGENT`
- `VARIABLE_INVALID_PATH`, `VARIABLE_UNKNOWN_STEP`,
`VARIABLE_NOT_UPSTREAM`

Activation also refuses the malformed set, since versions written before
the write gate can still hold malformed content.

The codes in neither set are computed by the shared structure validator
but block nothing: `UNREACHABLE_STEP`, `IF_ELSE_BRANCH_HAS_NO_NEXT_STEP`
(an empty branch is legal), `VARIABLE_MISSING_OUTPUT_SCHEMA` and
`VARIABLE_PATH_NOT_FOUND` (both depend on inferred output schemas, so
they can be false positives), and the warning
`AI_AGENT_MISSING_OUTPUT_VARIABLE`. A record step whose object is not
picked yet is malformed, not non-activable, since a missing or unknown
`objectName` is `OBJECT_NOT_FOUND`; in practice this never bites the
builder because `createWorkflowVersionStep` seeds every CREATE_RECORD
skeleton with a default object.

Activation used to stop at the first failing sync assertion and only
checked trigger presence, at least one step, form steps and the
PICK_RECORD config. It now refuses on the whole blocking set, with every
issue listed. That is a behaviour change: a draft that activated before
with, say, a variable pointing at a step that no longer exists will now
be refused until fixed. Output-schema hints are deliberately not in the
set: a CODE step or a webhook trigger without a declared output schema
activates as before.

## Notable changes

- `WorkflowVersionValidationException` with codes
`MALFORMED_WORKFLOW_VERSION` and `NON_ACTIVABLE_WORKFLOW_VERSION`, each
with a Lingui userFriendlyMessage, carrying the `issues` list.
`WorkflowQueryValidationException` is back to main's shape (FORBIDDEN
only).
- `assertWorkflowVersionIsNotMalformedOrThrow`, the pure util used at
the chokepoint.
-
`WorkflowVersionValidationWorkspaceService.assertWorkflowVersionIsActivableOrThrow`,
replacing the advisory report service
(`WorkflowValidationWorkspaceService.validateWorkflowVersion /
validateWorkflowDefinition` on main). Its module imports the metadata
read module only: the output-schema enrichment the report did, and the
schema-hint checks that depended on it (missing output schema on CODE,
HTTP and webhook steps, logic-function schema mismatch, steps without
variable references, and the iterator items-is-an-array check that
resolved against the referenced step's stored schema), are removed along
with their codes `CODE_STEP_MISSING_OUTPUT_SCHEMA`,
`STEP_HAS_NO_VARIABLE_REFERENCE`,
`LOGIC_FUNCTION_OUTPUT_SCHEMA_MISMATCH` and `ITERATOR_ITEMS_NOT_ARRAY`.
The iterator action already fails a run cleanly on non-array items. They
were guidance for the AI builder, not conditions for a run.
- `NON_ACTIVABLE_WORKFLOW_VALIDATION_ISSUE_CODES` in `twenty-shared`,
next to the malformed set.
- The front's generated GraphQL types pick up the new mutation.
- New `validateWorkflowVersion` mutation. The `validate_workflow` AI
tool keeps its name, since the standard skill and the setup prompt
reference it, but now runs the activation check and fails with issues
instead of returning a report. Its description, the standard
workflow-building skill text and the setup prompt are updated
accordingly, and the stale `validate: false` instruction is gone.
- Semantic record-step checks get their own codes so
`INVALID_STEP_PARAMS` means structural-schema-only: `OBJECT_NOT_FOUND`
(malformed) and `INCOMPLETE_PICK_RECORD_CONFIG` (non-activable). The
record-step metadata checks are shared through
`getWorkflowRecordStepMetadataIssues`.
- The pre-existing generic-mutation guard is renamed
`WorkflowVersionQueryValidationWorkspaceService`, after the
`WorkflowQueryValidationException` it throws, so it cannot be confused
with the content validation service.
- The `create_complete_workflow` and `update_workflow_version_step` AI
tools no longer run a validation after writing;
`update_workflow_version_step` therefore loses its `validate` boolean
input, and the orphaned `summarizeValidation` helper is deleted.
`create_complete_workflow`'s contract now tells the model RICH_TEXT must
be `{ markdown }`.
- `findRichTextFieldNames` is extracted from the executor's
`resolveRichTextFieldsInRecord` and shared instead of duplicated.

## Verification

Integration tests:

- **Malformed at write**
(`workflow-version-malformed-validation.integration-spec.ts`), via
`updateWorkflowVersionStep`: a bare-string rich text value and an
unknown target object are rejected (`BAD_USER_INPUT` /
`MALFORMED_WORKFLOW_VERSION`), a valid `{ markdown }` object is
accepted, and an incomplete record step still saves. The skeleton
`createWorkflowVersionStep` succeeding is exercised in the setup of the
same spec. Writing `steps` through the generic `updateWorkflowVersion`
mutation is `FORBIDDEN` by the pre-query hook.
- **Non-activable on request**
(`workflow-version-activation-validation.integration-spec.ts`):
`validateWorkflowVersion` on an empty draft fails with `BAD_USER_INPUT`
/ `NON_ACTIVABLE_WORKFLOW_VERSION` and names the missing trigger;
`activateWorkflowVersion` fails with the same subCode; after a trigger
and a skeleton step, `validateWorkflowVersion` returns `true` and the
version is still a draft.
- **AI / MCP**
(`workflow-ai-tool-malformed-validation.integration-spec.ts`),
`create_complete_workflow` through the `/mcp` `execute_tool` dispatch: a
bare-string rich text returns `{ success: false, error: "…rich text…" }`
with `isError: true` instead of crashing, while a valid `{ markdown }`
object and an incomplete record step both create the workflow.
- Unit: record-step metadata util, rich-text issue utils, tool spec.
Typecheck and lint pass.

Verified by hand on 2026-09-14 against the branch running locally
(server, front, migrated dev database):

- Dedicated GraphQL mutations: empty draft, manual trigger and skeleton
CREATE_RECORD step all save; a bare-string `bodyV2` is rejected with
`MALFORMED_WORKFLOW_VERSION` and the re-read version still holds the
previous `objectRecord` (rollback confirmed); an unknown object is
rejected; `{ markdown }` and an empty `objectRecord` are accepted;
generic `updateWorkflowVersion(steps)` is `FORBIDDEN`; deleting the step
re-links the graph.
- `validateWorkflowVersion` on an empty draft fails with
`NON_ACTIVABLE_WORKFLOW_VERSION`, message "The workflow has no trigger.;
The workflow has no steps.", userFriendlyMessage "This workflow version
is not ready to be activated."; `activateWorkflowVersion` fails
identically; the `validate_workflow` tool returns `success: false` with
`issues` `[MISSING_TRIGGER, NO_STEPS]`; after a trigger and a step,
`validateWorkflowVersion` returns `true`, the version is still a draft,
and `activateWorkflowVersion` then succeeds.
- `/mcp` `create_complete_workflow`: bare string → `success: false` +
`isError`; `{ markdown }` and an incomplete record step → created.
- Builder UI: add a Create Record step, switch it to Notes, type in the
Body rich text field (the front sends `{ blocknote, markdown: null }`,
which is well-formed), reopen the step and see the body re-hydrated,
delete the step. No console errors, no error toast, no server errors.

## Rollout

Data backfill for the existing affected versions is the next PR (#24965,
re-homed to the 2.41 upgrade directory). Until it runs, a legacy version
that already holds a bare-string rich text value is write-locked by the
malformed check: any content edit to it is rejected until the backfill
normalizes it. Those versions already crash at execution time today, so
this turns an opaque runtime crash into a clear write-time rejection,
but the two PRs should ship in the same release to keep that window
short.

**2026-09-14:** rebased on main after the September workflow core
migration work. The chokepoint and all its callers are unchanged; the
new core-API workflow creation path
(`createInitialDraftVersionForWorkflow`) also goes through it, and the
remaining direct `workflowVersion` writes only touch `status`.
2026-09-15 14:25:11 +00:00
github-actions[bot]andgithub-actions bc038ed106 i18n - website translations (#25981)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25981?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

Co-authored-by: github-actions <github-actions@twenty.com>
2026-09-15 16:23:06 +02:00
Raphaël Bosi 62ce737552 Use Twenty UI components in presentation stories (#25976)
Use Twenty UI components for supporting buttons, inputs, links,
counters, and typography in presentation stories so examples follow the
design system.

Remove the replaced custom styles and preserve accessible button names.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25976?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-09-15 14:20:06 +00:00
Paul Rastoin 613d3f6c5e Pin catalog sync to a registration's own source package (#25965)
Reworks how the marketplace catalog sync writes into application
registrations.

- `upsertFromCatalog` now requires a manifest, splits update from create
into private methods, and returns the upserted registration so the sync
no longer re-reads the row for asset storage. A skipped entry returns
null.
- The catalog identifier is validated as a UUID and lowercased before
any comparison, and the sync only attaches to registrations sourced from
npm whose `sourcePackage` matches the registry package. Other rows are
skipped with a warning.
- `latestAvailableVersion` only moves forward: the catalog update goes
through the same semver guard as the install flow, and an entry with no
version never erases a known one. `updateFromManifest` reports whether
the version rose, so the publish metric and auto-upgrade fan-out fire
only on a real bump.
- Removes the six-hourly application version-check cron and its
compare-and-swap. The hourly catalog sync already fetches every registry
package, refreshes manifest and assets, and enqueues auto-upgrades, so
the second writer only left the listing's manifest stale and could
observe the same transition twice.
- The catalog sync integration spec moves onto reusable utils (registry
package stub, catalog registration insert, lookup by universal
identifier) and covers source package pinning, non-npm registrations,
identifier normalization and downgrade refusal.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25965?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-09-15 14:13:05 +00:00
github-actions[bot]andgithub-actions f25a066541 i18n - translations (#25979)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25979?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

Co-authored-by: github-actions <github-actions@twenty.com>
2026-09-15 16:13:11 +02:00
Etienne 3bb53150a9 revert(email-tool): restore the default sender resolution (#25972)
Reverts #25908.

That PR made an API key caller resolve its default sender through the
workspace-visibility filter, so `send_email` and `draft_email` from MCP
need a mailbox shared with the workspace.

Connected account visibility is not editable or writable for email
accounts today, so there is no way to actually share one. The filter
therefore has no path to a working state: it only removes the two email
tools for MCP users.

Reverting until we have a product vision for connected account
visibility.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25972?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-09-15 13:58:41 +00:00
github-actions[bot]andgithub-actions 89084cbc92 i18n - translations (#25973)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-09-15 15:51:34 +02:00
Charles Bochet aca4b9ddbd Add copy button on hover to Emails/Phones/Links in the record side panel (#25940)
## What

Adds the same secondary hover button (Copy, or Open link when the field
click action is set to Copy) that the table view already shows to the
record side panel (and other inline-cell surfaces), for Emails, Phones
and Links fields.

Fixes #25936

## Why

In the table view, hovering an Emails/Phones/Links cell shows a Copy
button next to the Edit pencil. The side panel showed only the pencil,
so copying an email meant opening edit mode and using the item menu,
which puts Copy one click away from an irreversible Delete. Read-only
users could not reach Copy at all.

## Screenshots

Hovering an Emails/Phones/Links value. Before shows only the Edit
pencil; after shows Copy + Edit.


<img width="1440" height="900" alt="image"
src="https://github.com/user-attachments/assets/5a38bac2-b88c-442d-9a7d-0935e47f93bd"
/>
 

<img width="399" height="632" alt="image"
src="https://github.com/user-attachments/assets/5dd086e3-6b64-4840-91cf-b99dd6714ca7"
/>

<img width="184" height="204" alt="image"
src="https://github.com/user-attachments/assets/8c7a053f-858a-4e75-bfe6-c9d6760d26e3"
/>


## How

- Moved `useGetSecondaryRecordTableCellButton` from
`record-table/record-table-cell/hooks/` to a shared location
`record-field/ui/hooks/useGetSecondaryFieldButton.ts`, since it now
serves both the table cell and the inline cell. Behavior (field type
handling and the `clickAction` setting) is unchanged; it now also
returns an `ariaLabel` per action.
- `RecordInlineCellDisplayMode` calls the hook and renders the secondary
button(s) next to the pencil. Because Copy does not mutate data, the
secondary button also shows on read-only fields (where the pencil is
hidden).
- Threaded the `ariaLabel` through `RecordInlineCellButton` and
`RecordTableCellButtons` so both the table and side-panel buttons are
labeled.

Since the logic lives in `RecordInlineCellDisplayMode`, the button also
appears on the board/calendar card hovered cells, matching the table.

## Test

- Added `RecordInlineCellDisplayMode.stories.tsx` covering: hovered
(Copy + Edit shown), not hovered (no buttons), read-only (Copy only),
and clicking Copy on Emails/Phones/Links asserts the exact clipboard
value with the field staying in display mode.
- `nx lint:diff-with-main twenty-front`, package typecheck, and the new
Storybook interaction tests all pass.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25940?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture>``&lt;source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;&lt;source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"&gt;&lt;img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</picture></a>
<!-- End of auto-generated description by cubic. -->
2026-09-15 13:36:38 +00:00
Raphaël Bosi 73b957e764 Organize Twenty UI into primitive families (#25948)
Move all existing Twenty UI component families into
`primitives/<family>`, including components that still use their older
APIs. Reserve `components` for future app building blocks.

Replace the old family entry points with `twenty-ui/primitives/<family>`
and update workspace imports, renderer examples, documentation, and
generated exports.
2026-09-15 13:36:07 +00:00
martmull a482b18565 Record sharing: gate events through one access policy entry point (#25964)
Cleanup pass on the record sharing code merged in #25421, #25425,
#25426, #25428, #25650, #25439 and #25925, following the review of
#25925: no functional change, the event side of the gate loses its
duplicated plumbing and a few dead pieces go. Net: 28 files, +399 /
-1068.

## What changes

- The four event consumers (subscription publisher, webhook job, logic
function trigger job, workflow trigger listener) each rebuilt the record
share gate of a batch: a flag check, the share rows read and indexed,
the gate kind switch, the admission test, and the parents resolution
wired through two callbacks.
`RecordAccessPolicyService.buildEventRecordShareGate(batch)` now builds
that gate once per batch and `resolveAdmittedRecordIds(subject)` answers
the record ids the subject may receive. The consumers build their
subject and filter; the flag, the share rows (read at most once per
batch, as before) and the parents stay inside the service.
- Gone with it: `RecordShareGate`, `DENY_ALL_RECORD_SHARE_GATE`,
`buildRecordShareGate`, `isRecordAdmittedByRecordShareGate`,
`indexRecordSharesByRecordId` and their specs. The share row admission
rule survives as one pure util, `resolveRecordIdsSharedWithPrincipals`,
used by the service and by the twin integration spec that checks it
against the SQL gate. The service's own nested evaluation (captured
child rows) now runs through the same gate switch as the batch level
instead of a second copy.
- `resolveRecordIdsReadableThroughParents` is no longer public: nothing
outside the service needs to reach the parents resolution on its own,
and the integration specs that did now go through the batch gate, which
is what production calls.
- Dead code: `RecordShareService.findByRecord` (tests used it, they call
`findByRecordIds`), the `recordIdExpression` override of
`buildRecordShareCondition` that only its spec passed.
- Repository: `createPermissionBypassingQueryBuilder` and
`getRecordShareTableExpression` duplicated
`buildBypassingEventSelectQueryBuilder` and `getTableExpression`; the
column parents a record inherits through are filtered with a type guard
instead of being copied into link objects.

## What does not change

- The SQL row access policy, the inheritance rules, the deletion
capture, the shareWith path and the recordShare standard object are
untouched. The webhook, workflow, logic function and subscription
outcomes are the same for every subject; the publisher and workflow
listener specs now run the real `RecordAccessPolicyService` over a
mocked share service, so the private object, system object and unrelated
stream cases still assert the outcome rather than a mocked callback.
- `deleteBySourceId` stays: it has no production caller yet, but it is
the row API a sharing rule needs and the integration specs clean up
through it.

## Verified

Typecheck, lint, the 44 unit suites around the touched code, and the 25
integration suites of `object-records-permissions` and `hooks`.
2026-09-15 13:34:54 +00:00
github-actions[bot]andgithub-actions bd74b1e121 i18n - translations (#25971)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-09-15 15:31:46 +02:00
Paul Rastoin faf4ce0d3a Split the admin application registration update input (#25963)
The admin panel mutation and the tenant mutation for updating an
application registration shared a single input type. This gives each its
own.

- Adds `AdminUpdateApplicationRegistrationInput`, which carries the
listing flags (`isVetted`, `isListed`, and friends) and is the only
input accepted by `updateAdminApplicationRegistration`.
- `UpdateApplicationRegistrationInput` is trimmed to the fields a
workspace edits on its own registration.
- `ApplicationRegistrationService` splits the global update from the
tenant update accordingly.
- Regenerates the front and client SDK GraphQL types and updates the
admin panel mutation document.
- Adds an integration spec for the registration instance flags covering
both update paths, plus reusable request utils for the two mutations.
The catalog sync spec is updated to the new admin input name.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25963?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-09-15 15:29:37 +02:00
Paul Rastoin 64f5165b8a Scope application file uploads to the registration owner (#25962)
Resolves application file upload targets from the registration the
workspace owns instead of from the raw upload input, so the development
service and the file upload service share one lookup.

- `ApplicationRegistrationService` gains a helper that loads a
registration by identifier for a given workspace, used by both the
direct upload path and the development flow.
- The upload target computation moves out of
`ApplicationDevelopmentService` into `ApplicationFileUploadService`.
- Integration test helpers for creating and completing uploads are
extracted into reusable utils under
`test/integration/metadata/suites/application/utils`, with query
factories split from the request wrappers.
- Adds an integration spec covering upload creation and completion
against registrations the workspace does not own.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25962?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-09-15 15:27:08 +02:00
github-actions[bot]andgithub-actions 466b76cfdc i18n - website translations (#25967)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-09-15 15:26:43 +02:00
Thomas des Francs bfabaa16a3 [2/4] Desktop Recorder: add the installable Twenty integration (#25633)
Add the installable Desktop Recorder integration for locally recorded
calls.

- Store recordings, process Recall uploads, and generate summaries.
- Share Recall transport, retry policy, and signature verification with
Call Recorder.
- Retry transient transcript requests, finish failed transcripts after
media recovery, and stagger recovery jobs across workspaces.
- Deduplicate summary jobs with existing `jobId`; cache results using
existing KV operations.
- Resolve participants and their CRM avatars.
- Provide the macOS download link in app settings.

**Scope:** `twenty-apps/public/companion` and
`twenty-apps/shared/recall`. Call Recorder imports the shared transport;
its bot lifecycle stays separate. No core or billing changes.

**Local validation:** 298 integration tests, 80 Call Recorder Recall
tests, integration typecheck, changed-file lint, and both webhook
bundles passed.

**Before release:** verify live installation/settings, configure the
download URL and credentials. Ambiguous charging failures are not
retried automatically.

**Merge order:** #25632#25633#25616#25900.
2026-09-15 13:22:49 +00:00
Thomas des Francs d7e0abfffe Complete Organization naming in settings and activation (#25961)
## Summary

Rename the self-hosted Admin Panel tab to Organization and update
settings links to `/settings/admin-panel#organization`. Keep redirects
from the old `#enterprise` fragment and `/settings/enterprise` route.
Align security, logs, record permissions, and legacy page headings with
Organization wording.

Move the website activation page and checkout success URL to
`/organization/activate`, with permanent redirects from the old English
and localized URLs. Update English instructions and edit the
documentation screenshot to show the Organization tab. Keep the updated
image at the legacy image path until Crowdin updates localized
documentation.

Use Organization wording in website license API errors and related
diagnostics. Error codes, response fields, license configuration, and
backend API identifiers remain unchanged. Follow-up to #25941.

## Before/After

- Enterprise settings tab and feature labels → Organization.
- `/settings/admin-panel#enterprise` →
`/settings/admin-panel#organization`.
- `/enterprise/activate` → `/organization/activate`, preserving existing
links.
2026-09-15 13:21:28 +00:00
Thomas des Francs 4019e10d1e [1/4] Desktop Recorder: support existing UI controls (#25632)
Let the desktop app reuse existing Twenty UI controls.

- `AnimatedPlaceholder`: allow a custom asset base path.
- `IconButton`: forward refs and native props for menu triggers.
- `Button`: import Loader and Pill directly.

**Scope:** four existing UI files. Desktop-specific components live in
#25616.

**Latest validation:** desktop typecheck, 115 desktop main/shared tests,
and changed-file lint passed.

**Merge order:** #25632#25633#25616#25900.

<details>
<summary>Screenshot — existing AnimatedPlaceholder in the desktop
app</summary>

Local renderer preview with sample data; the surrounding desktop UI is
in #25616.

![Existing animated placeholder reused in
desktop](https://github.com/user-attachments/assets/8d05599e-16c3-4876-8ce8-163b9d116505)

</details>
2026-09-15 12:40:03 +00:00
martmull 85e02f6a4c Record sharing: inherited records follow the parent's complete access policy, in queries and events (#25925)
Follow-up to #25439 and #25914 (both merged), now based on `main`. Two
review findings on the inheritance gate: a parent only asked for a
non-null key or a share row, never for the reader's permission on the
parent object nor their row-level restrictions, so a member blocked from
reading a person by their role still read the person's notes and
attachments through it; and the event path (subscriptions, webhooks,
workflows, logic functions) kept treating every INHERITED record as
open, a second interpretation of the policy next to the SQL one. Inert
until a parent object is PRIVATE and `IS_RECORD_SHARING_ENABLED` is on.

## What changes

- One row access policy builder, `buildRowAccessPolicy`, composes for an
alias the role's permission on the object, its row-level predicate and
the record share gate (OPEN, PRIVATE share rows, INHERITED parents,
SYSTEM and APPLICATION denials). Direct queries go through it for their
root and joined aliases, and so do the column parents and child rows of
an INHERITED object: a gated parent is correlated through the parent row
under that policy, `(fk IS NOT NULL AND EXISTS (SELECT 1 FROM parent p
WHERE p.id = fk AND <policy>))`, a denied parent grants nothing. A
parent the reader may not query grants nothing on a child.
- The builder takes a subject, the pieces of an identity the policy
depends on: object permissions, share principals, owning application,
row-level filter. The repository derives it from its auth context; the
object permission predicate moved to a util the query-level validation
shares, so the API keeps reporting a denied root object with the error
it documents.
- `RecordAccessPolicyService` evaluates that policy for a subject
holding no auth context, in SQL on a system-context repository. For an
INHERITED record's events it decides which of the records the event
snapshot points at, and which live child rows point back at it, the
subject may read; the snapshot stands in for the record so a destroyed
one is still decided on. The record's own share rows stay evaluated in
memory as before.
- A deleted record's links are captured with its deletion: a note's
targets are soft-deleted with it and cascade away when it is destroyed,
so the repository reads the child rows an INHERITED record inherits
through before a soft delete or a destroy, and the `deleted` and
`destroyed` events carry them as `inheritedReadabilityChildRecords`, a
server-side transit property stripped before any client payload. The
service evaluates those captured rows as snapshots under the subject's
policy (object permission, row-level filter, the child's own share rows
or parents), so the readers who could see the record through its links
still receive its deletion. Subscriptions and logic functions strip the
capture before handing the event over; webhooks and workflows only ever
took `before` and `after`.
- The query gate applies the same rule to a trashed record: a child link
counts when it is live, or when it was trashed at or after the record
itself. A note soft-deleted with its targets stays visible in the trash,
and restorable, for the readers who saw it live; a target detached
before the deletion still grants nothing.
- A soft delete through the ORM now touches live rows only, unless the
builder opted into `withDeleted`, matching the rows its own snapshot
select already returned. The target cascades restamped targets detached
earlier with the current time, which the rule above would have read as a
link trashed with the note.
- The note and task target hooks, which trash and restore the targets
with their note or task, now run with permission checks bypassed. They
cascade a mutation the actor was already permitted on the parent, and
the repository they built without a role config carried no object
permission at all, which the inherited gate reads as denied on every
parent: with the flag on, they trashed nothing.
- Subjects per consumer: a subscriber's object permissions, principals
and row-level filter; the standard application's default role for
workflows; the application's default role for logic functions; everyone
alone for webhooks, which carry no identity.
`resolveRecordShareGateKind` returns `inherited` for an INHERITED object
and the gate carries the record ids readable through parents next to the
share rows; `isRecordSharedWithPrincipals` became
`isRecordAdmittedByRecordShareGate`.
- The subscription publisher checks that a stream has a query on the
event's object before building its gate, so a batch no longer costs a
parent lookup per stream subscribed to other objects.

## Things to know

- A gated parent now costs one correlated EXISTS on the parent table per
candidate row instead of a share-row lookup keyed by the foreign key,
since the parent's predicate needs the parent row.
- An INHERITED object's events cost, per subject and per batch, one
gated query per parent object and per child object of that batch, and a
soft delete or destroy of such a record costs one read of its child rows
per child object at write time.
- The timeline entries exposing a private linked record's title (#25454)
remain a prerequisite before activation, as does a backfill of share
rows for the notes and tasks that exist unattached before the flag turns
on (they hold no parent and no share row, so the gate would hide them
from everyone).

## Tests

- Unit: `buildRowAccessPolicy` composition (open, denied by object
permission, role predicate alone, share rows, parents under their own
predicate, a parent the subject may not read dropped, owning
application), the condition builder on policy-shaped parents including
the trashed-with-the-record rule, the gate builder on INHERITED, the
gate kind; the mutation builder keeping a soft delete off trashed rows
unless `withDeleted`; the event formatter carrying the captured child
rows on the deleted and destroyed events of their record only; the
publisher skipping the parent lookup for a stream on another object and
stripping the capture from what it broadcasts; the logic function
payload without the capture.
- Integration, in the children spec: a member whose role cannot read
people no longer sees the private person's notes, targets and
attachments through it even with a share row; the event gate resolves,
for the member's subject, exactly the notes the member's query returns;
a note on the private person soft-deleted by the admin (who reaches it
through its company target) has its targets trashed with it, is
unreadable through its live links and readable through the captured
ones, and the admin restores it through the target trashed with it; a
note deleted after one of its targets was detached stays out of the
member's trash. In the note target hooks spec: a target detached before
its note keeps its own deletion time when the note is deleted.
2026-09-15 12:32:51 +00:00
github-actions[bot]andgithub-actions 064ac45abe i18n - website translations (#25960)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-09-15 14:26:25 +02:00
Thomas TrompetteandThomas Trompette 9c26006dbf feat(workflow): read the workflow show page shell through core (#25901)
## Problem

The workflow show page's *content* reads core behind the flag (#25669),
but its shell still went through the workspace record API for everyone:
the generic `useRecordShowPageResource` ran `findOneWorkflow` to feed
the record store, the page title and the layout renderer.

The first attempt branched on `nameSingular === workflow` inside
`useRecordShowPageResource`. That does not scale:
`useRecordShowPageResource` is workspace-record machinery, and every
core-owned object would add another branch to it. The switch belongs
higher.

## What this PR does

**A registry of core-owned show pages, resolved at the page level.**
`RecordShowPage` looks the route's `objectNameSingular` up in
`CORE_OBJECT_SHOW_PAGES` and, on a hit, renders that page instead of
mounting any workspace record machinery. On a miss it renders
`WorkspaceRecordShowPageContent`, unchanged. Adding the next core object
is one registry entry plus one page component; nothing in
`object-record` changes again.

The registry is a `Map`, not an object literal — a route segment is raw
user input, and an object literal would resolve `constructor` /
`toString` and render a prototype member as a page component.

**`RecordShowPageShell`** is the old `RecordShowPageContent`, extracted
verbatim and made object-agnostic: it takes `objectNameSingular`,
`objectRecordId`, `record`, `loading`, `error`. Both the workspace path
and any core page render it, so the two get the same header, title,
layout renderer and deleted banner.

**`WorkflowCoreShowPage`** is the first entry. It reads `coreWorkflow`,
maps the DTO onto the record-store shape through a pure util, and
renders the shell. `useRecordShowPageResource` keeps a generic `skip?:
boolean` (no workflow knowledge) so the core page can hold the workspace
read dormant behind the fallback below.

**Server:** `CoreWorkflowDTO` gains `createdAt` (the header renders it),
and the mirror now carries the workspace record's creation time onto the
core row instead of letting the column default. Without that, a core row
minted by a restore — or by the first mirror write for a workflow that
predates the mirror — is stamped `now()`, and a workflow created months
ago reads "Created 2 minutes ago". The mirror is self-healing: the next
write to any workflow corrects a row that already drifted.

## Fallback, not a flag

No flag on this path: the core mutation surface is considered ready,
`IS_WORKFLOW_CORE_INDEX_PAGE_ENABLED` stays scoped to the index and the
routes, and the read falls back to the workspace record whenever core
has no row. That covers both a not-yet-mirrored workflow and a
soft-deleted one (core keeps no soft-deleted rows), so the
deleted-record banner and the restore path work until teardown.

**Deletions that happen elsewhere.** Skipping the workspace read also
removed the only live query SSE could re-broadcast through, so a page
left open did not learn about a delete from another tab and never showed
the banner. The page now listens for delete and restore operations on
the workspace workflow and refetches the core query, which flips
`isCoreRecordAbsent` and un-skips the workspace read. Known gap: the
flow diagram goes blank in the deleted state and stays blank after
Restore until a reload, because `useWorkflowWithCurrentVersion` reads
its own `useFindOneRecord` without `withSoftDeleted` and nothing re-runs
it — tracked for a follow-up.

A core query *error* is not a missing row. An outage surfaces as an
error on the shell rather than silently falling through to the workspace
read, which would have made an outage look like success.

## Deliberate limits

- **The core record is narrower than a workspace record**, and it
replaces the stored record wholesale — `createdBy`, `position` and the
versions/runs/attachments relation sections have no data behind them.
The workflow layout ships only the visualizer widget, so nothing reads
them today; growing the DTO is the fix if a fields or relations widget
is ever added to workflows. Coming from the workflows table, the same
replacement drops fields the table had already loaded; the table
refetches on return.
- **SSE stays as is.** Workflow writes still land on the workspace
record first (writers invert at a later stage), so the existing
workspace-event subscription remains live and correct. SSE on core
events cannot exist until core writers emit events.
- **Prev/next pagination untouched.** `useRecordShowPagePagination`
still reads workspace records; the workspace read path stays functional
until teardown.
- **`SeeActiveVersionWorkflowSingleRecordCommand` untouched.** The
command is slated for removal, so its flag-on dead end is not worth a
core rewrite.

## Tests

`findCoreObjectShowPage` (hit, miss, undefined, prototype members),
`isCoreRecordAbsent` (settled-and-absent, loading, errored, row present)
and the `buildWorkflowShowPageRecordFromCoreWorkflow` mapping util — all
pure, no mocks.

---------

Co-authored-by: Thomas Trompette <tom@twenty.com>
2026-09-15 12:18:52 +00:00
github-actions[bot]andgithub-actions 0caaf77995 i18n - translations (#25958)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25958?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-09-15 14:14:32 +02:00
github-actions[bot]andgithub-actions 7f592e10a8 i18n - website translations (#25956)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25956?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-09-15 14:09:15 +02:00
neo773 560841a52c Read forwarded email bodies like the mailbox importers do (#25954)
Forwarded (inbound) emails only kept the plain-text part, so HTML-only
emails such as PayPal or Microsoft 365 notices were saved with an empty
body. The inbound parser now uses the same
`extractMessageTextWithoutQuotedHistory` pipeline as Gmail, Microsoft
and IMAP, which converts HTML when there is no plain-text part and
strips quoted history.

Integration test covers an HTML-only email and a reply with quoted
history.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25954?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-09-15 12:04:33 +00:00
Thomas des Francs 1ed1ca5583 Clarify self-hosted pricing CTAs and Organization license keys (#25941)
## Summary

Self-hosted pricing CTAs sent visitors to cloud signup, and the
Organization purchase flow called its license an Enterprise key. Pro’s
“Start for free” now links to installation docs; Organization’s “Get a
license” links to the Organization key purchase instructions.

Use Organization key/license wording throughout the purchase and
activation flow. Rename key-related source files to organization-key and
update their imports. Replace the documentation screenshot with the
current Organization key UI. English docs use organization-key.png; keep
the same image at the legacy path while Crowdin updates localized pages.
Localized documentation is left to the translation workflow. The pricing
link points to the “Get an Organization Key” section using
#get-an-organization-key.

Public API names, configuration identifiers, scheduled-job identities,
and the Enterprise settings route remain compatible. Configuration
variable renaming has been dropped.

## Before/After

| Surface | Before | After |
| --- | --- | --- |
| Self-hosted Pro | Start for free → cloud signup | Start for free →
installation docs |
| Self-hosted Organization | Start for free → cloud signup | Get a
license → Organization key docs |
| Purchase and activation | Enterprise key/license | Organization
key/license |
| Key filenames and new links | enterprise-key | organization-key |

Cloud signup and Enterprise’s Talk to sales remain unchanged.
2026-09-15 12:04:15 +00:00
github-actions[bot]andgithub-actions 1b2c3d4a1c i18n - translations (#25955)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-09-15 13:59:08 +02:00
github-actions[bot]andgithub-actions 879e2637dc i18n - translations (#25953)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25953?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-09-15 13:50:38 +02:00
github-actions[bot]andgithub-actions 0f518812e7 i18n - translations (#25952)
Created by Github action

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25952?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-09-15 13:42:25 +02:00
neo773andFélix Malfait 74f05e1f28 Enforce blocklist scope and surface the workspace blocklist in settings (#25007)
Builds on the `blocklist.scope` field to make workspace-wide blocking
actually work.

**Backend.** A pre-query hook per blocklist mutation, with every scope
and ownership rule in `BlocklistValidationService`: a workspace-scoped
entry cannot target a member, a member-scoped one must target its own,
scope and owner are immutable after creation, and workspace-scoped
mutations require the `WORKSPACE` permission flag. Bulk mutations are
rejected outright. The delete and reimport jobs and both import services
now fan out twice, once for the workspace and once per member.

`MessagingBlocklistListener` was firing the reimport on `CREATED`, where
there is nothing to bring back. It now fires on `DELETED`, so unblocking
a handle re-imports it.

**Frontend.** Adds the workspace-wide blocklist table to Communication
settings and moves the workspace email controls out of Security. That
page no longer hides behind `IS_EMAIL_GROUP_ENABLED` — otherwise the
blocklist settings are unreachable unless an admin flips the flag. The
email group sections render as disabled "Soon" cards while the flag is
off.

Handle uniqueness stays in `BlocklistValidationService`, matching how
member-scoped handles were already enforced.

```ts
blocklist/query-hooks/                         entry point, one hook per mutation
  {create,update,delete,destroy,restore}-one   execute(authContext, _objectName, payload)
  {create,update,delete,destroy,restore,merge}-many
                                               execute()  -> always throws, no bulk
  utils/build-blocklist-mutation-context-or-throw.util.ts  NEW
    -> { workspaceId, workspaceMemberId, userWorkspaceId }

blocklist-validation.service.ts                every rule lives here
  validateBlocklistFor{CreateMany,UpdateOne,RestoreOne}({ payload|id, context })
  validateBlocklistRecordIsManageable({ id, context })
  assertCallerCanCreateEntry({ item, context })          scope vs owner
  assertHasWorkspaceBlocklistPermission(context)         PermissionFlagType.WORKSPACE
  assertScopeAndOwnerAreUnchanged({ data, existingRecord })
  validateUniquenessForCreateMany({ entries, context })

blocklist.repository.ts
  getWorkspaceScopedEntries(workspaceId)
  getMemberScopedEntries({ workspaceMemberId, workspaceId })
  getEntriesApplicableToWorkspaceMember({ workspaceMemberId, workspaceId })
    null member -> workspace-scoped only
utils/group-blocklist-handles-by-owner.util.ts  NEW
  -> { workspaceScopedHandles, handlesByWorkspaceMemberId }

messaging-blocklist.listener.ts
  DELETED -> ReimportMessagesJob      was bound to CREATED, so unblock did nothing
jobs: {messaging-item-delete,messaging-reimport,calendar-reimport}
  each fans out twice: once workspace-wide, once per member

{messaging-messages,calendar-events}-import.service.ts
  -> getEntriesApplicableToWorkspaceMember

twenty-front
  useSettingsNavigationItems.tsx               Communication no longer flag-gated
  SettingsWorkspaceCommunications.tsx          renders without the flag
  SettingsWorkspaceBlocklistSection.tsx   NEW
  SettingsWorkspaceEmailSyncSection.tsx   NEW  moved out of Security
  SettingsWorkspaceEmailGroupSection.tsx       flag off -> disabled "Soon"

tests
  blocklist-scope-hooks.integration-spec.ts    NEW  13
  blocklist-cleanup.integration-spec.ts             2 -> 4
  group-blocklist-handles-by-owner.util.spec.ts NEW 4
```


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25007?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-09-15 11:41:33 +00:00
Thomas TrompetteandThomas Trompette 8e106f7238 feat(workflow): discard a workflow draft through the core API (#25902)
## Problem

Discarding a workflow draft went through the generic workspace record
delete on `workflowVersion` (`useDeleteOneWorkflowVersion`), followed by
hand-rolled Apollo cache surgery on the workspace `workflow` object.
Create and delete already run through the core API (#25626); discard was
the missing core-owned mutation on that surface.

## What this PR does

**Server** — `discardCoreWorkflowDraft(input: {
workspaceWorkflowVersionId })` on `CoreWorkflowResolver`:

- loads the version `withDeleted`; a never-existed id returns `null`,
and an already-soft-deleted one re-runs the core-row cleanup before
returning `null`, so a retry after a crash between the two writes
genuinely converges instead of leaving an orphaned core row,
- asserts it is a DRAFT with the existing
`assertWorkflowVersionIsDraft`, refuses discarding a workflow's only
remaining version (the same guard the workspace delete pre-hook
enforces), and the softDelete carries a `status: DRAFT` predicate whose
`affected` count is checked, so a version published between the
validation read and the delete is refused rather than having its core
row removed under a published workspace row,
- soft-deletes the workspace row, removes its core version row
synchronously via the existing
`deleteCoreVersionsByWorkspaceVersionIds`, and returns the refreshed
`CoreWorkflowDTO` (statuses are derived from the remaining core version
rows, so the DRAFT chip disappears without cache surgery).

The argument is the workspace version id because every core resolver
argument is workspace-shaped today; they all flip to core ids together
in a later stage.

`WorkflowQueryValidationException` now maps to a GraphQL Forbidden on
this resolver via a new
`WorkflowQueryValidationGraphqlApiExceptionFilter` — without it the
refusals surfaced as internal errors and landed in Sentry.

**Front** — `useDiscardCoreWorkflowDraft` calls the mutation on the core
client, then runs the same workspace-cache eviction the flag-off path
runs (extracted from `useDeleteOneWorkflowVersion` into a shared
`useEvictDiscardedDraftFromWorkflowCache` — without it the stale DRAFT
status kept the command visible). That hook stamps `deletedAt` on the
cached workflowVersion record as the old record delete did, which is
what `useEffectiveDraftVersionId` reads, before pruning the parent
workflow's `versions` and `statuses`; otherwise the editor stays on the
discarded draft until SSE lands, then invalidates the core caches.
`DiscardDraftWorkflowSingleRecordCommand` uses it unconditionally — the
mutation writes both sides with stricter guards than the record delete
it replaces, so the flag adds nothing here and stays scoped to the index
surface. `useDeleteOneWorkflowVersion` had no other caller and is
removed; its cache eviction lives on as the shared hook.

**Accepted difference:** the softDelete runs under the system auth
context, so the version-deleted timeline event attributes to system
rather than the acting user — same as every core mutation since #25626;
unified when writers invert.

Generated GraphQL artifacts for the new mutation are regenerated and
committed (front data schema; the metadata client is unaffected since
the mutation lives on the core schema).

## Tests

Integration spec `core-workflow-discard-draft.integration-spec.ts`:
activates the initial version, creates a real draft from it, discards it
— asserting at the database level that the workspace row is soft-deleted
and the core row gone, and that the returned DTO drops the DRAFT status;
a second discard returns null; discarding a workflow's only version is
refused.

---------

Co-authored-by: Thomas Trompette <tom@twenty.com>
2026-09-15 11:35:33 +00:00
Thomas TrompetteandThomas Trompette b304b7e78e feat(workflow): remove the See Active Version command menu item from workspaces (#25938)
## Problem

`SeeActiveVersionWorkflowSingleRecordCommand` navigates to the workspace
`workflowVersion` show route. `IS_WORKFLOW_CORE_INDEX_PAGE_ENABLED`
hides that route, so with the flag on the command lands on
`WorkspaceRouteUnavailable` — a dead end. Rather than rewrite it against
core, we are dropping the command.

## What this PR does — the expand half only

- Removes `seeActiveVersionWorkflow` from `STANDARD_COMMAND_MENU_ITEMS`,
so new workspaces never get it.
- Adds `upgrade:2-41:remove-see-active-version-command-menu-item`, which
deletes the existing row from every provisioned workspace through a
system workspace migration.
- Drops the item from the command menu mock fixture.

After this ships and the upgrade command runs, no workspace has the item
and the dead end is gone for both flag states.

## Why the enum value and the component stay

The first version of this PR also deleted
`EngineComponentKey.SEE_ACTIVE_VERSION_WORKFLOW`, the front component
and its hook. That breaks the command menu for every existing workspace
during the deploy window, and not subtly:

`CommandMenuItemDTO.engineComponentKey` is declared `@Field(() =>
EngineComponentKey)` — non-null — and `commandMenuItems` returns
`[CommandMenuItemDTO!]!`. With the enum member gone, graphql-js cannot
serialize a row whose column still holds
`'SEE_ACTIVE_VERSION_WORKFLOW'`, and the field error propagates through
the non-null item to the non-null list. The entire query resolves to
`null`, so the workspace loses its whole command menu — not one item —
from the moment the new server boots until the upgrade command finishes.
For self-hosters that is however long they take to run `upgrade`.

Reproduced locally on this branch: with one row carrying the removed
key, `commandMenuItems` returned `data.commandMenuItems: null` with
`Enum "EngineComponentKey" cannot represent value:
"SEE_ACTIVE_VERSION_WORKFLOW"`. Restoring the row returned 115 items and
no errors.

So the enum member, the front component and `useActiveWorkflowVersion`
stay for now and become dead code that nothing can reach. Deleting them
is the contract half, and belongs in 2.42 once the rows are gone
everywhere.

## Upgrade command

It looks the item up by universal identifier in the workspace's own flat
maps rather than in the standard application definition, since the
standard definition no longer contains it after this PR. A missing item
logs and skips, which makes the command idempotent. When the identifier
does resolve, the command asserts the item carries
`SEE_ACTIVE_VERSION_WORKFLOW` before deleting, so a wrong identifier
that happens to match some other item throws instead of deleting it.

Verified locally against both seeded workspaces: the real run removed
the item from the one that had it and skipped the one that did not, and
`core."commandMenuItem"` rows for `31790508-75ff-4e4c-a768-83bd1b0718e0`
went to 0 with `commandMenuItems` still returning 115 items.

## Generated files

The command menu item mock fixture was edited by hand rather than
regenerated. `mock:generate` rebuilds the fixture from whatever the
local dev database holds, which pulled in unrelated ids, application ids
and a locally created workflow version; a 22-line deletion of the one
object is the accurate change.

No GraphQL schema change in this PR, so no codegen.

## Worth a second opinion

With the flag off, "See Active Version" works today and this removes it.
The nearest replacement, "See Versions History", opens the version list
rather than jumping straight to the active version, so this is a small
product removal rather than a pure dead-code cleanup.

---------

Co-authored-by: Thomas Trompette <tom@twenty.com>
2026-09-15 11:33:08 +00:00
Raphaël Bosi a3ba7be23d Add live previews to Twenty UI documentation (#25889)
Add embedded Storybook previews to all 17 Twenty UI component
documentation pages, with light and dark themes and interactive
examples.

On `main` and SDK or UI release tags, `CD Storybook UI` builds the
public Storybook, checks that the documentation's embedded story IDs
exist, and uploads the static site as a GitHub Actions artifact.

After the build, it dispatches `deploy-storybook-ui.yaml` in
`twenty-factory` with the source run ID. Factory validates the
successful source run, downloads its artifact, and publishes the static
files to Cloudflare Pages without executing the downloaded site.
Cloudflare credentials stay in factory's `storybook-ui` environment.

`main` updates the production site; release tags publish version aliases
such as `v2-42-0.twenty-ui-storybook.pages.dev`.

Merge [twenty-factory
#120](https://github.com/twentyhq/twenty-factory/pull/120) first and
complete the Cloudflare project, token, and GitHub environment setup in
its description before merging this PR. That description also covers
validating the first deployment and switching `storybook.twenty.com` to
Pages.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25889?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-09-15 11:24:07 +00:00
github-actions[bot]andgithub-actions 99d5cf1928 i18n - translations (#25950)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-09-15 12:59:24 +02:00
neo773 8c283d46e8 Remove the email group flag and gate campaigns behind their own flag (#25916)
Rolls out the shared mailbox (inbound forwarding + replying from the
group handle) to everyone while campaigns stay hidden.

```
Before:  IS_EMAIL_GROUP_ENABLED       -> shared mailbox + campaigns

After:   (no flag)                    -> Communication page, email groups, outbound domains, replies
         IS_MESSAGE_CAMPAIGN_ENABLED  -> campaigns, lists, unsubscribe, suppressions
```

An upgrade command moves the campaign and list command menu items
already stored in existing workspaces from IS_EMAIL_GROUP_ENABLED to
IS_MESSAGE_CAMPAIGN_ENABLED.
2026-09-15 10:50:04 +00:00
github-actions[bot]andgithub-actions 9032c46a9d i18n - translations (#25949)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-09-15 12:14:30 +02:00
Félix Malfait f1001eeb38 fix(messaging): resolve an app channel's connectedAccount for app contexts (#25942)
Follow-up to #25903, which is where an application first gets to hold a
`MessageChannelDTO` and so first meets this edge. Raised separately
rather than folded into the stack — it is not really about apps owning
channels, it is about app contexts reading connected accounts, which is
the same question `appConnections` faces.

## What breaks today

`MessageChannelResolver.connectedAccount` takes `@AuthUserWorkspaceId()`
with no `allowUndefined`, and resolves every non-`EMAIL_GROUP` channel
through user ownership. Two distinct failures follow, and the second is
the worse one:

1. **No user at all** — cron, webhook, install hook. The decorator
throws before the body runs: *"This endpoint requires a user context.
API keys are not supported."* That is not even the right diagnosis for
an application token.
2. **A user is present but the connection is app-owned.** Those rows
have `userWorkspaceId: null`, so `findByIdAndUserWorkspaceId` matches
nothing and the field comes back `null` **with no error at all**.
Silent.

Nothing on the supported path hits either: the SDK's
`APP_MESSAGE_CHANNEL_SELECTION` selects `connectedAccountId` (the
scalar), not `connectedAccount` (the relation). This only bites someone
hand-writing a GraphQL query — which is why it was filed as a known gap
on #25903 rather than fixed there.

## Approach

The branch **delegates** rather than re-deriving reachability:

```ts
if (isDefined(application)) {
  const account =
    await this.applicationMessageChannelsService.findReachableConnectedAccount({
      applicationId: application.id,
      workspaceId: workspace.id,
      requestUserWorkspaceId: userWorkspaceId ?? null,
      connectedAccountId: messageChannel.connectedAccountId,
    });

  return isDefined(account) ? buildPublicConnectedAccount(account) : null;
}
```

That predicate already exists and already answers exactly this question
— owned by this application, `provider = APP`, and **not** hidden from
the request user. That last clause matters: an `APPLICATION_ACCESS`
token can carry a user, and owning the app is not the same as being
allowed to read another member's private connection. Writing a second
ownership rule in a field resolver is how the two drift apart, so
`findReachableConnectedAccount` is promoted from private to public and
there stays exactly one implementation.

`@AuthApplication({ allowUndefined: true })` is the shape
`updateMessageChannel` two methods below already uses, so this is the
file's own convention rather than something new.

## Scope

Only the APP path changes:

- `EMAIL_GROUP` keeps its existing bypass untouched.
- The email path still resolves through `findByIdAndUserWorkspaceId` on
user ownership.
- An application-less call with no user now returns `null` instead of
reaching a lookup with `undefined` — unreachable from a user session,
where the decorator still guarantees a user.

## Testing

Five specs, built by direct construction rather than a Nest testing
module, matching `workspace-setup-chat.resolver.spec.ts`: a run with
nobody behind it resolves the account, the predicate's refusal surfaces
as `null`, the request user is forwarded rather than dropped when a
member triggered the run, and both the email and `EMAIL_GROUP` paths are
unchanged.

Three of the five fail against the pre-fix resolver — I checked, rather
than assuming they would.

**One limitation worth stating:** the specs call the resolver method
directly, so they exercise the branching but **not** the decorator
relaxation itself. `allowUndefined` is what actually stops the throw in
production, and that is only covered end-to-end.

Typecheck clean; 54 message-channel tests pass; oxlint and oxfmt clean.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01YKY5M2m4HSvQvNDNUprejZ)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/25942?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-09-15 12:12:07 +02:00
Félix Malfait 7df3651e5e test(messaging): cover the app-facing message channel API end to end (#25945)
Follow-up to #25903 / #25905 / #25906, which shipped the app-facing
channel and ingestion API with unit specs built on DI mocks. This adds
the integration coverage the quality bot asked for on both of those PRs.

## Why this and not a test app

The ask was "a test app leveraging channels that we should add in CI". I
went looking for the app-shaped version first, and it does not work —
for two separate reasons, either of which is fatal on its own.

**1. An app cannot create the connection its channel needs.**
`createAppMessageChannel` requires a `connectedAccountId` the calling
application owns. The only surface that mints one is
`connection-provider-oauth-flow.service.ts`, and
`ConnectionProviderManifest.type` is `'oauth'` and nothing else — so the
row is the product of a real browser round trip with an upstream
provider. The app-facing `appConnections` API is read-only (`list`,
`get`, `reportAuthFailure`). Slack's tests work around this by using a
made-up id in hook payloads, which never touches the database. There is
no in-CI path to a real app-owned connection.

**2. App integration tests do not run on server PRs.**
`ci-twenty-apps.yaml` discovers with `changed-only: true`, so an app's
`yarn test` only runs when that app's own files change. A server-side
regression in the channels API would not be caught by it. What *does*
run on every server PR is `server-apps-install-smoke`, and that is
`installation-only` — deploy and install, no test suite. Separately, the
integration job's `dockerhub-latest` leg runs against the latest
published image, which by construction does not have an unreleased API,
so a `test` script touching these mutations would be red until a release
ships.

So an example app would be a demo that proves nothing in CI, gated
behind a connection it cannot create. A server integration spec runs on
**every** server PR in the sharded matrix, and can mint the connection
fixture the same way
`report-app-connection-auth-failure.integration-spec.ts` already does.

Worth saying out loud, because it is the more interesting finding:
**`type: 'oauth'` being the only connection provider kind is also a
product gap for the LinkedIn case that started this.** An extension
holding its own session credential has nowhere to put them. That is a
separate conversation, not this PR.

## What is covered

One new suite, `app-message-channels.integration-spec.ts`, driving the
real GraphQL API with a real `APPLICATION_ACCESS` token. Two
applications are synced, each with a connection provider, and three
connections are inserted: one the caller owns, one owned by the other
app, one owned by the caller but private to a different member.

**Channel lifecycle** — create on an owned connection (including that
`displayName` is trimmed), list, update the three mutable fields,
delete.

**Ownership boundaries** — creating on another app's connection and on
another member's private connection are both `FORBIDDEN`; a list
filtered by a connection the app does not own is `FORBIDDEN`; another
app's token updating the channel gets `NOT_FOUND` rather than a message
confirming it exists. Omitting `visibility` is rejected rather than
defaulted. The unfiltered list runs with a channel the *other*
application owns already present, so the "and nothing else" half of that
assertion has something real to exclude.

**Ingestion** — two messages of one conversation land in a single thread
with distinct message ids and the body persisted; a redelivered
`externalId` returns the identical ids and leaves exactly one row (the
idempotency #25905 added); direction is derived from the sender and
matches the channel handle **case-insensitively**, which is the
regression test for the change made on #25905 that had no coverage; and
the refusals: another app's channel, a channel with sync turned off, a
message without exactly one `FROM`, a participant pointing at a person
that does not exist, and a batch of `INGEST_APP_MESSAGES_MAX_BATCH_SIZE
+ 1` which persists nothing.

## What is not covered

The token is minted from an admin session, so it carries a
`userWorkspaceId`. The cron / webhook / install-hook shape — an
application context with **no** user — is the one auth shape this suite
cannot produce, and it is exactly what #25942 fixes. Once that merges I
will add the `connectedAccount` field-resolver case here; it is the
end-to-end gap #25942's own description calls out as uncovered.

## Testing

Typecheck, oxlint and oxfmt clean. The suite could not be run locally,
so CI was its first execution — it landed in integration shard 3 and
passed there:

```
PASS test/integration/metadata/suites/message-channel/app-message-channels.integration-spec.ts (6.727 s)
Test Suites: 41 passed, 41 total
Tests:       287 passed, 287 total
```

All sixteen integration shards are green.
2026-09-15 12:10:35 +02:00