mirror of
https://github.com/twentyhq/twenty.git
synced 2026-09-16 07:56:04 -04:00
c-async-csv-export-server
15532
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f0492f94cf | Share CSV export cleanup job options | ||
|
|
bf28af8cb9 |
Merge branch 'c-csv-export-shared' into c-async-csv-export-server
# Conflicts: # packages/twenty-client-sdk/src/metadata/generated/types.ts |
||
|
|
79f00bbd06 | Merge commit 'ef6afd5a20' into c-csv-export-shared | ||
|
|
5f0faa94b7 | Cover CSV export column mapping edge cases | ||
|
|
e0ac181852 | Merge branch 'c-csv-export-shared' into c-async-csv-export-server | ||
|
|
75175ff555 | Merge remote-tracking branch 'origin/main' into c-csv-export-shared | ||
|
|
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. |
||
|
|
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. |
||
|
|
563eb2886f | Fix CSV export connection and download lifecycle | ||
|
|
3033965952 |
i18n - translations (#25990)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
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.
|
||
|
|
5097f92ecd | Add asynchronous CSV export pipeline behind a feature flag | ||
|
|
9d3fd32616 | Share CSV formatting and composite field labels | ||
|
|
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> |
||
|
|
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`. |
||
|
|
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> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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> |
||
|
|
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. --> |
||
|
|
89084cbc92 |
i18n - translations (#25973)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
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>``<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. --> |
||
|
|
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. |
||
|
|
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`. |
||
|
|
bd74b1e121 |
i18n - translations (#25971)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
466b76cfdc |
i18n - website translations (#25967)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.  </details> |
||
|
|
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. |
||
|
|
064ac45abe |
i18n - website translations (#25960)
Created by Github action Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. --> |
||
|
|
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. |
||
|
|
1b2c3d4a1c |
i18n - translations (#25955)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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. --> |
||
|
|
99d5cf1928 |
i18n - translations (#25950)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
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.
|
||
|
|
9032c46a9d |
i18n - translations (#25949)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
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. --> |
||
|
|
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. |