7e00298044b4a69163c89554d5238365b51929b2
14208 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7e00298044 |
Rename credits "Increase" button to "Manage" (#23840)
The green primary button in the Credits section was labelled "Increase"
with an up-arrow icon, but it opens the credit package picker, whose
slider spans every available package including smaller ones. So the
label promised upgrade-only while the modal supports both directions.
Renamed it to "Manage" and swapped the up arrow for `IconAdjustments`
(sliders), matching the slider-based picker it opens.
The secondary shortcut buttons ("Increase to $100", "Increase to $200")
are unchanged since those really do apply an upgrade directly.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01CmtdBmWL9eSD3ZX7tgzisZ)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23840?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. -->
|
||
|
|
692c0c8402 |
i18n - translations (#23844)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
647a6aec58 |
Add nested relation Field widgets on record page layouts (#23815)
## Context Record pages can show a list of directly related records (Field widget in Table display mode), but not records two relation hops away. The canonical ask: on a Client page, list the Transactions of the Client's Wallets. Stacked on #23814 (merged) and #23832 (merged); their commits are included in this branch. #23836 stacks on this PR to add many-to-one first hops. ## How it works The 2-hop case does not need any new query capability. It reuses the relation traversal filter shipped for advanced filters: the widget embeds a view on the terminal object (Transaction) with one seeded filter `inverse relation IS current record`, traversed one hop (`fieldMetadataId` = Transaction.wallet, `relationTargetFieldMetadataId` = Wallet.client, value `isCurrentRecordSelected`). At query time this compiles to `{ wallet: { clientId: { in: [currentRecordId] } } }`, which is within the backend's `MAX_RELATION_FILTER_DEPTH = 1` since the second hop lands on the join column. Records from all intermediate records (all wallets of the client) are listed, so one-to-many fan-out on the first hop works out of the box. ## Changes Configuration - `FieldConfiguration` gains an optional `nestedRelationFieldMetadataId` (shared type, DTO, GraphQL fragment, regenerated metadata types). Backward compatible: existing widgets are untouched. UI - The Field picker drills into one-to-many relation fields, mirroring the advanced filter submenu pattern: back header, an entry to select the relation itself (previous behavior), then the target object's one-to-many relations. Selecting a nested field creates a widget titled `First hop → Second hop` in Table display mode. First-level rows that open a submenu never show the checkmark; the selected chain is only visible inside the submenu, matching the chart group by field selection. - The layout dropdown, settings panel and renderer resolve the terminal object of the chain; a widget whose second hop was deleted or deactivated renders nothing instead of silently showing first-hop records. - Nested widgets only offer embedded view layouts (Table / Kanban / Calendar), since inline display modes would render the first hop's relation field. - The relation table view resolver regenerates the embedded view whenever the selection results in a table widget and the chain changed or the view id is missing, so a table widget can never carry a view belonging to a different chain. Server - `FieldConfigurationDTO` accepts the new optional field. - Both universal configuration mappers (to and from universal identifiers) carry it for app manifest sync. - New `validateFieldConfigurationNestedRelationOrThrow` enforces that both hops are active one-to-many relation fields on the right objects, wired next to the existing chart field reference validation. Record creation - `buildRecordInputFromFilter` skips relation-traversal filters: they constrain a related record's column, so prefilling the created record's own foreign key from them would link the wrong record (e.g. `walletId = clientId`). - Add New in a nested widget table instead prompts for the record to create through: the row opens a picker listing the current record's first-hop records (the client's wallets), scoped with a find filter on the relation join column, and creates the record with the picked id prefilled. Covers the plain table and per-group add rows. Board and calendar layouts hide their create buttons in nested widgets since they cannot know the record to create through. - Matching the created record against the widget's traversal filter client side is handled by #23832. Out of scope, deliberately: depth stays at exactly two levels (matches the backend filter depth cap), junction and morph relations are not drillable, and chart widgets on record pages are untouched. ## Tests - Unit: nested chain resolution util, draft view seeding with the traversal filter, view id change resolver, picker parameter derivation, server-side validation. Full `page-layout` and `record-filter` front suites pass (187 suites / 1233 tests), server `page-layout-widget` suites pass. - Manual, on seeded data: created a `People → Opportunities` widget on a Company page; it lists exactly the opportunities whose point of contact belongs to that company, persists across save and reload, and scopes per record. Add New opens a picker showing only that company's people; picking one creates an opportunity with `pointOfContactId` set (verified in DB) and the row appears in the widget immediately. --- _Generated by [Claude Code](https://claude.ai/code/session_01Xp3AgGtc4kSP8PpgpKMWLQ)_ <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23815?utm_source=github" rel="nofollow noreferrer noopener" target="_blank">``<img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a> |
||
|
|
9480513689 |
chore: sync AI model catalog from models.dev (#23839)
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev). This PR updates pricing, context windows, and model availability based on the latest data. New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically. Deprecated models are detected based on cost-efficiency within the same model family. **Please review before merging** — verify no critical models were incorrectly deprecated. Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com> |
||
|
|
c3d0f021b7 |
Fix client-side record matching for nested relation filters (#23832)
## Problem
`isRecordMatchingFilter` assumes every filter keyed by a relation field
name is a flat UUID filter on the related record id. Filters that
traverse a relation, like
```
{ pointOfContact: { companyId: { in: [companyId] } } }
```
(produced by view filters carrying `relationTargetFieldMetadataId`, such
as the seeded filter of a nested relation Field widget in #23815), make
it throw `Unexpected value for UUID filter`. The throw happens inside
the create and update optimistic effects, so creating a record from a
view seeded with such a filter aborts before anything is written.
## Fix
When the value under a relation field name holds related record field
names (or `and`/`or`/`not` composites) instead of UUID operators,
recurse into the related record with the relation target's object
metadata. A related record missing from the payload, or a list relation,
conservatively does not match. Flat UUID filters on the relation name,
join column filters and morph relations keep their existing behavior.
`isRecordMatchingFilter` now takes `objectMetadataItems` to resolve the
relation target metadata. The optimistic effect call sites already had
it in scope; it is threaded through the two group-by helpers.
## Tests
- New `Nested Relation Filters` cases: match, no match, related record
not loaded, composite conditions, list relation.
- Existing suites updated for the added parameter; record-filter and
optimistic-effect suites green, typecheck green.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Xp3AgGtc4kSP8PpgpKMWLQ)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23832?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. -->
|
||
|
|
5ada3adfd5 |
i18n - translations (#23834)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
dac5d5a23a |
Extend live update coverage: workflow draft updates and side-panel records (#23830)
Stacked on #23811. Closes the two remaining live-update coverage gaps found while auditing record-seeded editing surfaces. ## Workflow diagram misses updates to the current draft version `WorkflowSSESubscribeEffect` triggered a content refetch only on `create-one` of a workflow version (new draft created) and on SSE reconnection. Step and trigger edits on the existing draft arrive as `update-one` events and left the open diagram stale until a refresh, which is the common case when the AI chat or a teammate edits a draft workflow. Refetch on `update-one`/`update-many` too. Local workflow mutations do not dispatch these browser events (they only originate from SSE deliveries), and an own-persist echo reseeds the diagram with the state it already shows, so this does not fight local editing. ## Side-panel records receive no SSE events `SidePanelRecordPage` registered no SSE query, so a record opened in the side panel (notes and tasks most commonly) got no events for itself unless another surface happened to subscribe to a matching query. With #23811's rich text adoption this mattered doubly: events could not reach the editor at all. Register the record query like `RecordShowPage` does. The subscribe effect takes a `queryScope` so the record page and side panel keep independent registrations when they display the same record, and closing one surface does not unsubscribe the other. --- _Generated by [Claude Code](https://claude.ai/code/session_018nGvGhFahw1pcefb3P4iCk)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23830?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. --> |
||
|
|
393e62ba9f |
Make AI chat streaming render cost independent of message length (#23831)
Follow-up to #23573: chip-heavy answers are long by design, and each stream flush re-ran `protectChatReferencesForMarkdown` and `marked.lexer` over the whole message, so render cost grew quadratically with message length. This makes the per-flush cost proportional to the appended text instead, and offsets the new code by removing dead AI chat code. ## Streaming render - **Incremental block splitting.** `getMarkdownBlocksIncrementally` reuses blocks that can no longer change and re-tokenizes only the trailing ones. Two trailing blocks stay unstable, not one: a loose list followed by a blank line still merges with a later item (`- a\n\n` + `- b` is one list token). Uses `Lexer.blockTokens` instead of `marked.lexer` since only block raws are needed and the full lexer also runs the inline tokenizer. Simulated stream over a 22 KB chip-heavy message (120 chars/flush, matching the 100 ms flush throttle): 191 ms → 3.7 ms cumulative. The test suite pins char-by-char equivalence against full `marked.lexer` output across loose lists, unclosed fences, setext headings, tables, CRLF and chip markers. - **Per-block reference protection.** `protectChatReferencesForMarkdown` moved behind the existing block memo, so settled blocks never re-run reference parsing during a stream. - **Anchored open pattern.** `(?<!\[)\[\[+` anchors marker matching to the start of a bracket run. The greedy `+` from #23798 backtracked at every position inside a run, once per alternative: 429 ms → ~1 ms on a 10 KB bracket-run input. A run start always yields the same match, so no valid marker is lost. Also an `includes('[[')` bail-out in `findChatReferences`, which runs on every text node of the streaming block. ## Chip lookups `fieldMetadataItemByIdSelector` did `objectMetadataItems.find(obj => obj.fields.some(...))` per chip — O(workspace fields) each time the agent's tool calls trigger a metadata refetch mid-chat. The by-id and by-name map selectors mostly already existed with almost no consumers; this wires `fieldMetadataItemByIdSelector`, `objectMetadataItemFamilySelector` and `viewFromViewIdFamilySelector` to them (adding the missing `objectMetadataItemsByIdMapSelector` and `viewsByIdMapSelector`) and adds `areEqual` so unchanged lookups keep referential stability. ## Offscreen messages Settled messages (everything except the streaming last one) get `content-visibility: auto`, so long threads skip layout and paint for messages scrolled out of view. `contain-intrinsic-size: auto` keeps remembered heights, so scroll positions stay accurate once a message has been painted. ## Removed `ReasoningSummaryDisplay`, `agentChatMessagesComponentState`, `CHAT_THREADS_PAGE_SIZE`, `AgentResponseFormat` and `getFieldIcon` had no consumers. `TextWithChatReferences` and `protectChatReferencesForMarkdown` shared a duplicated segment-slicing loop, now in `getChatReferenceSegments`, and the nine identical per-tag markdown component entries collapse into `createChatReferenceElement`. The branch lands at +354/−329 including the new test suite; production code is net negative. Incidental: `marked` added to jest's `transformIgnorePatterns` allowlist (ESM-only, previously imported by no test). --- _Generated by [Claude Code](https://claude.ai/code/session_01MN8FVc63J4SJQHXWwzUwkh)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23831?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. --> |
||
|
|
c35e364562 |
Fix AI campaign editing: tool auth context in workers and live editor resync (#23811)
Fixes two issues reported when creating an email campaign through the AI chat panel. ## 1. `save_campaign` failed with "Workspace auth context not set" The AI chat streams inside a queue worker job, where no HTTP middleware populates the async-local workspace auth context. The new `MessageCampaignDraftService.saveDraft()` relies on `executeInWorkspaceContext()`'s fallback to `getWorkspaceAuthContext()`, which throws outside HTTP requests. Database CRUD tools worked because `dispatchDatabaseCrud` builds an auth context explicitly; static tools had no equivalent. **Fix:** `ToolExecutorService.dispatch` (the single choke point for all tool executions: preloaded chat tools, `execute_tool`, MCP, workflow agents) now resolves the acting identity once, reusing a provided auth context or building a user context from `userId`/`userWorkspaceId`, and runs the dispatch inside `withWorkspaceAuthContext()`. This mirrors what `WorkspaceAuthContextMiddleware` does for HTTP requests, so tool code can rely on the async-local context on every transport. Side benefit: metadata tools executed from chat previously emitted metadata events with no user attribution (`MetadataEventEmitter` swallows the missing context); they are now attributed correctly. ## 2. AI changes to the open campaign required a page refresh The SSE pipeline delivers worker-originated record updates to the Apollo cache correctly. The campaign editor ignored them: `usePersistedCampaignDraft` seeds local draft state from the record once, and the subject/body/list inputs are uncontrolled (TipTap reads `defaultValue` on mount only). **Fix:** the draft hook now adopts upstream record values while the draft is pristine and exposes a `draftResyncKey` that remounts the `defaultValue`-seeded inputs. Unsaved local edits win over concurrent remote changes (last write wins on flush), and echoes of our own debounced persists never remount inputs mid-typing. ## Tests - `tool-executor.service.spec.ts`: auth context exposed to static tools, provided-context reuse, no-identity passthrough, no context leakage after dispatch, CRUD receives the resolved context, CRUD still rejects without identity. - `usePersistedCampaignDraft.test.tsx`: adopt-when-pristine, own-echo stability, dirty-draft-wins, adopt-after-persist. - `lint:diff-with-main` and `typecheck` clean on both packages. --- _Generated by [Claude Code](https://claude.ai/code/session_018nGvGhFahw1pcefb3P4iCk)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23811?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. --> |
||
|
|
dc8c62a7a9 |
Add OpenTelemetry metrics to workspace migration build and run (#23797)
## Context ## AI generated graph goal example <img width="2078" height="914" alt="image" src="https://github.com/user-attachments/assets/14c760df-5ac8-4526-a5e0-40b57344be90" /> <img width="2158" height="1850" alt="image" src="https://github.com/user-attachments/assets/2324779e-9ab8-47fc-a014-78269a78823e" /> The workspace migration builder and runner already log phase timings via `logger.perfTime`, but those logs are only useful for local debugging: they're gated behind the `performance` log level and never leave the process. This PR routes the same timings into the existing `MetricsService` (OpenTelemetry) so migration performance can be tracked over time and regressions detected. ## What's recorded All metrics are histograms recorded via `MetricsService.recordHistogram`, which is a synchronous in-memory aggregation - export happens on the background OTel readers (Prometheus scrape or periodic OTLP push), so nothing is added to the migration hot path. When `METER_DRIVER` is unset, records are no-ops. | Metric | Where | Attributes | |---|---|---| | `workspace-migration/build-duration-ms` | `WorkspaceMigrationValidateBuildAndRunService` | `status`: `success`, `fail` (validation errors), `error` (builder threw) | | `workspace-migration/build-entity-duration-ms` | `WorkspaceEntityMigrationBuilderService.validateAndBuild` | `metadataName`, `status` | | `workspace-migration/build-entity-phase-duration-ms` | entity builder | `metadataName`, `phase`: `matrix-computation`, `deletion-validation`, `creation-validation`, `update-validation` | | `workspace-migration/run-duration-ms` | `WorkspaceMigrationRunnerService.run` | `status`: `success`, `fail` - recorded on every exit path, including pre-transaction failures (DDL locked, cache retrieval, application not found) | | `workspace-migration/run-phase-duration-ms` | runner | `phase`: `initial-cache-retrieval`, `action-execution`, `commit`, `cache-invalidation`; `status` - on failure the elapsed transaction time is recorded as `action-execution` with `status: 'fail'` (the action/commit split is unknowable mid-failure) | | `workspace-migration/action-duration-ms` | base action handler wrapper | `actionType`, `metadataName`, `step`, `status` - failed steps are recorded via try/finally | | `workspace-migration/action-count` | validate-build-and-run service | - | Phase notes: phases are disjoint (commit is subtracted from transaction time) but not exhaustive - connection setup, the application-map fetch, rollback, and after-commit side effects sit outside them, so the stack approximates rather than equals the run total. The `cache-invalidation` phase is recorded only at the run's post-commit call site, not inside the public `invalidateCache`, so standalone callers (upgrade backfill commands, `FlatCacheInvalidateCommand`) don't pollute the series. ## Implementation notes - Durations are measured with local `performance.now()` instead of reusing `perfTime`/`perfTimeEnd`, so metrics are recorded regardless of the `performance` log level and are immune to key collisions in the logger's shared timer map under concurrent migrations. Existing perf logs are untouched. - `WorkspaceMigrationRunnerService.run` is now a thin wrapper around the previous body (`executeRun`) so the total run duration and its success/fail status are recorded in one place for every throw path. - No `workspaceId` in attributes to keep cardinality bounded. - Two new bucket-boundary constants follow the existing `AI_LATENCY_MS_BUCKET_BOUNDARIES` pattern: durations 5ms-120s, action counts 1-5000. - `MetricsModule` imported into `WorkspaceMigrationModule`, `WorkspaceMigrationRunnerModule`, `WorkspaceMigrationBuilderModule`, and the action-handlers module. ## Test - `nx typecheck twenty-server` passes - oxlint + oxfmt clean on changed files - `workspace.service.spec.ts` passes |
||
|
|
b7e556bcfc |
fix(workflow): make core-consistency drift check trustworthy for rollout (#23807)
## Context Part of the workflow → core migration. Before enabling `IS_WORKFLOW_DISPATCH_FROM_CORE_ENABLED` per workspace, the drift signal that gates the rollout must be trustworthy. Two bugs in the (already merged) consistency cron made it lie in both directions. This PR fixes only those two; no new machinery. The actual pre-flight gate is a read-only SQL query run per batch of workspaces, so the heavier repair-command idea was dropped. ## What this does **1. Exclude soft-deleted trigger rows from the automated-trigger drift check.** `checkAutomatedTriggerSync` read the workspace `workflowAutomatedTrigger` table without a `deletedAt` filter (the sibling workflow/version checks have one). Workflow soft-delete soft-deletes the trigger row but removes the core-map entry, so every soft-deleted automated workflow emitted a permanent false `inTableNotCache` drift — inflating the exact metric meant to gate the flag. **2. Enumerate active workspaces in the consistency cron.** The scan was `SELECT DISTINCT "workspaceId" FROM core."workflow"`: a workspace whose mirror never succeeded has zero core rows and was therefore never checked — the worst-drifted tenants were invisible. It now enumerates ACTIVE workspaces and skips those with no (non-deleted) workflow rows, so cost stays close to actual workflow usage. No behavior change beyond the drift metrics themselves. |
||
|
|
6abeb7b5e5 |
feat(server): instrument the local metadata cache and cap heavy providers by entry count (#23778)
## What
Two related changes to the per-pod local workspace-metadata cache
(`WorkspaceCacheService`):
1. **Occupancy metrics** — per-pod gauges so we can measure how the
cache is actually used from prod instead of guessing:
- `twenty_workspace_cache_local_entries` — Map size
- `twenty_workspace_cache_local_workspaces` — distinct workspaces held
- `twenty_workspace_cache_local_versions_total` — total versions across
entries
- `twenty_workspace_cache_local_bytes_estimate` /
`..._bytes_by_provider{provider}` — sampled deep-size (circular-safe,
includes `localDataOnly` providers)
-
`twenty_workspace_cache_local_entries_by_version_count{versions=1|2|3|4|5+}`
— stale-version distribution
2. **Per-provider eviction budget (behavior change)** — a heavy provider
can override the global entry cap, and eviction now drops the
least-recently-read entry. `ORMEntityMetadatas` is capped at 128
entries.
## Why
Measured on a prod pod (with these gauges plus a live heap walk): the
local cache is ~1.5-2 GB of the pod's ~2.4 GB live heap, and the pod
sits at 85% of its 4 GB limit. Two providers own 89% of it:
- `orm:entity-metadatas` — **50%**, ~5 MB/entry (the full TypeORM
`EntityMetadata[]` graph), `localDataOnly` so it is pure per-pod RAM.
- `flat-maps:field-metadata` — 39%.
The pod held 434 ORM entries but served under one distinct workspace per
second, with 62% of entries idle for more than 5 minutes — it hoards.
Rebuilding an ORM entry is cheap (6-16 ms of synchronous CPU; the DB
read dominates the rest of a recompute), so bounding how many we retain
is nearly free.
## The eviction change
The only size control before this was a single global 6000-entry LRU,
which is byte-blind: a 5 MB ORM entry and a 760 B webhook entry each
count as "1", so "6000 entries" is anywhere from 300 MB to 3 GB.
This adds a **per-provider entry cap** and evicts the
**least-recently-read** entry, keyed on `version.lastReadAt` (replacing
the coarser `lastHashCheckedAt`, which was zeroed on invalidation and
only 100 ms-granular). `ORMEntityMetadatas` → 128 entries, down from 434
observed in prod: ≈640 MB at the measured ~1.0 versions/entry (worst
case bounded by `128 × MAX_LOCAL_STALE_VERSIONS`), against ~2.1 GB
today. The global 6000 cap remains as a coarse total backstop; providers
without an override are unchanged.
A cold miss (a workspace served again after its entry was evicted)
recomputes transparently. At the measured activation rate that is under
one rebuild per second per pod — well below 1% of a core. Cache
correctness is unchanged: entries are hash-versioned and disposable.
## Cost
- **Metrics**: stats are one pass over the local Map, memoized 5 s so
concurrent gauge callbacks reuse them. The byte estimate is a background
sampler (first run ~30 s after startup, then every 5 min) that
deep-sizes a few entries per provider, node-capped and yielding between
walks — off the request and scrape paths.
- **Eviction**: the per-provider cap is enforced on write; a cold-miss
rebuild is the 6-16 ms recompute above.
|
||
|
|
352d7dda55 |
fix(front): apply advanced filter value and operand edits after a fresh page load (#23819)
## Problem On a freshly loaded page (e.g. opening a saved view with an advanced filter), editing an advanced filter rule silently fails: - Toggling a record in a relation value dropdown (e.g. Account Owner \`Is Me\` → adding a workspace member) does nothing: the checkbox does not stick and the filter is never updated. - Changing the operand (e.g. \`Is\` → \`Is not\`) is also a no-op. The edits only work in the session where the rule was just created, which is why this slips through manual testing of new filters. Found while QAing #23718: the new runtime-computed relation chip made the stale value visible enough to notice the edit was never applied. ## Root cause The object-filter-dropdown component states for an advanced filter row live under the row's instance id (\`advanced-filter-<recordFilterId>\`, provided by \`AdvancedFilterRecordFilterRow\`). They are hydrated by \`useSetRecordFilterUsedInAdvancedFilterDropdownRow\` when a rule is created — but never on a later page load. \`AdvancedFilterValueInput\` did write \`objectFilterDropdownCurrentRecordFilter\` & co on dropdown open, but under a different instance id (\`advanced-filter-view-filter-value-input-<recordFilterId>\`) that no dropdown content ever reads — dead writes. So after a reload, \`selectedOperandInDropdown\` is undefined in the instance the dropdown reads, and \`ObjectFilterDropdownRecordSelect.handleMultipleRecordSelectChange\` (gated on it) silently drops the selection. Same story for \`useApplyObjectFilterDropdownOperand\`, which sees no current record filter and never upserts. ## Fix - \`useSetRecordFilterUsedInAdvancedFilterDropdownRow\` now also hydrates \`subFieldNameUsedInDropdown\` and \`relationTargetFieldMetadataIdUsedInDropdown\`, mirroring \`useSetEditableFilterChipDropdownStates\` (the regular filter chip flow, which does not have this bug). - \`AdvancedFilterValueInput\` calls it on value-dropdown open instead of the phantom-instance writes, and its search-input/subFieldName states now target the row instance actually read by the dropdown content. - \`AdvancedFilterRecordFilterOperandSelectContent\` hydrates the same states on operand-dropdown open. ## Test Verified locally against a seeded workspace, on a saved view \`Account Owner Is Me\` reloaded in a fresh session: - Adding a member in the value dropdown now applies immediately: chip updates to \`Me, Aaron Munoz\`, results re-query, Update view appears. - Unchecking \`Me\` leaves \`Aaron Munoz\` with the record-name chip and the filter applied. - Changing the operand to \`Is not\` applies (count flipped from owned-by-me to the complement). - Regular (non-advanced) filter chips unchanged. Ran \`lint:diff-with-main\`, \`typecheck\` and the advanced-filter jest suites. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23819?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. --> |
||
|
|
f7aab2e988 |
fix: allow app-manifest RECORD_TABLE widgets to reference a view by universal identifier (#23634)
## Context Fixes #23065. App-manifest dashboard `RECORD_TABLE` widgets could not reference a view by universal identifier. `RecordTableConfiguration.viewId` was typed as a plain `string`, so `FormatRecordSerializedRelationProperties` (which only renames properties branded with `SerializedRelation`) left it as `viewId` in the manifest type. As a result the manifest rejected `viewUniversalIdentifier`, and the widget could not be made portable across workspaces the way `FIELDS` widgets already are. ## Changes - `RecordTableConfiguration.viewId` is now `SerializedRelation | null` (was `string`), matching `FieldsConfiguration`. This makes the manifest type surface `viewUniversalIdentifier` instead of `viewId`. - `RecordTableConfigurationDTO.viewId` retyped to match. - Forward converter (`fromPageLayoutWidgetConfigurationToUniversalConfiguration`): the `RECORD_TABLE` case now emits the `viewUniversalIdentifier` key instead of `viewId`, since the branded property is renamed in the universal type. Now consistent with the `FIELDS` case (uses `| null`). - Reverse converter (`fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration`): the `RECORD_TABLE` case now reads `viewUniversalIdentifier` and resolves it back to a concrete `viewId`. Frontend readers need no change: `SerializedRelation` is a runtime string, so the existing `typeof === 'string'` guards and `as string` casts still hold. ## Migration None needed. The persisted `pageLayoutWidget.configuration` still stores a concrete `viewId`; `universalConfiguration` (which carries `viewUniversalIdentifier`) is computed on the fly from it and never persisted. Only the manifest/universal representation changes, so there is no stored data in the old shape to backfill. ## Verification - `nx typecheck twenty-server` and `nx typecheck twenty-front`: pass - oxlint + oxfmt on the changed files: clean - End-to-end against a server built from this branch: built a minimal app declaring a view (by `universalIdentifier`) and a `DASHBOARD` page layout with a `RECORD_TABLE` widget referencing that view via `viewUniversalIdentifier`, then installed it. The manifest carried `viewUniversalIdentifier`, and the installed `pageLayoutWidget.configuration.viewId` resolved to the concrete workspace view id. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23634?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. --> |
||
|
|
17ff17bdec |
Dnd library migration fixes and changes (#23752)
Follow-up to #23211. Fixed issues, and simplified where possible. The core idea: every sortable list now resolves its drop position the same way — "sortable over sortable", comparing the pointer against the hovered item's midpoint — instead of each surface owning bespoke droppable slots and end-drop zones. ## Refactors - New `resolveDropFromPointer` handles both axes in one util; items can tag their own `orientation`, so one provider can drive lists of mixed axes. - Dropped `DragDropItemDroppableSlot` and `DragDropItemDropLine` path. Record table/board headers, page-layout tabs & widgets, and fields config all derive the drop index from the hovered sortable, matching record-board cards. - `DragDropItemSortableCell` is now the single sortable primitive, with drag optionally delegated to an explicit `DragDropItemSortableHandle`. - Removed end-drop constants/types; lists now place a trailing append target and resolve the append position in the consumer's own index space. ## Fixes - Dragging a row within grouped records threw an error — the drag overlay now resolves the source row's record-group context. - Multi-select drag counter chip didn't show — drag state was read from the wrong component scope instead of the active `recordIndexId`. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23752?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@twenty.com> |
||
|
|
6e30405489 |
Bump vulnerable dependencies flagged by ECR image scanning (#23813)
## Context The Oneleet monitor **"AWS ECR repository image vulnerabilities are remediated"** is alerting on `prod-twenty` images: 1 CRITICAL + 6 HIGH advisories breach their SLA in 7 days, plus a set of MEDIUMs. All of them are npm packages baked into the image. ## Changes | Package | Before | After | How | Advisories | |---|---|---|---|---| | undici | 7.28.0 / 6.27.0 | 8.9.0 | jsdom `^30` bump + node-gyp refresh; global `undici: ^8.9.0` resolution for @module-federation/dts-plugin, e2b and miniflare, which still pin 7.28.0 at latest (replaces the old scoped dts-plugin resolution) | CVE-2026-13697 (critical), CVE-2026-14643, CVE-2026-15157/16728/16729 | | sharp | 0.34.5 | 0.35.3 | direct bump in twenty-sdk; @argos-ci refresh; `next/sharp` resolution (next 16.3.0 with the fix is still quarantined by yarn's minimal-age gate) | GHSA-f88m-g3jw-g9cj | | axios | 1.17.0 | 1.19.0 | lockfile refresh | GHSA-gcfj-64vw-6mp9 + 10 medium | | ip-address | 10.2.0 | 10.4.0 | lockfile refresh | CVE-2026-69192, CVE-2026-54272, CVE-2026-69198 | | brace-expansion | 2.1.2 | 2.1.4 | lockfile refresh (backport exists; Inspector only lists 5.x) | CVE-2026-69152, CVE-2026-14257, CVE-2026-13149 | | typeorm | 0.3.29 | 0.3.31 | pin bump; the local yarn patch applies unchanged | GHSA-2rp8-mm9q-fp49 | ## Validation - `yarn.lock` contains no remaining vulnerable versions (undici resolves only to 8.9.0) - `yarn npm audit`: no remaining advisories among the bumped packages - `nx build` green for twenty-server, twenty-front (exercises module-federation dts-plugin on undici 8), twenty-sdk, twenty-website; twenty-server typecheck green (typeorm patch is type-level) - Runtime smoke: jsdom 30 DOM parse, sharp 0.35.3 png encode, undici 8.9.0 load ## Not covered - **react-router / react-router-dom 6.30.4** (medium, 1–3 month SLA): react-router-dom 6.x has **no fixed release**; the fix is the v7 migration (~225 files) — separate effort. - `prod-business-dash` body-parser 2.2.2 → 2.3.0 lives in its own repo. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23813?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. --> |
||
|
|
198ffbb6bd |
Cancel drag activation when the drag source unmounts mid-gesture (#23816)
## Summary Follow-up to the Sentry error [TWENTY-FRONT-HJ8](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-HJ8) (`Cannot start a drag operation without a drag source`), seen on a dashboard page and discussed in #23752. A pointer drag only activates once the pointer travels past the activation constraints (distance/delay). dnd-kit's `PointerSensor` captures the pressed draggable on pointerdown, and when the constraint is satisfied it starts the drag by resolving that draggable's id in the registry. If a re-render unregistered it in between, the lookup fails and `manager.actions.start()` throws. That window is real in our UI: virtualized table rows remount under new per-instance sortable ids, and widgets/tabs remount while a page loads. The breadcrumbs of the Sentry event show the gesture straddling a navigation onto a loading dashboard, with the error firing on the activating `pointermove` 190ms later. ## Fix `PointerSensorWithSourceGuard` extends `PointerSensor` and checks the registry before starting: if the pressed draggable is gone, it cancels the gesture through the sensor's own cancel path (same one dnd-kit wires to activation aborts) instead of throwing. There is nothing left to drag at that point, so cancel is the correct outcome. `DND_KIT_SENSORS` now uses it, which covers every dnd surface. Unit tests pin the behavior with real dnd-kit internals: the base sensor throws in this scenario (documents why the guard exists, and breaks if upstream fixes it so we can remove the subclass), the guard cancels and leaves the operation idle, and a still-registered source starts normally. Fixes TWENTY-FRONT-HJ8 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23816?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. --> |
||
|
|
3f9137bd4e |
Fix widget view save paths dropping relationTargetFieldMetadataId (#23814)
## Context `viewFilter.relationTargetFieldMetadataId` (relation traversal, added in 2.6.0) is accepted and persisted by the `upsertViewWidget` mutation, and `mapViewFiltersToFilters` restores it when loading a widget view. But two frontend mappers silently dropped it, so any relation-traversal filter on a record table widget view was lost the moment the layout was saved (or the moment the user edited the widget's filters in the side panel): - `useSaveRecordTableWidgetViews` omitted the field when building the `upsertViewWidget` input - `useRecordTableWidgetFilterCallbacks` omitted it when syncing current record filters back into the widget view draft ## Changes - Carry `relationTargetFieldMetadataId` through both mappers - Add regression tests for both hooks (they fail without the fix) This is a prerequisite for nested relation field widgets (see follow-up PR), which rely on a traversal filter surviving the widget view save path. --- _Generated by [Claude Code](https://claude.ai/code/session_01Xp3AgGtc4kSP8PpgpKMWLQ)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23814?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. --> |
||
|
|
5b6734691c |
Fix workflow email body overflowing into the attachments section (#23810)
before (introduced by https://github.com/twentyhq/twenty/pull/23657) <img width="381" height="736" alt="Screenshot 2026-08-05 at 15 18 38" src="https://github.com/user-attachments/assets/91d5d1de-bffb-4508-a488-2fe6a09eca61" /> after <img width="1308" height="748" alt="image" src="https://github.com/user-attachments/assets/06a9b875-6cde-47c6-a23f-f9a1f268db42" /> ## Problem In the workflow Send Email action panel, the Body editor painted over the Attachments section when the panel was shorter than the editor's minimum height. |
||
|
|
4137a1f9fc |
Stop classifying Recall bot-detection timeouts as NOT_RECORDED in call-recorder (#23812)
Removes the two `timeout_exceeded_only_bots_detected_*` sub codes from `NOT_RECORDED_RECALL_SUB_CODES`, so a bot-detection leave is handled like any other call ending (`call_ended` -> PROCESSING -> artifact import -> COMPLETED). These sub codes are leave reasons, not capture verdicts. Bot detection only fires when participants are present (otherwise `noone_joined` fires first), and any participant starts the recording, so a bot-detection ending virtually always has a real recording behind it. It is also the app's own configured exit path whenever a third-party notetaker (Fireflies, Otter) lingers after the humans leave, since a lingering bot keeps `everyone_left_timeout` from ever firing. Classifying it as NOT_RECORDED stamped successfully recorded calls as failures and skipped artifact import; bots in the production Recall workspace end with this sub code near-daily. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23812?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. --> |
||
|
|
45cbdef930 |
i18n - translations (#23806)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23806?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> |
||
|
|
29e68a7f87 |
Refactor outbound email content compilation (#23782)
## Integration status This is now the final landing PR for the reviewed editor/email architecture stack. | Order | Pull request | Scope | Status | | --- | --- | --- | --- | | 0 | #23657 | Advanced text editor capability presets | Merged into `main` | | 1 | **This PR** | Outbound email content compilation | Ready to land into `main` | | 2 | #23783 | Clean editor surface seam | Reviewed and merged into this branch | | 3 | #23790 | Shared editor block catalog | Reviewed and merged through #23783 | | 4 | #23791 | Canonical TipTap document persistence | Reviewed and merged through #23790 | The current branch tree contains the complete stack. Merging this PR lands all four follow-up layers. ## Architecture The stack establishes four reusable boundaries: 1. **Outbound compilation** — campaign, workflow, and one-to-one/tool email share one compiler, sanitizer policy, renderer, and plain-text derivation path. 2. **Editor surface profiles** — the generic editor owns rendering mechanics while each consuming surface declares chrome, extensions, and explicit compatibility readers. 3. **Shared block primitives** — sections, columns, HTML, images, buttons, and related commands live in the neutral advanced-editor catalog; email behavior is supplied by email schemas/rendering, not by relocating reusable blocks into an email editor. 4. **Canonical persistence** — Twenty-owned authoring persists complete, versioned TipTap JSON documents. HTML, Markdown, plain text, and BlockNote are projections or explicitly owned legacy boundaries. ## Compatibility boundaries Compatibility remains only where shipped data requires it: - workflow Send Email: versionless TipTap JSON, HTML, and plain text - inline email: HTML - AI instructions: Markdown - record rich text: BlockNote arrays and older Markdown/plain text Campaign is unshipped, so its editor, stored rows, sendability validation, and send-time compilation require the current canonical schema version. AI chat drafts are canonical-only local state; old or malformed drafts are rejected at hydration, and plain-text preprompts are converted at their entry point. ## Outbound compiler details The shared compiler owns: - strict structured email-document parsing - React-email rendering - one cached DOMPurify/JSDOM policy for structured and legacy HTML - plain-text derivation from sanitized HTML - single-pass structured-document binding resolution across text, variable tags, links, images, buttons, and raw HTML Resolved workflow values remain inert, legacy workflow and one-to-one HTML remain supported, and Campaign HTML/plain text come from the same compiled result. ## Verification - all automated standard/security reviews passed on the three merged upper PRs with no unresolved threads - shared TipTap/email codec tests: 20 passing - editor, AI draft, and workflow compatibility tests: 14 passing - campaign validation and compilation tests: 31 passing - full shared suite during development: 223 suites / 1,738 tests passing - twenty-front, twenty-shared, and twenty-server typechecks - changed-file type-aware lint and formatting checks |
||
|
|
76bf3651bb |
Fix stray bracket after AI chat chips (#23798)
Chips in the AI chat sometimes rendered with a leftover `]` after them. The reference marker is bracket-asymmetric: it opens with `[[` and closes with `[[/kind]]`, so a complete reference holds four `[` and only two `]`. The model balances that by writing `…[[/object]]]`, and the parser ended the match exactly at the close tag, leaving the extra bracket as prose next to the chip. The parser now absorbs up to as many surplus `]` as the reference opened with, and accepts an opener with extra `[` so an over-wrapped marker doesn't leak one either. The system prompt also tells the model the marker is complete as written. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23798?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. --> |
||
|
|
f893b214e2 |
Make the call recorder transcript provider an application variable (#23789)
## What Post-call transcription was locked to Gladia by a frozen constant (#23532). It is now a `CALL_RECORDER_TRANSCRIPT_PROVIDER` application variable that a workspace admin picks in app settings. ## Changes - **Recall.ai transcription (`recallai_async`) is the default.** It is the only provider that needs no third-party key in the Recall dashboard, so a fresh install transcribes without extra setup. Gladia (`gladia_v2_async`) stays available with code switching for mixed-language calls, and still requires a Gladia API key in the Recall dashboard per region. - Providers name their language options differently (`recallai_async` takes `language_code`, `gladia_v2_async` takes `language_config.code_switching`), so the variable holds a Recall provider id and the app owns the per-provider payload in `RECALL_ASYNC_TRANSCRIPT_PROVIDERS`. Unset or unrecognized values fall back to the default, matching how `getBotImageBackground` and `isCallRecordingSummaryEnabled` read their variables. - `SETUP.md` now frames the Gladia key as conditional on that selection rather than a hard requirement, and lists the new variable alongside the other application variables. - App bumped to 1.7.0. No new server capability is needed, so `engines.twenty` stays at `>=2.26.0`. ## Upgrade note Existing installs run on Gladia today through the old constant, and move to Recall.ai transcription on upgrade unless the variable is set. Workspaces that rely on code switching for mixed-language calls should select Gladia after deploying. ## Tests 533 unit tests pass, typecheck and lint clean. - `recall-bot-api.test.ts` asserts the `create_transcript` request body for both the default and a Gladia selection. - `get-recall-async-transcript-provider.test.ts` covers the default, the fallback for an unsupported provider, and a drift guard asserting the manifest's SELECT options match the keys of the provider map, so adding a provider to one without the other fails. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23789?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. --> |
||
|
|
5c6ad77563 |
i18n - translations (#23803)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
59672b71b8 |
Add a book-a-call onboarding step for qualified leads (#23521)
https://github.com/user-attachments/assets/76d5a14e-53bd-4195-963b-bf9bb265c8c1 Large-company signups either self-serve a small plan or drop off at the paywall without sales ever seeing them. This adds an embedded Cal.com booking step to onboarding, shown only to leads worth a call. The step sits between Invite Team and the plan step: the lead has built out a workspace by then, and sales gets a chance before checkout. It is always skippable, and a successful booking advances automatically. Qualification reuses the employee count from the People Data Labs enrichment added in #23199. `ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT` sets the bar; leaving it unset means the step never appears. `CALENDAR_BOOKING_PAGE_ID` must also be configured, so the step can never strand someone on an empty embed. Enrichment is no longer gated on `IS_ONBOARDING_AI_CHAT_ENABLED`, since the book-a-call step is now a second consumer of it. `PEOPLE_DATA_LABS_API_KEY` remains the instance-level switch. The existing `/book-call` page is reused: it moves into the onboarding shell and its footer switches between Skip (as a step) and the back link (when reached from the plan page). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23521?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. --> |
||
|
|
0cf1ae23b5 |
i18n - translations (#23800)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23800?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> |
||
|
|
1d755983ff |
Feat/advanced text editor capability presets (#23657)
# Email editor for Compose campaigns
## Short version
Campaign bodies are currently plain rich text. This PR turns the
composer into an email editor: a centered email canvas with section,
column, button, divider, image and raw-HTML blocks, each editable
through a settings side panel, rendered to email-safe HTML per recipient
at send time. Modelled on Resend's Broadcast editor.
**Product**
- Email canvas with page/body styling (background, width, padding,
corner radius, border, text colour, alignment)
- Blocks: section, 2/3 columns, button, divider, raw HTML, images —
insertable from a floating left rail or the slash menu
- A **section is a container whose typography cascades to its
contents**, so one part of an email can have its own look
- Block settings panel focuses whatever you select and shows its
effective values
- Per-recipient variables (`{{firstName}}`, `{{lastName}}`,
`{{fullName}}`, `{{email}}`, `{{personId}}`) usable in text,
button/link/image URLs, image labels and raw HTML
- Image upload by drag-drop, paste or file picker
**Technical**
- Presets now declare **capabilities** instead of surfaces forking the
editor; the UI derives itself from loaded extensions
- Editor behavior lives in `twenty-front`; the versioned email-document
schema and structural traversal live in `twenty-shared`; rendering lives
in `twenty-emails` — HTML is produced server-side per recipient
- Section typography cascade is **resolved at render time**, not left to
CSS: react-email hardcodes `fontSize`/`lineHeight` on every paragraph
and Outlook ignores `inherit`
- Logic vendored from Resend (MIT); all controls rebuilt on `twenty-ui`
+ Linaria
**Also fixes:** the unsubscribe footer was being appended *after*
`</html>`, outside the document, where Gmail strips it — legally
significant since unsubscribe is required.
---
## Detailed version
### Product requirements
**Problem.** The Compose campaign body was a single rich-text field.
Marketing email needs layout — banded sections, columns, call-to-action
buttons, images with links — and it needs that layout to survive
Outlook, which means table-based HTML rather than the divs a text editor
produces. It also needs per-recipient personalisation.
**Reference.** Resend's Broadcast editor, chosen because it solves the
same problem (TipTap authoring → react-email output) and is MIT
licensed.
#### What a user can now do
| Area | Capability |
|---|---|
| Canvas | Email renders as a centered page with its own background,
width, padding, corner radius and border |
| Blocks | Section, 2/3 columns, button, divider, raw HTML, image |
| Insertion | Floating left rail (pointer-first) or the `/` slash menu
(keyboard-first) |
| Sections | Own text colour, font size, line height, letter spacing and
alignment, cascading to everything inside |
| Images | Upload by drag-drop, paste or picker; link URL, alt text,
width, spacing, border |
| Raw HTML | Edited as source in the panel, previewed on the canvas with
scripts neutralised |
| Variables | `{{firstName}}`, `{{lastName}}`, `{{fullName}}`,
`{{email}}`, `{{personId}}` in text, button URLs, link hrefs and raw
HTML |
| Settings panel | Follows selection; shows effective values; opens
automatically when a block is clicked |
#### Deliberate product decisions
- **Variables display as literal placeholders**, not prose labels, so
the syntax is copyable into HTML blocks and button URLs by hand.
- **Sections inherit until they override.** The panel shows what
actually renders rather than blank fields, but writes nothing until you
edit — so changing the body text colour still flows into sections.
- **Headings keep their own scale** inside a styled section; only
colour, family and spacing cascade, otherwise every heading would
collapse to body size.
- **Clicking a block opens its settings**, but only on whole-node
selections, so typing inside a section does not reopen a panel you just
closed.
### Technical strategy
#### 1. Capability presets (the foundation)
Per-surface variation previously worked by **forking**: three separate
`useEditor` call sites with hardcoded extension arrays. Inside the
shared tree there was no variation at all — all five surfaces received a
byte-identical extension list, and presets controlled only sizing,
chrome and serialization format. Adding email blocks that way meant
either leaking section/column nodes into the record rich-text field and
workflow email body, or writing a fourth fork.
Now:
- a preset declares a **capability list** (`basicMarks`, `headings`,
`lists`, `links`, `images`, `campaignVariables`, `slashCommand`,
`blocks`, `mentions`)
- capabilities resolve to extensions through a factory registry
- the UI derives itself from the loaded extensions via
`hasEditorExtension` — no capability list is prop-drilled into a menu,
because the `Editor` already knows what it can do
The acceptance test was collapsing the AI chat fork into an `aiChat`
preset with no visible change to that composer. `campaignBody` is the
only preset opting into the shared `EMAIL_DOCUMENT_CAPABILITIES` today.
Workflow email keeps its current field UI, but can opt into the same
canvas, block settings and image uploader later without adding another
schema or renderer.
#### 2. Schema / renderer split
The hard constraint: **our HTML is produced server-side, per recipient,
at send time**, because variables substitute into nodes rather than into
a serialized string. That rules out Resend's
`renderToReactEmail`-on-the-extension pattern.
```
twenty-front TipTap extensions + node views + shared email settings UI
twenty-shared versioned email-document schema + structural traversal
twenty-emails react-email renderers (imported by twenty-server)
twenty-server surface-specific variable resolution, validation, send
```
Logic was **vendored, not depended on** — Resend's TipTap is 3.17
against our 3.4, and their UI is Radix. We copied the schema/serializer
approach and rebuilt every control on `twenty-ui` + Linaria.
#### 3. Section typography cascade
The subtle part, and the one that would have silently shipped broken.
Section typography *looks* like it should cascade via CSS. It does not:
```js
// react-email's Text
style: { fontSize: "14px", lineHeight: "24px", ...style, ...margins }
```
Every paragraph re-declares `fontSize` and `lineHeight`, overriding any
enclosing section. `inherit` is not a fix either — Outlook's Word engine
ignores it.
So the cascade is **resolved in the renderer**: the tree walk threads
the enclosing section's typography down and writes computed values
explicitly onto each text node. Nested sections refine what they
inherit.
Verified against real rendered output:
| | rendered |
|---|---|
| paragraph inside section | `font-size:22px; color:rgb(255,0,0);
letter-spacing:2px` |
| h1 inside section | `font-size:32px` (own scale) + section colour and
spacing |
| paragraph outside | `font-size:14px`, no colour — untouched |
#### 4. Storage
`bodyTemplate` stays serialized TipTap JSON in a `TEXT` column. Block
attributes are ProseMirror node attrs, so richer blocks add keys to JSON
already being serialized — no migration, and it flows into the existing
500 ms debounced draft save unchanged.
Since the feature has not shipped, the legacy HTML-string body path was
removed rather than maintained. That is a tightening, not just a
deletion: `bodyTemplate` is writable through the record API, and the old
fallback would interpolate an arbitrary string and email it as markup. A
body that is neither empty nor a valid TipTap document is now rejected
at the send gate.
#### 5. Image hosting
Inline assets use an `EmailImage` file folder with
`ignoreExpirationToken: true` and immutable cache headers, because
recipients' mail clients never authenticate and may open an email years
later. The shared uploader returns `{ fileId, url }`; the image node
keeps both the durable file identity and its delivery URL so
ownership/lifecycle or URL resolution can evolve later without a
document migration. The server verifies the uploaded bytes and only
accepts GIF, JPEG, PNG and WebP.
This is intentionally separate from workflow/email **attachments**.
Attachments remain private files that the server reads and embeds as
MIME parts at send time; inline images need a durable recipient-facing
URL. A future workflow canvas should reuse `useUploadEmailImage` for
inline content while keeping its existing attachment control unchanged.
Adding the folder requires three registrations — the folder config, the
route guard's `SUPPORTED_FILE_FOLDERS`, and `DIRECT_UPLOAD_FILE_FOLDERS`
in the upload service.
### Bugs fixed along the way
- **Unsubscribe footer was appended after `</html>`**, outside the
document, where Gmail strips it. Legally significant, since an
unsubscribe link is required. Now inserted before `</body>`.
- **Body text colour never reached the email.**
- **`onImageUpload` was declared but never passed** by any production
call site, so drag-drop and paste image upload were inert everywhere
outside Storybook.
- **Message lists were not user-facing**, so members could not be added
from the list page.
- **Image resize wrote an undeclared `width` attribute** that TipTap
silently dropped.
- **The text bubble menu appeared over selected atom blocks** with
nothing to format.
### Review notes / known limitations
**Security posture to check.** Anything in `EmailImage` is readable by
anyone holding the URL, forever. The server now enforces an image-only
MIME allowlist from sniffed bytes, but it cannot determine whether the
image itself is confidential. This remains a deliberate trade-off for
recipient-visible inline assets.
**Test gap.** The section typography cascade has no regression test:
react-email's `render()` hangs under Jest (tried 60s), and
`twenty-emails` has no test target at all. Verified by rendering through
the built package instead. Adding a test target there is worthwhile
follow-up.
**Sending needs configuration.** `EMAILING_DOMAIN_DRIVER` defaults to
`LOG`, which fakes a messageId, reports any domain as verified, and only
logs — a campaign reaches "sent" with nothing delivered. Real sending
needs `AWS_SES`.
**Unrelated platform bug found.** The pinned "Create new record" command
throws on viewless objects like `messageListMember`, because
`recordIndexId` derives from the current view.
**Not done.** Panel chrome from the reference: breadcrumb (`Page style /
Section`), collapsible groups, per-side spacing grid, and a
variable-insert button inside link fields. All presentation over the
same data.
**Deferred.** Drag-to-reorder blocks.
`@tiptap/extension-drag-handle-react@3.4.2` matches our pinned versions
exactly, so no upgrade is needed, but its behaviour around atom node
views (HTML block, image) is unverified and belongs in its own change.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23657?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>
Co-authored-by: Félix Malfait <felix@twenty.com>
|
||
|
|
5effee7754 |
Fix grouping a view that can no longer be changed or removed (#23619)
Fixes #23529 https://github.com/user-attachments/assets/2dbcf5ac-9b2e-4331-b7e8-703c8c5384b5 Grouping People by Company was a one-way door: once the view was grouped, the grouping could neither be changed nor removed. Two independent bugs on the same path caused it, and both had to be fixed. ## 1. The Group by entry was disabled, so the picker was unreachable `ObjectOptionsDropdownRecordGroupsContent` disabled the `Group by` entry whenever the object had a single groupable field. People exposes exactly one (Company), so the entry was always disabled there. That entry is the only way back to the field picker once a view is grouped: `ObjectOptionsDropdownCustomView` sends `Group` to the picker while the view is ungrouped, and to the group management screen once it is grouped. With the entry disabled, the picker, and with it the `None` option, became unreachable. A table view can always drop its grouping through `None`, so the entry now stays enabled there and is only disabled for layouts that require a grouping. ## 2. The view groups created by the server were never synced back The server deletes and recreates the view groups whenever `mainGroupByFieldMetadataId` changes (`handleFlatViewUpdateSideEffect`), and returns them in the `updateView` payload. `usePerformViewAPIUpdate` only wrote the view itself back to the metadata store, so the `viewGroups` entity kept the pre-change rows. The view create path already syncs them; the update path did not. On top of that, `useHandleRecordGroupField` overwrote the groups returned by the mutation with client-generated ones whose ids matched no persisted row, and `resetRecordGroupField` bailed out on `viewGroups.length === 0`. Since a relation grouping legitimately starts with no groups, clicking `None` was a no-op even when it could be reached. - sync the view groups returned by `updateView` into the metadata store - use those groups instead of regenerating them client-side - reset the grouping based on `mainGroupByFieldMetadataId`, and reload the record index states so the table regroups and ungroups without a refresh ## 3. Drive-by: No Value missing from the widget draft preview `buildDraftViewGroupsForFieldMetadataItem` mirrors `computeFlatViewGroupsOnViewCreate` so the page layout widget preview matches what gets persisted, but it returned early for relation fields and skipped the empty group. The server keeps creating it for nullable fields, relations included, so the group appeared out of nowhere once the widget was saved. It now skips only the option groups and keeps the empty group. ## Not changed Grouping by a relation shows no groups until you add them through `New group`. That is intended, since a relation can have an unbounded number of groups, and nothing here changes it. |
||
|
|
13a2e3ebe8 |
fix(twenty-server): compose email from the caller's own connected account (#23793)
### The bug
`draft_email` and `send_email` take an optional `connectedAccountId`.
When an agent omits it — which it does whenever it has no way to know
the id — `EmailComposerService` resolved the account like this:
```ts
const allAccounts = await this.connectedAccountRepository.find({
where: { workspaceId, archivedAt: IsNull() },
});
return allAccounts[0].id;
```
The first connected account **in the workspace**, ignoring the
`userWorkspaceId` that `ToolExecutionContext` already carries — with no
`ORDER BY`, so "first" is whatever the planner returns.
We hit this on our own workspace: an agent chat drafted a customer email
on behalf of one user, and the draft landed in a different user's
mailbox. The tool reported `success: true` with a `connectedAccountId`
belonging to someone who was not in the conversation, so nothing
surfaced the mistake. `send_email` shares this composer, so the same
fallback sends mail from another person's address.
### The fix
- **No id supplied** → the caller's own account
(`context.userWorkspaceId`), else an account whose `visibility` is
`workspace`, else throw `CONNECTED_ACCOUNT_NOT_FOUND`. Never a
colleague's private mailbox by accident.
- **Id supplied** → used as given, whoever owns it. Blocking a member
from composing through another member's account is a product decision
this PR does not make; the mix-up above happens when no id is passed at
all.
- **No `userWorkspaceId`** (workflow run) → unchanged.
Ordering is `createdAt ASC, id ASC` so the no-caller path is
deterministic when rows share a `createdAt` — which the seed data does.
### Verified against a real workspace
Run locally against the seeded `test` database — 7 connected accounts in
one workspace, owned by four different members, **all sharing one
`createdAt`**. Same spec, composer swapped:
| Scenario | on `main` | with this PR |
|---|---|---|
| Phil's agent composes, no id | **tim@apple.dev's account** | phil's
own |
| explicit id (jony's), caller is phil | jony's | jony's |
| workflow run (no caller), explicit id | jony's | jony's |
| **workflow run (no caller), no id** | **first account, unordered** |
**first account, `createdAt`/`id` ordered** |
| caller with no account | silently resolved a colleague's | throws |
### What this does not fix
A workflow run carries no caller: `ToolBackedWorkflowAction` executes
the tool with `{ workspaceId }` and no `userWorkspaceId`. So when an
email step's sender resolves to nothing — `postprocessInput` guards for
it — the composer still falls back to the workspace's first account,
because there is no identity to attribute the mail to. The pick is at
least deterministic now. Giving workflow runs an owner is a separate
change.
Normal workflow steps are unaffected:
`EmailWorkflowActionBase.resolveSenderConnectedAccountId` resolves the
configured sender (a connected-account id, or a workspace member id from
a resolved variable) and passes it explicitly.
### Behaviour change to expect
A caller with no connected account of their own, in a workspace with no
shared account, now gets an error where the call previously "succeeded"
from a colleague's mailbox.
### Tests
Resolution is exercised by
`test/integration/email-tool/suites/email-composer-connected-account.integration-spec.ts`
against a real workspace — eight cases: supplied id honoured, supplied
id with no caller, invalid id, unknown id, caller's own account,
workspace-shared fallback (flips `visibility` in Postgres and restores
it), no usable account, and first-account-when-no-caller.
The service's unit spec is deleted: mocking the DI graph asserted the
mock rather than the resolution, and every case it covered now runs
against the database. The pure selection logic keeps unit specs —
`select-connected-account-id-for-caller.util.spec.ts` and
`is-connected-account-usable-by-caller.util.spec.ts`.
Not covered here: the workflow chain itself (`postprocessInput` →
`resolveSenderConnectedAccountId` → `DraftEmailWorkflowAction`), which
this PR does not change.
`npx nx typecheck twenty-server`, the email-tool and connected-account
suites, and the integration spec all pass; oxlint type-aware clean.
|
||
|
|
6d83018b6f |
i18n - docs translations (#23796)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23796?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> |
||
|
|
61c72942ac |
feat(workflow): dispatch automated triggers from core behind a flag (#23775)
## Context
Part of the workflow → core migration. Before we can stop writing
workspace `trigger`/`steps`, automated-trigger dispatch must read from
core. Dispatch currently reads the workspace `workflowAutomatedTrigger`
table (populated from the workspace trigger), so it would go blank once
those writes stop. This flips the dispatch reads behind a flag,
mirroring the version-content read switch.
## What this does
New flag `IS_WORKFLOW_DISPATCH_FROM_CORE_ENABLED` (per-workspace,
default off). At each dispatch read site, flag-on reads the core-derived
trigger map and flag-off keeps the current workspace query.
- **DB-event listener** (`workflow-database-event-trigger.listener.ts`):
extracted `getDatabaseEventListeners(workspaceId, eventName)`. Flag-on
filters the core map (`getOrRecompute → byWorkflowId`, `type ===
DATABASE_EVENT && settings.eventName === name`); flag-off keeps the repo
`find`. The evaluation type is broadened to the structural `{
workflowId, settings }` that both the entity and the map entry satisfy;
the enqueue loop and `shouldTriggerJob` are unchanged.
- **CRON job** (`workflow-cron-trigger-cron.job.ts`): extracted
`getWorkspaceCronTriggers(workspaceId)`. Flag-on filters the core map
for `type === CRON` → `{ workflowId, pattern }`; flag-off keeps the raw
SQL. The redis cron cache, dedup and dispatch loop are unchanged; only
the rebuild source swaps.
## Why it's safe
- The core map is keyed by the workspace `workflowId`, and both sites
enqueue `workflowId` only. Nothing consumes the map's core
`workflowVersionId`, so `workflow-trigger.job.ts` still re-derives the
version from workspace `lastPublishedVersionId` (no id translation).
- Flag defaults off, per-workspace rollout. The drift cron's
`checkAutomatedTriggerSync` already compares the core map against the
workspace table, so it's the soak signal for flipping the flag.
- The CRON source is only re-read on a cron-cache rebuild (cache miss),
so a flag flip takes effect on the next rebuild: bounded by the cache
TTL, or immediately on activation/deactivation, which invalidates the
cache. Both sources emit identical `{ workflowId, pattern }` for a
synced workspace, so the switch is a no-op in output.
## Prerequisite
- The orphan-ACTIVE core-version cleanup (#23739) must land first: the
core map is built from core ACTIVE versions, so a phantom orphan would
become a live phantom trigger the moment this flag flips.
## Verification
- Server unit specs cover both sites with the flag off (existing
behavior) and on (reads the core map).
- Live-verified on a dev instance: DB-event and CRON dispatch both fire
from the core map with the flag on, and from the workspace entity with
it off.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23775?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. -->
|
||
|
|
42aa566e32 |
feat(twenty-slack): link records and format the assistant reply footer (#23539)
Follow-up to the Slack bot branch, improving how assistant replies read
in Slack.
## Problem
The assistant never had the workspace URL. `buildSlackAssistantPrompt`
injected the request, requester and thread context, and the agent prompt
said nothing about links, so a record it created or found came back as
plain text with no way to open it. The reply was also a single
`markdown_text` blob with `_Answered in 3s_` appended to the answer.
## Changes
**Record deep links.** `fetchWorkspaceBaseUrl` resolves the workspace
URL from `currentWorkspace { workspaceUrls }`, preferring a custom
domain over the subdomain. It runs in parallel with the existing Slack
context fetch, so no extra latency. The prompt carries the base URL plus
the `[Record Name](base/object/<objectNameSingular>/<recordId>)` rule.
When the URL cannot be resolved the prompt explicitly forbids writing
any Twenty URL, so a failed lookup degrades to plain record names rather
than invented links.
**Reply structure.** The answer now goes out as Block Kit: a `markdown`
block for the body and a `context` block for the duration, so it reads
as a footer rather than italic text tacked onto the answer.
`getSlackChatMessageBodyFields` grew a blocks variant that keeps the
message text as Slack's notification and screen-reader fallback, and
`slackUpdateMessageHandler` now falls back to plain text on
`invalid_blocks` for blocks as well as markdown.
## Screenshots
### Before
<img width="344" height="161" alt="Screenshot 2026-07-30 at 8 13 55 AM"
src="https://github.com/user-attachments/assets/c4a76654-b4bc-4f3d-a8ad-a00073ec5674"
/>
### After
<img width="408" height="126" alt="Screenshot 2026-07-30 at 8 26 15 AM"
src="https://github.com/user-attachments/assets/ba2ec249-0520-42ca-87d1-c272515bddef"
/>
---
_Generated by [Claude
Code](https://claude.ai/code/session_0148FpKn9T41aVHsZZ2d1Lrw)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23539?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. -->
|
||
|
|
196a1945ff |
i18n - translations (#23794)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23794?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> |
||
|
|
b05d2406ec |
fix(twenty-front): stop field widget layout dropdown from crashing th… (#23784)
…e record page RecordTableWidgetViewDraftInitEffect read the page layout edit mode and the page layout instance id from context, but the widget settings side panel renders outside the page layout tree. Opening the Layout picker on a relation field widget displayed as a table threw "PageLayoutEditModeContext Context not found" and took down the whole record page. Both values are now passed in by the caller. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23784?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. --> |
||
|
|
ded3f1efb3 |
Add messages support to runAgent for multi-turn bot conversations (#23395)
- Extend `runAgent` so callers can pass either a one-shot prompt or a multi-turn messages array (user / assistant text), matching AI SDK’s XOR shape — for Slack/Discord/Teams bots that need thread history. - Enforce exactly one of prompt | messages in AgentRunService; map messages 1:1 to AI SDK ModelMessages in AgentAsyncExecutorService - Update shared types, GraphQL/SDK inputs, docs (skills-and-agents), and regenerate metadata clients; existing prompt-only callers stay unchanged <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23395?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. --> |
||
|
|
0b817e3bc0 |
Restrict which workspace fields can be updated before activation (#23781)
## What `validateWorkspaceUpdatePermissions` returned early with no checks at all when the workspace was in `PENDING_CREATION`, so `updateWorkspace` accepted any field during that window. It now allows only the fields needed to set the workspace up (`displayName`, `subdomain`, `logo`) and rejects everything else until the workspace is activated. Note that `updateWorkspace`'s resolver guard is `CustomPermissionGuard`, which always returns true and only documents that the check lives in the resolver/service, so this service method is the actual enforcement point. ## Why A workspace stays in `PENDING_CREATION` from signup until onboarding completes, and the JWT strategy issues an authenticated context for it without resolving member permissions. During that window every field was writable with no permission check, including security relevant ones such as `allowImpersonation`, `isTwoFactorAuthenticationEnforced` and `isPublicInviteLinkEnabled`. In practice the only principal present before activation is the workspace creator, who is granted the Admin role (`canUpdateAllSettings`) the moment activation completes, so there is no privilege escalation over another user today. This is defense in depth: the early return was broader than it needed to be, and it becomes a real gap if the "only the creator exists before activation" assumption ever stops holding, for example a workspace left pending or a future flow that adds members before activation. The bypass exists because a pending workspace has no roles yet, so permissions cannot be resolved for it. Keeping a small explicit allowlist preserves that while removing the blanket skip. ## Scope Only the `updateWorkspace` path. `SettingsPermissionGuard` has a similar bypass for `PENDING_CREATION` / `ONGOING_CREATION`, but it covers 62 resolvers including billing endpoints that onboarding legitimately calls before activation, so narrowing it needs its own analysis and is deliberately left out. ## Tests `workspace-update-before-activation.integration-spec.ts`, run against a real database with the seeded workspace flipped to `PENDING_CREATION`: - a security sensitive field (`allowImpersonation`) is rejected and the stored value is unchanged - mixing a setup field with a security sensitive one rejects the whole update, and `displayName` is not persisted - setup fields (`displayName`) still apply, so the restriction does not break workspace setup Both rejection tests were verified to fail when the old blanket early return is put back, while the positive control keeps passing. The existing `settings-permissions/workspace*` suites still pass (38 tests total), confirming no change for activated workspaces. --- _Generated by [Claude Code](https://claude.ai/code/session_01Qf58T7Lm7PazNbS3UwaZPd)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23781?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. --> |
||
|
|
7c9ec6a770 |
Continue the workspace setup chat in the side panel when navigating away (#23744)
https://github.com/user-attachments/assets/579a8d41-901e-41c0-85a4-f23e6bc3da8d The /workspace-setup full-page chat shows the nav drawer, so users can navigate away mid-conversation and lose sight of the chat. Leaving the page by any means (drawer link, browser back) now opens the same conversation in the Ask AI side panel, with the full-page chat visually shrinking into the panel via the panel's existing width transition. The page marks a handoff atom while mounted; the side panel consumes it in a mount layout effect (pre-paint, so no flash frame), opens the Ask AI page, and enters at full width before shrinking. The Close button still exits without reopening the panel, the Collapse button keeps its behavior and gains the same animation, and prefers-reduced-motion skips it. Mobile is unchanged since the full-screen panel would cover the destination page. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23744?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. --> |
||
|
|
8bfa9c4adb |
Proxy API routes through the vite dev server to keep local dev same-origin (#23779)
Replaces #23774 (closed), rebased on latest main. ## Problem Since the cookie-session migration (#23642), the front sends every request with `credentials: 'include'` and the server only reflects `Access-Control-Allow-Origin` for the exact origins in the credentialed allowlist (`SERVER_URL`, `FRONTEND_URL`, `AUTH_COOKIE_ALLOWED_ORIGINS`). Any other origin gets the `*` wildcard, which browsers reject for credentialed requests. Local dev is split-origin by default (front on `localhost:3001`, API on `localhost:3000`), and with `IS_MULTIWORKSPACE_ENABLED` every workspace subdomain (`apple.localhost:3001`, ...) is yet another origin. Each locally created workspace would need a manual `AUTH_COOKIE_ALLOWED_ORIGINS` entry. ## Solution Make local dev same-origin instead of widening the CORS policy: the vite dev server now proxies all top-level API route prefixes to the backend, and the front calls its own origin. - `vite.config.ts` adds a `server.proxy` covering the backend's top-level prefixes (`/graphql`, `/metadata`, `/admin-panel`, `/auth`, `/rest`, `/file`, `/client-config`, ...), defined in `src/config/apiProxyPrefixes.ts`. Keys are anchored regexes (`^/auth($|[/?])`) so SPA routes sharing a prefix (`/authorize`, `/settings`) are not swallowed. The target defaults to `http://localhost:3000` and follows `REACT_APP_SERVER_BASE_URL`. `changeOrigin` stays off so the backend sees the browser's Host: same-origin checks (CSRF, cookie issuance) and workspace resolution by subdomain work unchanged through the proxy. - `config/index.ts` collapses to `window._env_?.REACT_APP_SERVER_BASE_URL || window.location.origin`. Every supported production path injects `window._env_` (docker entrypoint fails hard without `REACT_APP_SERVER_BASE_URL`; a server-served front gets it from `generateFrontConfig()`), and in dev the current origin is correct on `localhost:3001` and every `*.localhost:3001` workspace subdomain thanks to the proxy. The removed `http://<hostname>:3000` fallback only served an un-injected production bundle browsed on localhost, a setup whose credentialed auth the cookie-session migration had already broken. The credentialed allowlist itself is unchanged and stays strict; since dev traffic is same-origin, the per-subdomain cookie-allowlist problem disappears without loosening any production CORS/CSRF policy. ## Tests - `src/config/__tests__/apiProxyPrefixes.test.ts` guards the proxy boundary in both directions: representative backend path shapes (including `/metadata?query=...` and `/auth/...`) must match, every SPA route from the `AppPath` enum and vite's own dev paths must not — so a future route collision fails unit tests instead of breaking dev. - Verified against running dev servers: API paths proxy to the backend from both `localhost:3001` and `apple.localhost:3001`, while SPA routes `/settings` and `/authorize` still serve the vite app; a same-origin POST from `apple.localhost:3001` goes through with no CORS involvement. - `lint:diff-with-main` and `typecheck` pass for twenty-front. --------- Co-authored-by: Félix Malfait <felix@twenty.com> |
||
|
|
8a856a4bce |
Standardize dragged element feedback (#23772)
## Summary - make dragged table rows use a consistent background across sticky cells - apply one shared opacity treatment to table rows, Kanban cards, and other dnd-kit feedback ## Before/After <img width="1400" height="1980" alt="drag-feedback-before-after" src="https://github.com/user-attachments/assets/ee1fe81f-a762-4343-8db6-c51cf63dbac8" /> |
||
|
|
0f35b5895a |
fix(workflow): reject if-else branches with a dangling filterGroupId (#23758)
## Summary An If/Else workflow step branch whose `filterGroupId` doesn't resolve to any entry in `stepFilterGroups` was matching unconditionally — before any of the step's real conditions were evaluated — instead of being rejected. This let a single stale or mistyped `filterGroupId` silently hijack the routing of an entire If/Else step. Fixes #23754 ## Problem Reproduced with a standalone unit test against `findMatchingBranch`: ```ts const branches = [ { id: 'branch-A', filterGroupId: 'group-id-that-does-not-exist', nextStepIds: ['wrong-step'] }, { id: 'branch-B', filterGroupId: 'real-group', nextStepIds: ['correct-step'] }, ]; const stepFilterGroups = [{ id: 'real-group', logicalOperator: 'AND' }]; const resolvedFilters = [{ /* branch-B's real filter, evaluates to false */ }]; findMatchingBranch({ branches, stepFilterGroups, resolvedFilters }).id; // => 'branch-A' (its condition was never evaluated at all) ``` `branch-A` wins even though its `filterGroupId` doesn't exist and `branch-B`'s actual (non-matching) filter was correctly evaluated to `false`. ## Root cause `find-matching-branch.util.ts` builds `branchFilterGroups` via `collectAllDescendantGroups(branch.filterGroupId, stepFilterGroups)`, which silently returns an empty `Set` when the root id isn't found. The resulting empty `branchFilterGroups`/`branchFilters` are passed to `evaluateFilterConditions`, which treats "both empty" as vacuously `true` — a rule that's correct for the real trailing else-branch (no `filterGroupId` at all, by design) but indistinguishable, at this call site, from "the referenced group doesn't exist." Since `Array.prototype.find` returns the first match, this branch wins over any later branch whose condition was actually evaluated. There was also no validation path that would catch this before execution: `validateBranchingStep` (`validate-workflow-graph.util.ts`) already checks If/Else branch count and `nextStepIds` connectivity, but had no check for `filterGroupId` referential integrity. ## Fix 1. `find-matching-branch.util.ts` — throw `WorkflowStepExecutorException` (`INVALID_STEP_INPUT`) when a branch's `filterGroupId` doesn't resolve to any group, instead of silently falling through to `evaluateFilterConditions({filterGroups: [], filters: []})`. This mirrors the sibling guard clauses already in this action for other malformed-input cases. 2. `validate-workflow-graph.util.ts` — extended the existing `IF_ELSE` branch checks in `validateBranchingStep` with the same check, surfaced as a new `IF_ELSE_BRANCH_FILTER_GROUP_NOT_FOUND` issue code, so `validate_workflow` catches this before a workflow ever runs. **Alternative considered:** fixing only at validation time. Rejected — validation can be skipped (e.g. the AI workflow-editing tool's `validate: false` option) or bypassed entirely by a direct API write, so the execution-time guard is the actual fix; the validation check is defense in depth, not a substitute. **Alternative considered:** silently skipping the malformed branch instead of throwing. Rejected — throwing immediately gives a specific, actionable error pointing at the exact misconfiguration, matching this file's existing error granularity (distinct messages for "not an if-else step", "no branches", "missing filter groups/filters", "no matching branch"). ## Tests - `find-matching-branch.util.spec.ts` (new) — real-condition match, else-branch fallback match, throws on a dangling `filterGroupId` (fails on `main`, passes here), throws when no branch matches and there's no else branch. - `validate-workflow-graph.util.test.ts` (+2) — flags `IF_ELSE_BRANCH_FILTER_GROUP_NOT_FOUND` for a dangling reference; does not false-positive on a correctly-configured branch. - Full module suites: `npx nx test twenty-server` scoped to `src/modules/workflow` → 68 suites / 626 tests passed. `npx jest packages/twenty-shared/src/workflow` → 30 suites / 248 tests passed. - `npx nx lint twenty-server twenty-shared` and `npx nx typecheck twenty-server twenty-shared` → clean. ## Compatibility / risk Internal-only change to workflow execution and validation logic — no GraphQL schema change, no public API signature change, no migration. A workflow that today relies (accidentally) on the silent "dangling group = always match" behavior would start throwing at execution time, but that was never intentional or documented behavior. ## Out of scope - Branch **ordering** invariants (e.g. asserting the group-less else branch is always last) — not needed for this fix; the defect reproduces purely from a dangling `filterGroupId`, independent of order. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23758?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: Thomas Trompette <thomas.trompette@sfr.fr> |
||
|
|
29aa6e85d6 |
fix(front): only show Discard Draft when workflow has a published version (#23756)
## Problem Draft workflows expose a **Discard Draft** action, but it fails when the draft is the *only* version of the workflow. The backend refuses the delete with `The initial version of a workflow can not be deleted` (guard in `validateWorkflowVersionForDeleteOne`), yet the action was still shown. The display condition and the delete-guard disagreed: - Display condition: `every(selectedRecords, "versions.length")` -> truthy when there is **at least one** version. - Delete guard: forbids deletion unless **another** non-deleted version exists. So a workflow whose only version is a draft showed the button, and clicking it hit a `FORBIDDEN` error. ## Fix Show the action only when the workflow has a published version to fall back to, which mirrors the backend guard: ``` every(selectedRecords, "lastPublishedVersionId") and everyEquals(selectedRecords, "currentVersion.status", "DRAFT") and noneDefined(selectedRecords, "deletedAt") ``` `lastPublishedVersionId` is a plain scalar already on the workflow. `every` (truthiness) is used rather than `everyDefined` because the field is an empty string for never-published workflows, and `everyDefined` would treat `""` as present. The command-menu evaluator reads records straight from the store, and the index/table view only fetches visible columns, so the enrichment provider now also backfills `lastPublishedVersionId` (already fetched by `useWorkflowsWithCurrentVersions`) to keep the condition reliable outside the record show page. ## Existing workspaces The standard-application full sync only runs at workspace creation, so editing the constant alone would fix new workspaces but leave existing ones showing the broken button. A `2.27.0` workspace upgrade command re-syncs the `discardDraftWorkflow` availability expression for existing workspaces, updating it only when it still equals the legacy `versions.length` value (so custom expressions are left untouched). Mirrors the existing 2-23 command-menu-item sync pattern. ## Notes - The `>`-style "more than one version" comparison is not expressible in the current `conditionalAvailabilityExpression` grammar (comparison operators only reach top-level scalars like `numberOfSelectedRecords`, not per-record paths). Gating on `lastPublishedVersionId` achieves the same intent without adding a parser helper. ## Test - Fresh workflow (single draft) -> Discard Draft hidden. - Publish, then edit to create a new draft -> Discard Draft shown and works. - Unit test on the sync-operations builder: updates the legacy expression, no-ops when already synced / custom / missing. |
||
|
|
3833015626 |
Ignore expired invitations in invitation lookups (#23749)
## What Invitation lookups did not filter on `expiresAt`, so expired invitations were still treated as active. This aligns them with the sibling `findInvitationsByEmail`, which already applied that filter. - `WorkspaceInvitationService.getOneWorkspaceInvitation` - added `deletedAt IS NULL` and `expiresAt > now` (also converted to a typed `findOne` so the column references are checked). - `AuthService.findInvitationForSignInUp` - added `expiresAt > now` (it already filtered `deletedAt`). - `throwIfOnboardingInvitationLimitReached` - expired tokens no longer count toward the onboarding invitation limit. - `createWorkspaceInvitation` - deletes the expired token for that email before issuing a replacement, so re-invites don't accumulate stale rows. ## Why Without the filter, an expired pending invitation behaved as if it were still active: - On sign-up with a personal invite token, an expired invitation still granted access to the workspace. - Re-inviting an email whose invitation had lapsed reported `INVITATION_ALREADY_EXIST` instead of sending a fresh invite. - Expired onboarding invitations still consumed quota, so the limit could be hit by invitations nobody could use. Once expired tokens are ignored on read, a re-invite would leave the old row behind, so `createWorkspaceInvitation` now removes it. The delete is scoped to the same workspace, invitation token types, that exact email, and `expiresAt <= now`, so it can only remove tokens that are already unusable. Closes twentyhq/private-issues#503 ## Tests Integration suites added, run against a real database: - `auth/sign-up/failing-sign-up-with-expired-invitation` - expired personal invitation is rejected (snapshot asserts the specific `FORBIDDEN` error). - `auth/sign-up/successful-sign-up-with-valid-invitation` - positive control: a valid invitation still grants access, so the rejection above cannot pass for an unrelated reason. - `expired-workspace-invitation` - re-invite over an expired invitation succeeds and leaves exactly one (fresh) token; a valid invitation is still reported as already existing. Each assertion was verified to fail when its corresponding filter is removed. Unit tests (`workspace-invitation.service.spec.ts`, `auth.service.spec.ts`), typecheck, and lint all pass. Not included: invitations that expire and are never re-invited still linger, since no cron reaps invitation tokens today. |
||
|
|
be051c8724 |
Keep the token pair as a fallback after switching to cookie auth (#23755)
`CookieSessionBootEffect` cleared the token pair the moment it switched a client onto cookie auth. That leaves the client with a single credential, and a server that still has `AUTH_COOKIE_SESSIONS_ENABLED=false` ignores the session cookie entirely — `extractSessionTokenFromRequest` early-returns when the flag is off. A cookie-only client is therefore unauthenticated against such a server, `handleTokenRenewal` finds no refresh token, and `onUnauthenticatedError` signs the user out. That is not a hypothetical state. It is every request routed to a not-yet-rolled pod while the flag is being enabled, and every request after the flag is rolled back. Requests are load-balanced per request, so a migrated client hits an old pod almost immediately and gets signed out; signing back in can migrate it again and repeat for the length of the rollout. It also means rollback was not free, contrary to how it was described: flipping the flag back to `false` signed out everyone who had already migrated, because the pair they were supposed to fall back to had been deleted. ## Approach Keep the token pair as a dormant fallback, and stop *sending* it while cookie auth is active. Both halves are needed. Retaining it without suppressing the header would be worse than the bug: `validateTokenByRequest` checks the Bearer token first and only falls back to the session cookie when there is none, so a client that keeps sending Bearer would never exercise the cookie at all, and `CookieSessionCsrfMiddleware` bypasses on any Bearer-carrying request. Cookie sessions would silently become a no-op. So: - `switchToCookieAuth` no longer nulls the token pair - the auth link omits `authorization` while cookie auth is active, leaving the cookie as the credential in use - on an unauthenticated error while cookie auth is active, the client deactivates cookie auth once per operation and falls through to the existing renewal path, which replays with a fresh Bearer The fallback deliberately goes through renewal rather than replaying immediately: access tokens live 10 minutes, so the retained one has usually expired while the client was authenticating by cookie, and an immediate replay would just fail again. `isCookieAuthActive` is read and written through `localStorage` from the link because the links run per request and must agree with the atom synchronously — a React state update lands a render too late to affect the request being built. ## Follow-up This trades the immediate removal of the token pair from `localStorage` for rollout safety, so the XSS-exfiltration surface that cookie sessions close stays open a while longer. Once cookie sessions are stable across every environment, the retained pair should be dropped — reverting to a clear on `switchToCookieAuth` is a one-line change. ## Test Three cases added to `apollo.factory.test.ts`: no Bearer header while cookie auth is active; an unauthenticated response falls back and replays with the token pair rather than calling `onUnauthenticatedError`; and the fallback is attempted only once before going through renewal. The existing `CookieSessionBootEffect` assertion that the pair is cleared is inverted to assert it is retained. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23755?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. --> |
||
|
|
0379b537dd |
feat(workflow): repair orphan core workflow versions via upgrade command (#23739)
## Context Part of the workflow + workflowVersion in core migration. Before enabling `IS_WORKFLOW_VERSION_IN_CORE_ENABLED` on real workspaces, legacy drift between the workspace source-of-truth and the core mirror must be repaired. The dangerous category is orphan **ACTIVE** core `workflowVersion` rows: once dispatch reads core (later step), they become phantom triggers. ## The problem Some workspaces carry orphan `core."workflowVersion"` rows: core versions that no workspace version references via `coreWorkflowVersionId`. On one production workspace this was 57 rows, 5 of them ACTIVE `DATABASE_EVENT`. Root cause is pre-2.25 residue: - The v2.22 `backfill-workflow-version-core-links` command minted a core row per active workspace version, copying `status` verbatim. - The workflow delete/destroy cascade did not clean core until #23356 (first in v2.25.0): there was no `deleteCoreVersionsByWorkflowIds` and the deactivate-on-delete status flip was not mirrored. - Workflows deleted then destroyed in that window left their core rows behind, still ACTIVE, with every workspace referrer gone. Current code (>= v2.25) cleans core transactionally on delete/destroy, so this cannot recur. This command clears the historical residue. ## What this does A `@RegisteredWorkspaceCommand('2.28.0')` that, per provisioned workspace: - Deletes `core."workflowVersion"` rows with no workspace referrer, then `invalidateAndRecompute`s the automated trigger map. - `NOT EXISTS` does not filter `deletedAt`, so a soft-deleted (restorable) workspace version still protects its core row. - Scoped to `applicationId = workspaceCustomApplicationId OR NULL`, so future app-owned core versions are never touched. - Supports `--dry-run` and is idempotent (re-run is a no-op). ## Testing Run through the real upgrade harness on a dev instance: - Injected 2 synthetic orphans (1 ACTIVE `DATABASE_EVENT`, 1 ARCHIVED). `--dry-run` reported `Would delete 2 (1 ACTIVE)`; real run reported `Deleted 2 (1 ACTIVE)`. - The 5 legit linked core versions were untouched (referrer guard verified). Orphans remaining: 0. - Re-run logged `No orphan core workflowVersion rows` (idempotent). - typecheck, oxlint, oxfmt all clean. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23739?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. --> |
||
|
|
1ebcbdda42 |
feat(server): export event-loop delay and workspace-cache recompute metrics (#23751)
## What
Exports three sets of metrics to Prometheus to diagnose the recurring
"Slow DB Query" Sentry issues on `POST /graphql` (e.g. the
`fieldMetadata` select):
- `twenty_nodejs_eventloop_delay_seconds` (mean/p50/p99/max) +
`twenty_nodejs_eventloop_utilization`
- `twenty_workspace_cache_recompute_duration_seconds{cache_key}` — wall
time per provider `computeForCache`
- `twenty_workspace_cache_redis_write_duration_seconds` — serialize +
Redis write time for recomputed entries
## Why
Investigation of these issues showed:
- The flagged query executes in ~1ms (prod EXPLAIN), so it is not a
query/index problem.
- Event counts do **not** correlate with connection-pool acquire latency
(Pearson ~0 against both p99 and the direct count of >1s acquires), so
it is not pool contention.
- Event counts **do** correlate with pod CPU (Pearson +0.40).
The leading explanation is that the slow `db` span is inflated by
event-loop saturation during the workspace metadata cache recompute:
`Promise.all` parallelizes the I/O, but the synchronous work it cannot
parallelize (TypeORM entity hydration of JSONB-heavy result sets, then
`JSON.stringify` of the flat-map payloads into Redis) blocks the single
event-loop thread, so an awaiting query resolves ~1.3s late.
Node event-loop delay was only being collected by Sentry's
`nodeRuntimeMetricsIntegration`, never exported to Prometheus, so it
could not be graphed or correlated in Grafana. These metrics confirm (or
refute) the mechanism and give a before/after baseline for the fix.
Grafana panels land in a companion twenty-infra PR.
## Notes
- Metric-only change; no behavioral change to the cache.
- Uses the same OTel `MetricsService` / meter as the existing DB pool
metrics.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23751?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. -->
|
||
|
|
6e1c710a7d |
Fan out Fireflies backfill into batched jobs (#23622)
Backfills Fireflies calls missed by webhooks as a fan-out of enqueued
batch jobs.
- `POST /fireflies/backfill { days }` (capped at 3650) validates the
request, enqueues a background discovery worker, and returns immediately
with `{ outcome: 'started' }`.
- The discovery worker and the daily 03:00 healer (fixed seven-day
window) list Fireflies transcript ids for the window, split them into
batches of 20, and enqueue one import job per batch. Discovery reports
transcript, batch, and successfully enqueued batch counts, including
partial enqueue failures.
- Listing is bounded to 2,000 pages. Batch delays are staggered by 60
seconds and capped at the queue's seven-day scheduling horizon.
- Each batch job syncs only its explicit id list, importing only missing
transcript/summary fields; already-synced calls short-circuit.
- Batch jobs use `retryLimit: 2`; a Fireflies 429/5xx fails the job so
the queue retries it. Calls within a batch stay paced one second apart.
Supersedes #23623.
---------
Co-authored-by: martmull <martmull@hotmail.fr>
|
||
|
|
167d684a29 |
Run the people-data-labs spec suite in CI (#23743)
The PDL app's unit vitest config only matched `src/**/*.test.ts`, but the app's test files were named `*.spec.ts`, so CI ran a single file and 368 tests never executed. Renamed the 88 spec files to `*.test.ts`, the convention every other public app and the `create-twenty-app` scaffold already use, which leaves `vitest.unit.config.ts` byte-identical to the other apps. `capitalize-name` had both a spec and a test file covering the same function, so the more thorough one was kept. Turning the suite on surfaced one real failure: `collectUuids` in the select-option test scooped up the `path` strings added to `PDL_LOGIC_FUNCTION_CONSTANTS` and asserted they were v4 UUIDs. It now stops at any object with a `universalIdentifier` and collects only that value. `yarn test:unit` is green at 88 files / 368 tests, with `yarn typecheck` and `yarn lint` clean. |
||
|
|
72e2301697 |
fix(settings): unbreak the AI tools table and validate page-level graphql documents (#23731)
## Problem
Opening Settings > AI (tools tab) fails with a misleading error:
```
{"errors":[{"message":"App version mismatch.","extensions":{"code":"APP_VERSION_MISMATCH"}}]}
```
The real error is a GraphQL validation failure. Both queries on that
page select a `logo` field that no longer exists:
- `FindManyApplicationsForToolTable` selects `Application.logo`
- `FindManyMarketplaceAppsForToolTable` selects `MarketplaceApp.logo`
#23411 renamed `MarketplaceApp.logo` to `logoUrl` and stopped exposing
`logo` on `Application`, but missed these two queries (added in #21121)
and the types/component behind them. So the schema exposes `logoUrl`
while the AI settings page still asks for `logo`.
The reason the error says "App version mismatch" is that
`useGraphQLErrorHandlerHook.onValidate` does not return validation
errors as-is: when a document fails validation and the request's
`x-app-version` is semver-lower than the server's `APP_VERSION`, it
throws `APP_VERSION_MISMATCH` instead. On an instance where the frontend
build trails the backend, every genuine query bug on that instance
surfaces as this message, and refreshing never helps because the query
is wrong in the code.
## Why nothing caught it
Two gaps lined up:
1. **The documents were invisible to codegen.** `codegen-metadata.cjs`
lists documents as an explicit allow-list of
`./src/modules/*/graphql/**` entries; nothing under `./src/pages/**` was
ever in it. Codegen validates every matched document against the live
schema and CI fails on drift, so the rename would have been caught had
these two queries been in the matched set.
2. **The response types were hand-written.**
`SettingsAgentToolApplication` / `SettingsAgentToolMarketplaceApp` were
free-standing object types declaring `logo?: string | null`, passed as
the `useQuery` generic. Nothing tied them to the schema, so they kept
compiling after the field was gone.
## Changes
Fix:
- Select `logoUrl` instead of `logo` in
`findManyApplicationsForToolTable` and
`findManyMarketplaceAppsForToolTable`.
Prevention:
- Add `./src/pages/**/graphql/**/*.{ts,tsx}` to `codegen-metadata.cjs`,
so page-level documents are validated by the CI codegen check and get
generated operation types.
- Add three more previously-unvalidated metadata modules to the same
list: `metadata-store`, `sse-db-event`, `geo-map`.
- Derive `SettingsAgentToolApplication` /
`SettingsAgentToolMarketplaceApp` from the generated operation types
instead of hand-writing them.
- Type `SettingsToolIcon`'s `ApplicationInfo` / `MarketplaceAppInfo` as
`Pick` of those, so a field disappearing from the schema is a compile
error rather than a silently-optional property.
- Regenerate `generated-metadata/graphql.ts` (additive: operation types
+ typed document nodes for the five newly covered documents).
App logos in the tools table also render again, which they hadn't since
#23411.
## Verification
Swept every file containing a `gql` document in twenty-front (484)
against the document globs of all three codegen configs. 65 are
uncovered, most legitimately so (runtime-generated record queries,
mocks, tests, stories). The static documents no config validated were
the two fixed here (broken), `metadata-store` / `sse-db-event` /
`geo-map` (valid, now covered), and `information-banner` /
`settings/legal` (valid, core schema — left alone since `codegen.cjs`
targets a schema that can't be validated offline; worth a follow-up).
Validated the fixed documents against the checked-in metadata SDL, and
reproduced `generated-metadata/graphql.ts` with the pinned codegen
toolchain to confirm the regenerated file matches what CI produces. CI's
own codegen check (`server-validation`) then confirmed it against a live
server, alongside front typecheck, lint, jest and builds.
## Follow-up, not in this PR
The error masking in `use-graphql-error-handler.hook.ts` is worth
revisiting:
- It discards the original validation errors, so a real query bug is
unreportable on any deployment where the frontend trails the backend.
Attaching the underlying errors (or at least logging them) would have
made this a one-minute diagnosis.
- The `x-schema-version` branch above it is dead: no client in the repo
sends that header.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FuH2fAvLct7NvTUEnAWPSV)_
|
||
|
|
55707868cd |
Add IS_FEATURE_FLAG_MANAGEMENT_ENABLED to unlock feature flag toggling outside cloud (#23750)
The admin panel has a per-workspace Feature Flags tab that can toggle
any key in `FeatureFlagKey`, but it was hidden unless `NODE_ENV` was
`development` or billing was enabled:
```ts
canManageFeatureFlags:
this.twentyConfigService.get('NODE_ENV') === NodeEnvironment.DEVELOPMENT || isBillingEnabled,
```
Preview apps run `NODE_ENV=production` with billing off, so the tab
disappears and there is no way to flip a flag on a `trycloudflare.com`
app short of editing `core.featureFlag` by hand.
This is purely a client-side gate. `updateWorkspaceFeatureFlag` is
guarded server-side by `AdminPanelGuard` (`canAccessFullAdminPanel`) and
has no billing or cloud check, so unhiding the tab does not widen what
the server accepts. The gate has to be coarse because `/client-config`
is a public unauthenticated endpoint and cannot carry per-user state,
which is presumably why it ended up keyed on `NODE_ENV`/billing.
## Changes
- New `IS_FEATURE_FLAG_MANAGEMENT_ENABLED` config variable
(`ADVANCED_SETTINGS`), defaulting to `false`.
- `canManageFeatureFlags` now also honours it. Development mode and
billing-enabled instances behave exactly as before.
## Notes
- Deliberately not added to `docker-compose.yml` or `.env.example`, and
not documented in `feature-flags.mdx`. Anyone who needs it can set the
env var directly.
- `twentyhq/ci-public#3` sets it for preview apps by patching the
variable into the server service and the generated `.env`, so it does
not depend on the compose file carrying the entry.
- The seeded dev users already have `canAccessFullAdminPanel: true`, and
on a non-seeded instance the first user to sign up is granted it, so the
tab is reachable once the variable is on.
## Test
`client-config.service.spec.ts` covers the new variable unlocking
management in production with billing off, alongside the existing cases
for development mode, billing enabled, and both off.
---
_Generated by [Claude
Code](https://claude.ai/code/session_015ovxLQNTHxvBbhfrKq2aZt)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23750?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. -->
|