b768441c13aed1c978522f59d2cc326f3bd5a708
10745 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8553c574db |
Improve twenty-ui packaging for standalone publishing (#21946)
Quick packaging wins to move twenty-ui closer to a standalone publishable library. - Move `react`/`react-dom` to `peerDependencies` (`^19.0.0`) so consumers provide a single React and we avoid duplicate-React bugs. They stay in `devDependencies` for the in-repo build, and `vite.config.ts` now derives the Rollup `external` list from peer deps too so React stays externalized instead of bundled. - Declare `type-fest` in `dependencies`. It was a phantom dep (resolved only via root hoisting) and its types are referenced by the emitted json-visualizer `.d.ts`, so standalone consumers need it. - Move build-only `glob` to `devDependencies` and add `typescript` (both used only by `generateBarrels.ts`). - Make `tsconfig.json` self-contained by inlining the base compiler options, and point the Vite `cacheDir`/`optimizeDeps.exclude` at package-local paths. Verified: typecheck, build (React confirmed externalized in `dist`, not inlined), dts emission, and unit tests all pass. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21946?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. --> |
||
|
|
2276e12c12 |
fix(server): skip callRecordings widget in calendar-event page sync when field is absent (#21967)
## Context On main's auto-upgrade, `SyncCalendarEventRecordPageCommand` (2.15.0 workspace command) failed for workspaces that don't have the call-recording feature metadata, aborting the upgrade with: ``` Migration action 'create' for 'pageLayoutWidget' (universalIdentifier: f473b435-...) failed Caused by: Field metadata not found for universal identifier: 48d6d151-... (calendarEvent.callRecordings) ``` ## Root cause The command always included the `callRecordings` page-layout widget. That widget's configuration references the `calendarEvent.callRecordings` relation field (`48d6d151`). Workspaces that never had the `callRecording` object / relation field synced fail transpilation with `ENTITY_NOT_FOUND`, and since one workspace failure aborts the segment, the whole upgrade stops. On the affected environment, ~half of active/suspended workspaces lack both the `callRecording` object and the `calendarEvent.callRecordings` field. ## Fix Only add the `callRecordings` widget when the `callRecordings` field actually exists in the workspace (checked via `flatFieldMetadataMaps.byUniversalIdentifier`). This mirrors the command's existing guard on the `calendarEvent` object. Workspaces without the field still get the fields / participants / timeline widgets; the callRecordings widget is simply skipped. The view fields for the record page do not reference `callRecordings`, so only the widget needed guarding. ## Test plan - [x] `nx typecheck twenty-server` - [x] `oxlint --type-aware` on the changed file: 0 errors - [ ] CI <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21967?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. --> |
||
|
|
6d7380dfec |
Remove unused call-recording application (#21966)
as title <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21966?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. --> |
||
|
|
5f94ee3e02 |
perf(server): raise workspace local cache size and meter evictions (#21954)
## Context The per-pod in-process workspace metadata cache (`WorkspaceCacheService`) evicts by a fixed **1,000-entry count**. Each workspace's cached metadata is ~1 MB (dominated by the flat `field-metadata` map) over ~10–13 entries, so 1,000 entries ≈ only a few dozen workspaces per pod. On a multi-tenant instance with far more active workspaces, the L1 cache thrashes — LRU-evicting and re-fetching the ~1 MB of maps from Redis on misses — and since the cache sits in one AZ while pods span both, ~half of that transfer is billed cross-AZ. (In prod this cache node serves ~2.6 TB/day.) ## What this does - **Raise `MAX_LOCAL_CACHE_ENTRIES` 1,000 → 7,500** (~500 workspaces at ~1 MB each; server pods are 4 GiB / `--max-old-space-size=3500`, so this stays well within the heap). - **Add a `workspace-metadata-cache/local-eviction` counter** (incremented by the number of entries dropped each time the cache hits capacity) so we can see capacity-driven evictions in metrics and tune the limit from real data rather than guessing. Eviction stays **batched** (`MIN_EVICT_KEYS`), so the sort runs about once per 100 inserts at steady state rather than on every write. ### Why count, not bytes An earlier iteration bounded by measured bytes, but that required `JSON.stringify`-ing every cached value (incl. the ~1 MB field-metadata maps) on every write — meaningful CPU/GC overhead on the fill path. A raised count cap avoids that entirely; the new eviction metric gives us the signal to right-size it. No change to cache semantics, hashing, or the Redis format. |
||
|
|
4dd9253d01 |
perf(server): rate-limit the active event stream count scan (#21951)
## Context
`twenty_event_streams_live_total` (an observable gauge) calls
`getTotalActiveStreamCount()` →
`scanAndCountSetMembers('workspace:*:activeStreams')`, which runs a
full-keyspace `SCAN MATCH` over the entire Redis DB **on every metrics
scrape, on every pod**. `SCAN MATCH` walks every key (filtering only the
output), and the subscriptions namespace shares the node with the
workspace metadata cache (~190k keys in our prod), so this was the
dominant Redis command (billions of `SCAN` calls) to count a handful of
sets.
## What this does
Cache the count and refresh it via the scan at most once per
`ACTIVE_STREAM_COUNT_REFRESH_MS` (5 min) per pod, instead of on every
scrape. Steady-state scrapes return the cached value; the authoritative
scan still runs periodically so the gauge stays fresh.
Single-method change; no new Redis keys, no data-model changes.
|
||
|
|
5ce91e711c |
fix(website): render partner marketplace dynamically to stop profile 404s (#21963)
Fixes #21962 ## Root cause Partner data is materialized **at build time** from the live partners API, and a build-time fetch failure is silently swallowed (`fetch-live-marketplace-partners.ts` → `catch → return []`). One root cause surfaces in two places: - **All profile links 404 (the reported issue).** `profile/[slug]/page.tsx` enumerates slugs in `generateStaticParams()` — a build-time fetch — under the `[locale]` layout's inherited `dynamicParams = false`. If that build-time fetch fails or returns empty, **zero slugs are generated**, and because `generateStaticParams` never re-runs at runtime and `dynamicParams=false` disables on-demand generation, **every** `/partners/profile/[slug]` 404s until the next deploy — even though the marketplace returns 20 partners client-side. - **`/partners/list` intermittently renders empty.** The list page is statically prerendered; the same build-time failure bakes an empty marketplace and freezes it in the OpenNext/R2 cache. This only reproduces on deployed builds: local dev renders on demand, the env vars are present, and the partners API is reachable. ## Fix Two route-segment config changes, no data-layer rewrite: | File | Change | Effect | |---|---|---| | `(site)/partners/profile/[slug]/page.tsx` | `export const dynamicParams = true` | Any slug renders on-demand at runtime where the API is reachable. `generateStaticParams` becomes best-effort prewarm instead of a 404 trap. Genuinely missing slugs still `notFound()`. | | `(site)/partners/list/page.tsx` | `export const dynamic = 'force-dynamic'` | List is fetched at runtime, never baked empty at build. The explicit `next: { revalidate: 300 }` on `/s/partners` survives `force-dynamic` (`patch-fetch.js` only forces no-store when there is *no* explicit fetch config), so responses stay cached and are served stale on transient blips. | ## Verification - `oxlint` + `oxfmt --check`: clean on both files. - `jest src/partners-marketplace`: 36/36 pass. - End-to-end behavior (static-vs-dynamic rendering) is a build/deploy concern with no meaningful unit test — needs a deploy to confirm against the live marketplace. ## Note / follow-up (out of scope) Edge case left deliberately: if a real partner's *first-ever* request lands during an API outage, its on-demand `notFound()` could cache for ~300s. Closing that means making the slug lookup distinguish "fetch failed" from "not found" — a larger change than this fix. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21963?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. --> |
||
|
|
c608792aea |
feat: real-time email & calendar tabs on record pages (#21953)
emails and calendar tabs only refreshed on reload, unlike timeline. this subscribes to the participant object (messageParticipant / calendarEventParticipant) for the record's related people over the existing sse stream and refetches on change. relatedPersonIds is resolved server-side so any object with the tab inherits it, no per-object code. resolver stays the source of truth so visibility masking is untouched. |
||
|
|
068a8d4efe |
fix(front): keep relation field record tables scoped to the host record (#21293)
## Problem
When a relation field is added to a record page as a record **table**
widget
(Page Layouts → a `FIELD` widget with `fieldDisplayMode: TABLE` and a
`viewId`),
the table renders the **global** list of the related object instead of
only the
records related to the current record.
Steps to reproduce:
1. On a Company record page layout, add a to-many relation field (e.g.
`Opportunities`) as a widget and set its display mode to **Table** with
a view
(so it shows columns).
2. Open a Company record.
3. The Opportunities table lists *all* opportunities in the workspace,
not just
the ones linked to that company.
Note: when the same relation widget has **no** `viewId`, it is correctly
scoped
to the record — but then it can't render custom columns. So custom
columns and
relation-scoping were effectively mutually exclusive.
## Root cause
`FieldWidgetRelationTable` renders the related records through
`RecordTableWidgetRendererContent` using the widget's `viewId`. That
path loads
the view's filters and fetches the related object's records, but **never
applies
the relation filter** that constrains the table to the host record. With
a
`viewId` present, the table therefore shows the whole object.
The relation filter itself already exists elsewhere —
`RecordDetailRelationSection` builds
``{ `${inverseRelationFieldName}Id`: { in: [recordId] } }`` for its
aggregate.
It just isn't applied on the table path.
## Fix
- Add a pure helper `getRelationTableFilter()` that builds the
host-relation
filter for a to-many relation field (morph-aware, mirroring
`RecordDetailRelationSection`).
- `FieldWidgetRelationTable` computes this filter and passes it down via
the
existing `RecordFilterValueDependenciesContext` (new optional
`relationTableFilter`).
- `useFindManyRecordIndexTableParams` (rows) and
`useAggregateRecordsForRecordTableColumnFooter` (footer aggregates) AND
this
filter into their queries.
The filter is scoped to the relation-table instance through the context
and
defaults to `undefined`, so **every other table (record index, kanban,
dashboards, …) is unaffected** — `combineFilters` / object spread treat
the
absent filter as a no-op. No backend changes.
## Tests
- New unit tests for `getRelationTableFilter` (to-many → foreign-key
filter;
to-one → none; unresolved relation type / field → none; morph relation;
missing morph target names → none).
- `nx typecheck twenty-front`, `nx lint twenty-front`, and the new
`nx test twenty-front` suite pass locally.
## Screenshots
Same record (a "Centre" with 0 related theory allocations and 34 related
orders), same page-layout (relation fields shown as Table widgets with a
view).
**Before** — with a `viewId`, the relation tables show the *global*
lists: the
Theory Allocations table is full of allocations belonging to *other*
records,
and Collateral Orders shows 60 (the whole object's first page) instead
of 34.
<!-- drag the BEFORE screenshot here -->
**After** — the same tables are scoped to the record: Theory Allocations
is
empty (this record has none) and Collateral Orders shows exactly its 34
orders,
with the view's columns (Status / Total Value / Date).
<!-- drag the AFTER screenshot here -->
## Verification
Verified on a self-hosted instance running the equivalent change (the
four
touched files are byte-identical on `main` and the latest release tag):
a
relation table widget with a `viewId` now shows only the host record's
related
rows **with** the view's columns, the footer aggregates match the
visible rows,
and the global record index is unchanged. Confirmed across records with
different related-record counts (e.g. a record with 34 related orders
shows 34;
a record with 1 shows 1; records with 0 show an empty table).
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
|
||
|
|
c171c62099 |
chore(twenty-server): upgrade typeorm to 0.3.29 (#21957)
## Summary Upgrades **typeorm `0.3.26` → `0.3.29`** and adapts the twenty-orm `update`/`upsert` overrides to typeorm's newly-added `options.returning`. Upgrading to resolve [this](https://github.com/twentyhq/twenty/security/dependabot/1573) alert. ## Why `0.3.29` is the latest release compatible with `@ptc-org/nestjs-query-typeorm` (peers `typeorm@^0.3.15`; the `1.x` line has no compatible release, so it's blocked until that dependency moves). ## Changes **`chore` — bump** - `typeorm` patch descriptor `0.3.26 → 0.3.29` + `yarn.lock`. - Local patch carried over **unchanged** (pure rename) — both hunks (`PickKeysByType` nullable-awareness, `DeleteResult.generatedMaps`) are still absent upstream in `0.3.29`, so it remains load-bearing. **`refactor` — adapt overrides** - `0.3.29` adds `options?: UpdateOptions` (carrying `returning`) to `EntityManager`/`Repository` `update()`. The override must accept it at the base-mandated position, so it's added as its **own dedicated parameter** (not hidden inside `permissionOptions`), honoring `options.returning` with a fallback to Twenty's permission-aware `selectedColumns` (`'*'` default). - The same merge is applied to `upsert()`, which already received `UpsertOptions` but was dropping its `returning` field — so both write methods now treat the option identically. - Internal call sites + specs updated for the new parameter slot. ## Verification - `nx typecheck twenty-server` — **0 errors** - twenty-orm unit tests — **191 / 191 pass** - `oxlint` / `oxfmt` — clean |
||
|
|
6eb60f8a49 |
Add gallery screenshot to People Data Labs app (#21960)
Adds a marketplace gallery screenshot to the People Data Labs app. - Adds `public/gallery/cover.png` (Companies table with enriched fields) - References it via a new `screenshots` field in `application-config.ts`, matching the convention used by the other internal apps (Linear, Fireflies, Last Contact). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21960?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. --> |
||
|
|
0b8368cd6c |
Refactor search vector field (#21947)
# Introduction Refactoring the search vector field validation <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21947?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. --> |
||
|
|
a08f424cc5 |
suport non aws providers 1 (#21927)
Title: Relax @IsAWSRegion validation constraint for custom S3-compatible
storage endpoints
Summary: This PR updates the @IsAWSRegion decorator to support
non-standard region slugs (e.g., fr-par) when a custom S3-compatible
storage provider is used.
Previously, the decorator enforced a strict regex
(/^[a-z]{2}-[a-z]+-\d{1}$/) for all region variables, which caused
runtime validation errors and worker crashes when users tried to
configure non-AWS providers like Scaleway or DigitalOcean that use
different region formats.
This change introduces a conditional check: If the property being
validated is STORAGE_S3_REGION and a STORAGE_S3_ENDPOINT is defined on
the configuration object, the strict regex constraint is bypassed, and
any non-empty string is accepted.
Changes Made
is-aws-region.decorator.ts: Updated the IsAWSRegionConstraint class to
accept args: ValidationArguments. Added logic to bypass the regex
validation if args.property === 'STORAGE_S3_REGION' and
object.STORAGE_S3_ENDPOINT is present.
TypeScript Typings: **The AwsRegion interface intentionally remains
strictly typed as `${string}-${string}-${number}`. This preserves strict
compile-time types for standard usage, while class-validator and
class-transformer gracefully handle the runtime relaxation during
environment variable loading.**
Testing
I have added below script to test this function
```
const { validate, ValidateIf } = require('class-validator');
const { IsAWSRegion } = require('./packages/twenty-server/dist/engine/core-modules/twenty-config/decorators/is-aws-region.decorator');
class TestConfig {
constructor(region, endpoint) {
this.STORAGE_S3_REGION = region;
this.STORAGE_S3_ENDPOINT = endpoint;
}
}
ValidateIf((env) => !env.STORAGE_S3_ENDPOINT)(TestConfig.prototype, 'STORAGE_S3_REGION');
IsAWSRegion()(TestConfig.prototype, 'STORAGE_S3_REGION');
const config = new TestConfig('fr-par', 'https://s3.fr-par.scw.cloud');
validate(config).then(errors => {
if (errors.length > 0) {
console.error('Validation failed:');
errors.forEach(err => {
console.error(`Property: ${err.property}`);
console.error(`Constraints:`, err.constraints);
});
} else {
console.log('Validation passed!');
}
});
```
Screenshots
before
<img width="1210" height="188" alt="Screenshot_2026-06-22_12-51-51"
src="https://github.com/user-attachments/assets/4cd0613e-79bd-43db-8d90-5dd0f5341002"
/>
after
<img width="1394" height="152" alt="Screenshot_2026-06-22_12-52-28"
src="https://github.com/user-attachments/assets/e5287fb5-8462-46ce-a078-f5657dd689a5"
/>
Closes https://github.com/twentyhq/twenty/issues/21908
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21927?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: Charles Bochet <charles@twenty.com>
|
||
|
|
3030b7d0e5 |
Add people-data-labs on internal ci apps (#21941)
Add people-data-labs to internal apps CI: remove from CI_EXCLUDED_APPLICATIONS and unify config with twenty-discord/twenty-slack. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21941?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. --> |
||
|
|
e0fadfee7c |
Remove jotai from twenty-ui (#21937)
twenty-ui no longer depends on jotai, so its components work without a consumer-provided jotai store (better practice for a shared UI library). twenty-front keeps jotai; this is scoped to the library. - **Avatar**: tracks image-load failure in local `useState` instead of a global atom. - **Icons**: the icon registry moved from a jotai atom to a React Context. `IconsProvider` and `useIcons` keep identical signatures; the context itself stays internal. - Removed the unused `createState` helper, the `invalidAvatarUrlsAtomV2` / `iconsState` atoms, and `JotaiRootDecorator`; regenerated barrels and dropped the `jotai` dependency. No other package needs changes: nothing imports the removed symbols, and `twenty-sdk` (which re-exports twenty-ui via `export *`) simply stops surfacing the two leaked atoms on its next publish. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21937?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. --> |
||
|
|
003ad62f66 |
fix(server): surface nested QueryFailedError detail in upgrade error formatting (#21948)
## Context
While upgrading, a workspace migration failed with:
```
[Runner] [install-perf] migration failed after 20 action(s): Migration action 'create' for 'pageLayoutWidget' (universalIdentifier: f473b435-...) failed
```
The action names the failure but not *why* — unique violation? FK? which
key/row? The real cause is captured but was getting flattened away
before it reached anyone reading it.
## Root cause of the bad diagnostics
When a migration action fails, the action handler captures the real
error (typically a TypeORM `QueryFailedError` from `repository.insert`)
into
`WorkspaceMigrationRunnerException.errors.{metadata,workspaceSchema,actionTranspilation}`
and re-throws it intact. Caller-side formatters surface it — but
`formatUpgradeErrorForStorage` (read by the `upgrade-status` command)
flattened a nested `QueryFailedError` to just its `.message`, dropping
the PostgreSQL `code`, `detail` (the exact failing key/value) and
`query`.
## What this PR does
`formatUpgradeErrorForStorage` now **recurses** into nested causes, so a
wrapped `QueryFailedError` keeps its full driver detail.
Surfacing/logging stays a caller concern (the runner already produces
and re-throws the structured exception) — this PR only fixes the
formatter that was dropping detail. Added a unit test for the `create
pageLayoutWidget` unique-violation case.
### Stored upgrade error — before
```
Metadata error: duplicate key value violates unique constraint "IDX_..."
```
### After
```
Metadata error:
[QueryFailedError] duplicate key value violates unique constraint "IDX_..."
PostgreSQL code: 23505
Detail: Key (universalIdentifier)=(f473b435-...) already exists.
Query: INSERT INTO "core"."pageLayoutWidget" VALUES ($1)
```
## Scope
Diagnostics only — it surfaces the cause, it does not change migration
behavior. The underlying `create pageLayoutWidget` failure (likely a
unique/FK violation when upgrading existing workspaces, downstream of
#21673) is a separate follow-up once the exact cause is captured.
## Note / possible follow-up
`workspaceMigrationRunnerExceptionFormatter` (the GraphQL/app-install
surfacing path) has the same flattening issue — it reads
`error.errors.metadata.code`, but for a `QueryFailedError` the pg code
lives on `driverError.code`, so it falls back to `INTERNAL_SERVER_ERROR`
and loses `detail`. Left out of scope here; happy to fix in a follow-up
if wanted.
## Tests
- New unit test for a `QueryFailedError` nested in an `EXECUTION_FAILED`
exception; snapshots updated.
- `oxlint`, `oxfmt --check`, `nx typecheck twenty-server`, and the
affected jest suites pass.
|
||
|
|
64385842bb |
Add twenty-linear on internal ci apps (#21942)
Add twenty-linear to internal apps CI: remove from CI_EXCLUDED_APPLICATIONS and unify config with twenty-discord/twenty-slack. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21942?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. --> |
||
|
|
4b868f9b29 |
Add twenty-for-twenty on internal ci apps (#21944)
Add twenty-for-twenty to internal apps CI: remove from CI_EXCLUDED_APPLICATIONS and unify config with twenty-discord/twenty-slack. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21944?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. --> |
||
|
|
02a966bb7f |
make mergeMany atomic and optimize relation/field-map handling (#21885)
Closes [core-team-issue#2333](https://github.com/twentyhq/core-team-issues/issues/2333) ## Summary Hardens and optimizes `CommonMergeManyQueryRunnerService`: - **Atomicity**: wrap relation migration + duplicate deletion + survivor update in a single transaction so a mid-merge failure rolls back fully (previously failures were swallowed and could leave orphaned/half-merged data). - **Perf**: drop the redundant `find`-before-`update` in relation migration (2N → N queries, no row hydration) and hoist `buildFieldMapsFromFlatObjectMetadata` out of the per-field loops. ### Why a transaction (not parallelization) The relation migrations could be parallelized with `Promise.all`, but merge is a destructive operation: a partial failure leaves orphaned or half-merged records. We prioritize correctness, so the steps run inside one transaction. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21885?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> Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
eeca9cd42e |
fix(front): isolate record table dashboard widget filters on duplicate (#21936)
closes https://discord.com/channels/1130383047699738754/1518291134382608394 https://github.com/user-attachments/assets/931e4e88-44e8-4634-a7f9-e0564bd80fff <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21936?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. --> |
||
|
|
8b9e3a0fc3 |
Add self-hosting on internal ci apps (#21940)
Add self-hosting to internal apps CI: remove from CI_EXCLUDED_APPLICATIONS and unify config with twenty-discord/twenty-slack. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21940?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. --> |
||
|
|
b354df3c59 |
Add twenty-fireflies on internal ci apps (#21939)
Add twenty-fireflies to internal apps CI: remove from CI_EXCLUDED_APPLICATIONS and unify config with twenty-discord/twenty-slack. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21939?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. --> |
||
|
|
1eadef8ea0 |
fix(workflow): serialize object variables in resolved prompts (#21612)
## Problem
When a workflow passes a variable into a text input (e.g. an **AI
Agent** prompt) and that variable resolves to an object or array, the
resolved string contained `[object Object]` instead of the actual
content. The AI then received useless input.
## Cause
`resolveString` in the shared variable resolver builds the final string
with `String.prototype.replace`. When an embedded `{{variable}}`
resolved to an object, the replace callback returned the object
directly, which JS coerces to `"[object Object]"`. The rich-text
resolver had the same issue via `String(resolvedValue)`.
## Fix
When an embedded variable resolves to a non-null object (or array),
serialize it with `JSON.stringify` before inserting it into the
surrounding string. Primitive values keep their existing coercion
behavior, and the single-variable case (`{{message}}` with nothing
around it) still returns the raw object so non-string consumers are
unaffected.
Applied the same guard to both the plain and rich-text variable
resolvers for consistency.
## Tests
Added cases covering embedded object/array variables in both resolvers,
plus a guard test confirming a standalone `{{variable}}` still returns
the raw object.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21612?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. -->
|
||
|
|
eefae87296 |
fix: surface proper errors for People create/delete constraint violations (#21270)
## Summary Fixes #21119 — `createPerson` / `deletePerson` mutations fail with a generic `INTERNAL_SERVER_ERROR: "Data validation error."` instead of a meaningful error, blocking core CRM record management. ## Root Cause The `computeTwentyORMException` function in `twenty-orm` contains a catch-all block that matches **every known Postgres error code** via `Object.values(POSTGRESQL_ERROR_CODES).includes(errorCode)` and discards all error detail, throwing: ```ts throw new PostgresException('Data validation error.', errorCode); ``` This `PostgresException` is then converted by the GraphQL error handler into `INTERNAL_SERVER_ERROR`, masking the real constraint violation. Common mutations like `createPerson` that hit: - **`NOT_NULL_VIOLATION` (23502)** — a required field is missing - **`FOREIGN_KEY_VIOLATION` (23503)** — referenced record missing or deletion blocked by a FK - **`RESTRICT_VIOLATION` (23001)** — record deletion blocked by a referencing row ...all silently surface as the same opaque `"Data validation error."` with `INTERNAL_SERVER_ERROR`. Already handled correctly before the catch-all: - `UNIQUE_VIOLATION` → delegates to `handleDuplicateKeyError` ✅ - `INVALID_TEXT_REPRESENTATION` → `TwentyORMException(INVALID_INPUT)` ✅ - Query read timeout → `TwentyORMException(QUERY_READ_TIMEOUT)` ✅ ## Fix Add explicit handling **before** the catch-all for the four most common data-integrity constraint errors, converting them to `TwentyORMException(INVALID_INPUT)` with a clear user-facing message. The GraphQL error handler then returns `BAD_USER_INPUT` (400) instead of `INTERNAL_SERVER_ERROR` (500). ## Changes ### `packages/twenty-server/src/engine/twenty-orm/error-handling/compute-twenty-orm-exception.ts` Added specific handling for: | Postgres Code | Constant | User-facing message | |---|---|---| | `23502` | `NOT_NULL_VIOLATION` | "A required field is missing. Please provide all required values and try again." | | `23503` | `FOREIGN_KEY_VIOLATION` | "This operation references a record that does not exist or cannot be modified due to existing relationships." | | `23001` | `RESTRICT_VIOLATION` | "This record cannot be deleted because it is still referenced by other records." | ## Before / After **Before:** ```json { "data": { "createPerson": null }, "errors": [{ "message": "Data validation error.", "extensions": { "code": "INTERNAL_SERVER_ERROR" } }] } ``` **After (e.g. NOT_NULL_VIOLATION):** ```json { "data": { "createPerson": null }, "errors": [{ "message": "A required field is missing. Please provide all required values and try again.", "extensions": { "code": "BAD_USER_INPUT" } }] } ``` --------- Co-authored-by: Pantkartik <pantkartik@github.com> Co-authored-by: Charles Bochet <charles@twenty.com> Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> |
||
|
|
cb5d64fefc |
Add twenty-exa application to internal app ci (#21882)
renamed exa to twenty-exa add twenty-exa to ci check <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21882?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. --> |
||
|
|
6237598a30 |
Fix meeting bot CalendarEvent field visibility and editability (#21883)
- Add the meeting bot preference field to the CalendarEvent record page fields view. - Use a Standard-app ownership gate for record field read-only logic. - Allow app-owned and workspace-custom fields on system objects to follow isUIEditable and permissions. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21883?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. --> |
||
|
|
584c567a7f |
Add twenty-discord on internal ci apps (#21928)
add twenty-discord to ci check <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21928?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. --> |
||
|
|
4c966bfc32 |
[Twenty-front]: Bunch of View Picker Fixes and improvements. (#21290)
While working on #21208, I found a few related improvements and fixes that were worth including in this PR. 1. Improved View Picker UX: - Added optimistic updates when selecting a view from both the drag-and-drop view picker - Added optimistic updates when editing view. Before it used to close the whole dropdown. - Added highlighting for the currently selected view. - Before: https://github.com/user-attachments/assets/469fc60c-e65f-4452-a5a4-7df6188ab19d - After: https://github.com/user-attachments/assets/d3b151c1-0c10-45e7-a796-b5e6061c898d 2. Remove Favorites from the View Picker - Added support for removing a favorite directly from the view picker without needing to open additional menus. - Before: https://github.com/user-attachments/assets/70437fb9-d4c1-488b-aab9-0ea92d1bad99 - After: https://github.com/user-attachments/assets/442546bd-24ae-43d5-abe1-268ef3ff6475 --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
153e41e036 |
ci: block bot contributors from PR commit history (#21926)
## What Adds a CI check (`Blocked Contributors Check`) that runs on every PR and **fails** if any commit is attributed to a known bot — via the commit author, committer, or a `Co-Authored-By:` trailer. Goal: keep automated agents (Claude, Cursor, Copilot, …) out of Twenty's contributor history. ## How - On `pull_request` (`opened`, `synchronize`, `reopened`) it fetches all PR commits via the GitHub API and matches author/committer name+email and the full commit message (for trailers) against an editable blocklist. - Patterns target **bot identities** (emails / `[bot]` handles), **not** bare first names — so a human contributor named "Claude" is *not* flagged. - On failure it emits `::error::` annotations naming the offending SHA + what matched, plus remediation guidance (rebase with `--reset-author`, strip trailers, force-push). Current blocklist: ``` noreply@anthropic.com @anthropic.com cursoragent@cursor.com copilot-swe-agent[bot] ``` Add a line to block another bot — no logic changes needed. ## Notes - This workflow only *reports* a failed status. To actually block merges, add **Blocked Contributors Check** as a required status check in branch-protection rules for `main` (repo Settings → Branches). - `@anthropic.com` also blocks any Anthropic-domain identity; narrow to just `noreply@anthropic.com` if real Anthropic employees may contribute under their work email. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21926?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. --> |
||
|
|
1b7dc0367e |
fix(ai): gemini not working in ask ai (#21898)
Upstream issue: https://github.com/vercel/ai/issues/14369 Gemini 400s whenever a tool result contains JSON Schema `$ref`/`$defs` (it reads `$ref` as a function declaration name and finds no match). We hit this because `learn_tools` returns tool input schemas, and our recursive filter schema emits `$ref`/`$defs`. Other providers accept it fine, so this only blocks Gemini. Adds a Google-only `wrapLanguageModel` middleware that serializes ref-bearing tool results to text before they reach Gemini, so the pointers travel as a string instead of structured keys. The model still reads the full schema (same as the MCP path). Guarded so normal tool results pass through untouched. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21898?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: Charles Bochet <charles@twenty.com> |
||
|
|
3ad5259106 |
fix(twenty-server): remove uuid format from openapi pageInfo cursors (#21920)
## Summary The OpenAPI schema for list responses declared `pageInfo.startCursor` and `pageInfo.endCursor` with `format: 'uuid'`, but the API actually returns base64-encoded cursor strings. This makes the documented schema inconsistent with the real response and breaks client generators that trust the `uuid` format. Closes #20003 ## Changes - Removed `format: 'uuid'` from `startCursor` and `endCursor` in `getFindManyResponse200`. - Removed `format: 'uuid'` from `startCursor` and `endCursor` in `getFindDuplicatesResponse200`. ## Verification - `npx nx lint:diff-with-main twenty-server` passed. - `npx oxlint` on the modified file passed with 0 warnings/errors. - `npx nx jest twenty-server --testPathPattern=open-api/utils` passed (11 tests, 4 snapshots). Note: `npx nx typecheck twenty-server` and the default `nx test` target hit pre-existing build errors in `twenty-ui` (unrelated Tabler icon/type mismatches), so I used the project`s `jest` target for focused verification. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21920?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: Claude <noreply@anthropic.com> |
||
|
|
2abf9c2930 |
feat(workflow): Pick Record load balanced strategy (3/3) (#21902)
## Overview Final PR in the Pick Record stack. Adds the **Load Balanced** strategy: pick the candidate that currently has the *fewest related records*. This is the "fair assignment" mode — e.g. assign a new company to the account owner who currently owns the fewest companies, or route a lead to the rep with the fewest open opportunities. **Stacked on #21900** (which is stacked on #21899) — merge in order. This PR's diff against `main` includes PRs 1 & 2 until they merge. ## What changed - Widened the `strategy` enum to add `LOAD_BALANCED`, and added an optional `loadBalance: { objectNameSingular, fieldName }` to the action input. - Editor: selecting **Load balanced** reveals a **Balance by** object picker and a **Count by** field picker (the related object's many-to-one relation fields). - Executor: for each candidate, counts records of the chosen related object whose chosen relation points at that candidate, then selects the least-loaded one. ## How it works Given pool = workspace members and config `{ objectNameSingular: "opportunity", fieldName: "pointOfContact" }`, the executor counts, per member, the opportunities whose `pointOfContact` is that member, and picks the member with the lowest count. ## Design decisions & tradeoffs 1. **No persistent state — computed live each run.** Unlike round robin, load balancing reads current data, so there's no cursor to store. Correct by construction even under concurrency (each run recomputes counts); the only caveat is two simultaneous runs can both see the same "least loaded" candidate before either assignment lands (a small, self-correcting skew), which is inherent to load-balancing and acceptable. 2. **Count via per-candidate queries.** One filtered count per candidate (`{ [relationField]: { id: { eq: candidateId } } }`), run in parallel. For the realistic pool sizes this targets (a team), this is simple and clear. A single `group_by` aggregate would scale better for very large pools — noted as a future optimization, deliberately not done to keep the logic obvious. 3. **Deterministic tie-break.** Candidates are pre-sorted by id (shared with round robin), and the first minimum wins — so equal-load ties resolve deterministically rather than arbitrarily. 4. **`Count by` lists all many-to-one relations of the chosen object** (not filtered to those targeting the pool object). Keeps the editor simple; picking an unrelated field just yields zero counts, which is visibly wrong. Filtering options to relations that target the pool object is a nice follow-up. 5. **Filter on the counted set** (e.g. only *open* opportunities) is intentionally out of scope for this first cut — documented as a follow-up. ## Testing Added `pick-record-load-balanced-workflow.integration-spec.ts`: creates two fresh companies (0 related opportunities each), attaches one opportunity to the second, configures `LOAD_BALANCED` counting opportunities by `company`, and asserts the step picks the **first** company (0 < 1). Passes locally alongside the random and round-robin tests (3 suites / 4 tests). `typecheck` + `lint:diff-with-main` green for shared/server/front. ## The full stack 1. #21899 — Random (the action + the whole scaffold) 2. #21900 — Round robin (atomic Redis cursor) 3. this — Load balanced Together these enable round-robin / load-balanced / random **assignment workflows** in Twenty, composed via the standard variable picker (assign the chosen record downstream with `{{step.<id>.id}}`). https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8 --- _Generated by [Claude Code](https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21902?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. --> |
||
|
|
82f6597dc3 |
i18n - docs translations (#21923)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21923?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> |
||
|
|
fa6d1394af |
feat(workflow): Pick Record round robin strategy (2/3) (#21900)
## Overview Second PR in the Pick Record stack. Adds a **Round Robin** selection strategy alongside Random, so an assignment workflow can distribute records *evenly* across a candidate pool (e.g. rotate company ownership across a set of workspace members) rather than just randomly. **Stacked on #21899** — review/merge that one first. This PR's diff against `main` includes PR 1's commits until #21899 merges. ## What changed - Widened the `strategy` enum (`RANDOM` → `RANDOM | ROUND_ROBIN`) in the shared schema and the server input type. - Editor now shows a **Strategy** selector (Random / Round robin). The candidate-pool label changed from "Pick at random from" to the neutral "Pick from" since random is no longer the only mode. - Executor implements round robin. ## Design decisions & tradeoffs 1. **State store: Redis `incrBy` (atomic), keyed `pick-record:round-robin:{workspaceId}:{stepId}`.** Round robin needs a persistent cursor, and workflow runs are **not** serialized — two runs can execute the same step concurrently — so the increment must be atomic. `CacheStorageService.incrBy` (workflow cache namespace) is a single atomic Redis op, needs no schema change, and is already injectable. Index = `(cursor - 1) % poolSize`. **Tradeoff — durability:** a Redis flush/eviction resets the cursor, which restarts the cycle from an offset. That causes a one-time *fairness drift*, never a *correctness* bug (no double-assignment, since each increment is atomic). If strict durability is ever required, the cursor can move to a Postgres counter table with `INSERT … ON CONFLICT … DO UPDATE SET cursor = cursor + 1 RETURNING cursor` (atomic + durable) — deliberately **not** done here to avoid a migration for what is, in practice, an acceptable reset. 2. **Deterministic pool ordering.** The resolved pool is sorted by `id` before the cursor is applied, so position→record mapping is stable run-to-run regardless of fetch order. Without this, round robin wouldn't reliably cycle. 3. **Cursor key uses `stepId`.** Stable across runs of a published version. Republishing a version may mint new step ids, which resets the cursor — acceptable and documented here. 4. **Slot-on-increment.** The cursor increments when the step runs (reserving a position); if a later step in the run fails, that position is effectively skipped. Minor, acceptable unfairness — flagged rather than adding cross-step compensation. ## Testing Added `pick-record-round-robin-workflow.integration-spec.ts`: builds a workflow with a 3-record pool and `ROUND_ROBIN`, runs it 4 times sequentially, and asserts the picks are exactly `[p0, p1, p2, p0]` (full cycle + wraparound) against the deterministically-ordered pool. Passes locally alongside PR 1's random test (2 suites / 3 tests). `typecheck` + `lint:diff-with-main` green for shared/server/front. ## Follow-up - PR 3: `LOAD_BALANCED` (fewest related records wins). https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8 --- _Generated by [Claude Code](https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21900?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. --> |
||
|
|
a682c8fa62 |
feat(sdk): declare row-level permission predicates in the role manifest (#21919)
## Why Apps can declare object and field permissions on a role via `defineRole`, but **not row-level security**. The RLS engine and the metadata-sync machinery already support predicates fully — they're first-class universal flat entities, the `FlatRole` already carries `rowLevelPermissionPredicateUniversalIdentifiers`, and the workspace-migration layer has builders/validators/handlers for them. The only gap was the **manifest layer**: `RoleManifest` had no field for predicates, so the sync converter always left them empty. As a result, the only way to ship RLS with an app was a post-install script that pushed predicates through the `upsertRowLevelPermissionPredicates` mutation. That mutation assigns predicates to the workspace's **generic custom application**, not the app that owns the role — so a single role's definition ends up split across two applications and drifts on every upgrade (you have to remember to re-run the script). The Partner app does exactly this today via `configure-partner-rls.ts`. ## What Adds `rowLevelPermissionPredicates` and `rowLevelPermissionPredicateGroups` to `RoleManifest` / `RoleConfig`, mirroring how `objectPermissions` / `fieldPermissions` already flow end-to-end: - **twenty-shared** — predicate + predicate-group manifest types on `RoleManifest` (referencing objects/fields by `universalIdentifier`, operand/logical-operator from the existing GraphQL enums). - **twenty-sdk** — `defineRole` accepts and validates them; the build derives deterministic predicate `universalIdentifier`s (groups keep an explicit one so predicates can reference them). - **twenty-server** — two converters turn manifest predicates/groups into universal flat entities during application-manifest sync, so they are created/updated/deleted together with the role and **owned by the app that ships it**. ### Bug fix found along the way The migration build order ran the `rowLevelPermissionPredicate(Group)` builders **before** the `role` builder, so a predicate declared alongside a brand-new role failed validation with `ROLE_NOT_FOUND`. They now run **after** the role builder, exactly like object/field permissions. ## Partner app (second commit) Converts `partner.role.ts` to declare its five predicates inline and **deletes `configure-partner-rls.ts`** + the `rls:configure` scripts — the workaround this PR is meant to retire. The predicates are byte-for-byte the same semantics as the script produced. > Live-deployment note: the existing script-created predicates are owned by the *custom* application, so the Partner app sync won't touch them. Clear them once (e.g. an empty upsert on the Partner role) around deploy to avoid duplicates. Kept as a **separate commit** so it can be split out if reviewers prefer. ## Testing - **Integration (full app):** new `successful-manifest-sync-row-level-permission-predicate.integration-spec.ts` — installs an app whose role declares a predicate and asserts the predicate row is created (and **owned by the app**, not the custom app), updated in place on re-sync, removed when dropped from the manifest, and removed on uninstall. Ran locally against a seeded test DB ✅. - Re-ran the existing cross-app permission + view-field manifest suites to confirm the build-order change doesn't regress object/field-permission sync (13/13 ✅). - **Unit (utils only):** `defineRole` validation and `fromRoleConfigToRoleManifest` deterministic-id derivation. - Docs: new "Row-level security" section in `apps/config/roles.mdx`. ## Scope notes / possible follow-ups - Surfacing RLS in the app-install permission summary UI was intentionally left out (predicates *restrict* rather than grant, and typically live on a non-default role) — easy follow-up if wanted. - The `upsertRowLevelPermissionPredicates` mutation still homes out-of-band predicates on the custom app for app-owned roles; making that consistent (or rejecting it, like field permissions already do) is a sensible follow-up. https://claude.ai/code/session_01MipAis9z9okd4oCm9HCKEf --- _Generated by [Claude Code](https://claude.ai/code/session_01MipAis9z9okd4oCm9HCKEf)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21919?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. --> |
||
|
|
573fd00ea7 |
feat(workflow): add Pick Record action (1/3 — random selection) (#21899)
## Overview
Adds a new workflow action, **Pick Record**, that selects **one** record
from a configured candidate pool and exposes the chosen record as the
step's output. Downstream steps can then reference it through the normal
variable picker — e.g. assign an owner in an _Update Record_ step by
setting **Account Owner = `{{step.<pickRecordId>.id}}`**.
This is the foundation for building **assignment workflows**
(round-robin / load-balanced owner assignment, reviewer rotation, etc.)
in Twenty.
## This is PR 1 of a 3-PR stack
| PR | Strategy | Adds |
|----|----------|------|
| **1 (this one)** | `RANDOM` | The whole `PICK_RECORD` action,
end-to-end, stateless |
| 2 | `ROUND_ROBIN` | A persistent, atomically-incremented per-step
cursor + the strategy selector UI |
| 3 | `LOAD_BALANCED` | "fewest related records wins" via an aggregate
count |
Each PR widens the `strategy` enum (a backward-compatible change), so no
data migration is needed between them.
## How it works
- **Editor**: pick an Object, then pick the candidate records (a
multi-record selector). A random record is selected from that pool at
run time.
- **Output**: a single record of the chosen object — the same output
shape as `CREATE_RECORD`/`UPDATE_RECORD` — so it drills into
`{{step.x.id}}`, `{{step.x.name}}`, … in the variable picker.
- **Execution**: reuses `FindRecordsService` to fetch the pool (`id IN
(recordIds)`, which also transparently drops any deleted candidates),
then returns one at random.
## Design decisions & tradeoffs
1. **Standalone step that outputs a variable, not an inline "random"
mode on the relation field.** This mirrors Attio's round-robin block.
The decisive reason is composition: the chosen record is almost always
reused (assign owner **and** create a follow-up task for them **and**
email them). A variable is chosen once and reused everywhere; an inline
per-field value would re-roll independently in each place. It also keeps
the (stateful) round-robin/load-balanced logic out of the field inputs.
Tradeoff: one extra step to wire up vs. an inline control — accepted for
the composability win. An inline "Assign automatically" entry point can
still be layered on later as sugar that inserts this step.
2. **Co-located in the `record-crud` action module and reuses
`FindRecordsService`.** Avoids duplicating module wiring (auth context,
permissions, object-metadata resolution) and the data-access path.
Tradeoff: "Pick" is a selection rather than a CRUD op, so the folder
name is slightly broad; chose reuse + low risk over a separate module.
Can be extracted if the family grows.
3. **`strategy` exists in the schema (defaulted `RANDOM`) but the
selector is hidden in this PR.** A dropdown with a single option would
be UX slop, and adding the field only in PR 2 would force a data
backfill for any `PICK_RECORD` steps created in between. Keeping the
field now (hidden) avoids both. PR 2 introduces the selector once
there's a real choice.
4. **Pool is an explicit static list (`recordIds`) for v1.** Matches the
most common assignment case ("rotate among these N people") and reuses
the existing `FormMultiRecordPicker`. A filter-based pool (reusing the
Find Records filter UI) and a list-from-a-previous-step pool are natural
follow-ups, intentionally out of scope here to keep the stack focused on
the three strategies.
5. **Output schema is computed on the frontend** (like `CREATE_RECORD`),
derived from `input.objectName` — so it is **not** added to
`PERSISTED_OUTPUT_SCHEMA_TYPES` and needs no server-side schema
computation.
6. **Validation**: `PICK_RECORD` is added to object-name metadata
validation (so a deleted/invalid target object is flagged) via a
dedicated `OBJECT_TARGETING_ACTION_TYPES` set — deliberately **not** to
`VARIABLE_CONSUMING_ACTION_TYPES`, because a static pool legitimately
references no upstream variable and would otherwise raise a spurious "no
variable reference" warning.
7. **Empty pool → step error** at run time (respecting the step's
error-handling options) rather than a silent no-op, since an empty pool
is a misconfiguration or fully-deleted set.
8. **`Math.random`** is used for selection — no cryptographic guarantee
is needed for assignment fairness.
## Testing
Per our testing convention (integration test over service/`.spec`
tests): added `pick-record-workflow.integration-spec.ts`, which builds a
workflow with a manual trigger + a `PICK_RECORD` step, configures a
known two-record pool, runs it, and asserts the run completes and the
picked record is **always** within the configured pool (verifying the
pool filter) across repeated runs.
Local verification (typecheck + lint for shared/server/front) is green;
running the integration suite and attaching editor screenshots in a
follow-up comment.
## Follow-ups
- PR 2: `ROUND_ROBIN` + persistent atomic cursor (Redis `incrBy` vs. a
Postgres counter table — tradeoff to be documented on that PR) +
strategy selector.
- PR 3: `LOAD_BALANCED`.
- Later (not in this stack): filter-based / variable-list pools, an
inline "Assign automatically" entry point on relation fields, OOO-skip /
weighting.
https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8
---
_Generated by [Claude
Code](https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21899?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. -->
|
||
|
|
f4219449db |
fix(front): prevent AI agent output field error message from overlapping the Type field (#21921)
## Problem Follow-up to #21834, found during QA. That PR added an inline validation error on the AI Agent **Output → Variable Name** field. The error is rendered with `InputErrorHelper`, which is `position: absolute`. When the message wraps to two lines (which it does at the side-panel width), it is taken out of the layout flow and **overlaps the "Type" selector** directly below it: ``` Variable Name [ sdlfkj sdlkj ] Use only letters, numbers, underscores, dots or hyphens (max 64 Type <-- overlapped by the error message [ Text ▾ ] ``` ## Fix Render the error with `InputHint danger` instead of `InputErrorHelper`, matching how the sibling `FormNumberFieldInput` already shows its errors. `InputHint` flows in the column (`margin-top`, not absolute), so the error reserves its own space and pushes the following fields down instead of overlapping them. This is a one-line behaviour change in `FormTextFieldInput`; no new component or styling is introduced. ## After The `Type` field is pushed below the wrapped error message with correct spacing:  ## Tests - Added a `WithError` story to `FormTextFieldInput` (mirrors the existing `FormNumberFieldInput` `WithError` story) asserting the error message is visible. ## QA Reproduced and verified in Storybook against the real `WorkflowOutputSchemaBuilder` (throwaway story, not committed): before the fix the error overlapped `Type`; after the fix the `Type` field is pushed below the wrapped message with correct spacing. |
||
|
|
334e962ab5 |
fix: cannot create record from table view — empty morph to-many relation returns null (#21846)
## Problem
Creating a record from the table view (reproduced on **People**) crashes
the client even though the `createOne…` mutation succeeds server-side,
so the record never appears:
```
Cannot read properties of null (reading 'map')
getRecordConnectionFromRecords → getRecordNodeFromRecord → optimistic cache effect → createOneRecord
```
## Root cause
An empty **morph** to-many relation comes back as `null`, while every
other to-many relation comes back as `{ edges: [] }`. The frontend then
runs `null.map` while building the optimistic cache node; the error
escapes the mutation `update`, the rollback evicts the record, and it
never lands in the table.
## Fix
**Server** — plain to-many relations are hydrated to `[]` and formatted
to `{ edges: [] }` by `ObjectRecordsToGraphqlConnectionHelper`; an empty
morph to-many was left undefined and the field was skipped (→ `null`).
Default an unset to-many value to `[]` so it goes through the **same
connection path as plain to-many relations**.
**Frontend** — defensive guard in `getRecordNodeFromRecord`: a to-many
relation whose value isn't an array is skipped instead of crashing,
mirroring the existing guard in `extractTargetRecordsFromRelation`.
Needed regardless, since cached data / SSE / older servers still send
`null`.
## Tests
- Unit: `getRecordNodeFromRecord` skips a null to-many (reproduces the
exact crash without the guard).
- Integration: an empty morph `ONE_TO_MANY` read returns `{ edges: []
}`, not null.
|
||
|
|
a0689d1577 |
feat(workflow): condition filter on database-event triggers (#21868)
## Problem
Connecting a mailbox bulk-creates contacts via the email/calendar sync,
and each `person.upserted` fires the seeded **"Create company when
adding a new person"** workflow. The trigger enqueues one run per record
(no batching) and each run bills several `WORKFLOW_NODE_RUN` events — so
a single mailbox connect can rack up tens of thousands of runs and
exhaust credits on a brand-new workspace. The workflow is also redundant
on that path: the sync already creates the company from the email domain
and links the person to it.
## What this does
Adds an optional, user-defined **filter** to database-event (listener)
triggers, evaluated in the listener **before a run is enqueued**.
Non-matching events never create a run, so they consume zero execution
credits. This is the Filter node's capability, lifted to the trigger
level, and available for all event types (created / updated / upserted /
deleted).
The seeded "Create company when adding a new person" workflow now
carries a visible trigger filter — `Created by → Source is not Email`
**and** `is not Calendar` — so it no longer runs for sync-created
contacts, while still running for manually / API / CSV-added people.
## How (reuse)
- **Backend:** extracted `evaluateStepFilters()`, shared by the Filter
action and the trigger listener's new `eventMatchesRecordFilter` gate.
The record is exposed under the `trigger` key so filters reference it
exactly like steps do (`{{trigger.properties.after.…}}`).
- **Shared:** one optional `filter` added to the database-event trigger
zod schema; the front-end type derives from it (settings stay JSON — no
codegen).
- **Frontend:** extracted `WorkflowStepFilterBuilder` from the Filter
action's body; both the Filter action and the trigger editor render it.
The field picker needed no changes — at the trigger it already resolves
to the record's own fields via `TRIGGER_STEP_ID`.
## Scope / decisions
- **No migration for existing workspaces** (by request) — only newly
created workspaces get the filtered default; already-created workspaces
keep the always-on workflow.
- Deliberately did **not** add relation-enrichment to the upsert path
(it would add a DB lookup to the very bulk-sync path we're relieving).
Trigger filters work on the record's own scalar/composite fields (e.g.
`createdBy.source`); relation-based filters work on created/updated
where enrichment already runs.
## Verification
- Typecheck: `twenty-shared`, `twenty-server`, `twenty-front` all green.
- Lint (diff, autofix): 0 warnings / 0 errors across all three.
- Unit tests: a new `evaluate-step-filters` spec exercising the exact
`createdBy.source IS_NOT` seed mechanism, plus new listener specs
proving non-matching events are not enqueued. All backend
filter/listener suites pass.
- Not run here: integration tests (need a DB) and Storybook.
https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De
---
_Generated by [Claude
Code](https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21868?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: Claude <noreply@anthropic.com>
|
||
|
|
f8db73598c |
Fix dangling relation fields crashing records after deleting a custom object (#21874)
Fixes https://github.com/twentyhq/twenty/issues/21706 ## Context Deleting a custom object that has relation/junction fields pointing to it (e.g. a junction object linked from Person and Company) crashes record pages with `Target object metadata item not found for <field>`. The backend cascade correctly deletes the related relation fields, view fields and page-layout widgets, but the frontend metadata store only removed the deleted object itself, leaving dangling relation fields (and stale UI-layer references) behind. ## Fix After a successful deletion, `useDeleteOneObjectMetadataItem` now calls `invalidateMetadataStore()`, triggering the existing reconcile path that refetches objects, fields, indexes, views, view fields and page-layout widgets. This removes the dangling relations and cleans up the UI layers in one consistent pass (also replacing the previous manual command-menu refetch). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21874?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. --> |
||
|
|
544c89119c |
fix: hide restricted objects and views nested in navigation folders (#21914)
Closes #20141 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21914?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. --> |
||
|
|
602acf7a16 |
fix(twenty-website): fill feature-card visual frame on wide viewports (#21876)
## Problem On the home feature cards, the visual frame is capped at `max-width: 411px` (the scene's design width) and centered. Below ~411px-wide cards this is invisible, but once a card grows past 411px (wider viewports) the dark scene stops filling and the **card's light background shows on both sides** of the visual. At 1200px everything looks correct because the cards are narrower than 411px and the cap is never engaged; the issue only appears as the viewport widens. ## Fix `FeatureCard.tsx`, one file: - **Remove the `max-width: 411px` cap** (and the now-dead `margin: 0 auto`) from `CardImageFrame` so the frame fills the card width at every breakpoint. `useScaleToFit` then scales the 411×508 scene up to match — it's a CSS transform on DOM, so it stays crisp; no raster upscaling. - **Even out the card gutter** — `CardImage` padding `8px → 16px` (top + sides) so the visual's inset matches the content's 16px inset instead of stepping in. Bottom stays `0` (the content block's 16px provides the bottom gutter). The visual scenes themselves are untouched — this is purely the frame/container. ## Before <img width="1477" height="681" alt="image" src="https://github.com/user-attachments/assets/731f1ef7-e761-468e-b7aa-a5a06f8ac790" /> ## After <img width="1473" height="705" alt="image" src="https://github.com/user-attachments/assets/99d036a6-d3ad-4682-99c8-f283b5b95171" /> |
||
|
|
a99d380175 |
feat(website): spotlight visual on top and uniform tile background (#21906)
Two tweaks to the product-feature tiles section: - **Spotlight visual moved to the top** — the spotlight tile now renders its visual above its content, matching the layout of the regular grid cells. - **Uniform tile background** — every tile now uses the same neutral gray background; removed the per-tile `TILE_MUTED` alternating toggle. |
||
|
|
e90fb4b55c |
fix(security): bump dompurify to 3.4.11 (config/hook pollution) (#21905)
## fix(security): bump dompurify to 3.4.11 (config/hook pollution) Resolves [Dependabot Alert #1520](https://github.com/twentyhq/twenty/security/dependabot/1520) and [#1509](https://github.com/twentyhq/twenty/security/dependabot/1509). ### What `dompurify` is affected by: - **Permanent `ALLOWED_ATTR` pollution via `setConfig()`** ([#1520](https://github.com/twentyhq/twenty/security/dependabot/1520), Moderate, `<= 3.4.10`) - **Trusted Types policy survives `clearConfig()`** ([#1509](https://github.com/twentyhq/twenty/security/dependabot/1509), Low, `< 3.4.9`) Both patched in `3.4.11`. Bumps the direct `twenty-server` dep `^3.4.0 -> ^3.4.11`. ### Compatibility Both advisories are about config/hook state pollution via `setConfig`/`clearConfig`/hooks. All four of our call sites use plain `DOMPurify(window).sanitize(...)` with **default config** — no `setConfig`, `clearConfig`, `addHook`, `ALLOWED_ATTR`, or `RETURN_TRUSTED_TYPE` — so we are not on the affected path, and the fix does not change default-`sanitize` behavior. Verification: `typecheck twenty-server` passes; the `prepare-file-for-storage`, `create-html-to-text-converter`, and `email-composer` suites pass (28 tests). ### Verification - `dompurify` resolves to `3.4.11` (no `<= 3.4.10` remains). - Lockfile + single package.json pin change; `yarn install --immutable` passes. |
||
|
|
d74b6aeadf |
fix(security): bump nodemailer to 9.0.1 (raw-option SSRF / file read) (#21903)
## fix(security): bump nodemailer to 9.0.1 (raw-option SSRF / file read) Resolves [Dependabot Alert #1518](https://github.com/twentyhq/twenty/security/dependabot/1518) and [#1519](https://github.com/twentyhq/twenty/security/dependabot/1519). ### What `nodemailer` `<= 9.0.0` lets the message-level `raw` option bypass `disableFileAccess`/`disableUrlAccess`, enabling **arbitrary file read** and **full-response SSRF** in the delivered message ([GHSA advisory](https://github.com/twentyhq/twenty/security/dependabot/1518), High). Patched in `9.0.1`. ### How — direct bump, no resolution - **twenty-server:** `nodemailer ^8.0.5 -> ^9.0.1` (major bump). - **seed-dependencies:** the application-package template `nodemailer ^8.0.5 -> ^9.0.1`; both `DEFAULT_PACKAGE_JSON_CHECKSUM` and `DEFAULT_YARN_LOCK_CHECKSUM` regenerated to match the recomputed seed files (the deps-layer cache key). ### Compatibility — verified nothing breaks It is a major upgrade, so the 9.0 breaking change was checked against the current tree. The only behavior change is **stricter TLS validation when nodemailer fetches remote content** (attachment `href`/`path` URLs, built-in OAuth2 token endpoints, HTTP/HTTPS proxy `CONNECT`). None of those paths are reachable here: - Attachments are passed as **content buffers**, never `path`/`href`. - Gmail OAuth uses **googleapis**, not nodemailer's built-in OAuth2. - No proxy on any transport. - The SMTP socket TLS is governed separately (unchanged). Verification: `typecheck twenty-server` passes (with `@types/nodemailer ^7.0.3`), and the `email-sender`, `gmail-message-outbound`, and `imap-smtp-caldav-connection` suites pass (10 tests). ### Not covered (follow-up) Root alert **#1521** will stay open: `imapflow@1.3.6` exact-pins `nodemailer@8.0.10`. The clean fix is `imapflow 1.4.2` (which pins nodemailer `9.0.1`), but it published 2026-06-19 and is **age-gated until ~2026-06-22** — it will land then as a parent-bump (no resolution). ### Verification - `nodemailer` resolves to `9.0.1` for twenty-server; seed lockfile has `9.0.1`; both seed checksums match the canonical recompute. - `yarn install --immutable` passes. |
||
|
|
7eafbd91c6 |
test(server): make timeline integration test self-seed its data (#21896)
## Problem
`timeline-from-object-record.integration-spec.ts` is flaky depending on
Jest shard composition. Its `beforeAll` scans the dev-seeded people for
one with message threads and one with calendar events, and throws when
none is found:
```
Expected the seeded workspace to contain a person with message threads and calendar events
```
This was observed as a deterministic failure of `server-integration-test
(2)` (failed on re-run too), while the other 15 shards were green.
## Root cause
The suite depends on **mutable shared fixture state** under two fragile
assumptions:
1. **That no sibling suite wiped the seeded people.**
`deleteAllRecords('person')` is a common pattern across the REST/GraphQL
suites — `rest-api-core-find-many`, `rest-api-core-find-one`,
`all-people-resolvers`, `search-resolver`, etc. — each hard-deletes
every person (`DELETE FROM "...".person`) and leaves only its own
handful behind, without restoring the seed. Within a shard, Jest runs
files serially (`maxWorkers: 1`) ordered by file size descending (no
timing cache in CI). `rest-api-core-find-many` (~16 KB, runs 2nd)
executes **before** `timeline-from-object-record` (~12 KB, runs 5th), so
by the time the timeline `beforeAll` runs, only 4 company-linked test
people remain — none with threads or events.
2. **That the seeder's `Math.random` participant assignment** happened
to land a thread and an event on a company-linked person within the
first 100 results — itself non-deterministic across DB resets.
It surfaced now because an unrelated PR added a new integration test
file, which changed the total file set and therefore Jest's shard
distribution, moving `timeline-from-object-record` and
`rest-api-core-find-many` into the **same shard** for the first time. It
is a latent test-isolation issue, not a product regression.
### Reproduced locally
Against a DB where `rest-api-core-find-many` had already run (person
count = 4), the timeline suite fails with the exact CI error; on a
freshly seeded DB it passes. So the failure is purely order/seed
dependent.
## Fix
Make the suite self-contained: in `beforeAll` it now provisions its own
graph via the GraphQL API and tears it down in `afterAll`:
```
company → person → messageThread → message → messageParticipant(personId)
↘ calendarEvent → calendarEventParticipant(personId)
```
The timeline resolvers count threads via `messageThread → messages →
messageParticipants.personId` and events via `calendarEvent →
calendarEventParticipants.personId`, so this graph is sufficient and
minimal. The suite no longer reads any ambient seeded data, making it
independent of execution order and seeding randomness.
## Validation
- Self-seeding suite passes against the **polluted** DB (4 people, no
seeded threads/events) — the exact CI failure condition.
- Idempotent across repeated runs and leaves **no residue** (all
fixtures destroyed in `afterAll`).
- Full `--shard=2/16` run green except a pre-existing environmental
failure (`successful-save-imap-smtp-caldav-account`, fails locally with
no mail server, identical before/after this change).
https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De
---
_Generated by [Claude
Code](https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21896?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. -->
|
||
|
|
b8ea742a88 |
fix(front): respect user number format for counts and aggregates (#21894)
## Problem
Several user-facing numbers were rendered raw (e.g. `153909`) instead of
honoring the workspace member's **Number format** preference (e.g. `153
909` with `Spaces and comma`). The formatting utilities already existed
(`formatNumber` / `useNumberFormat`) but were not applied on these
surfaces.
## Root cause
`transformAggregateRawValueIntoAggregateDisplayValue` — the shared
helper behind every table/board/chart aggregate — returned the `COUNT`
branch as a raw string and never threaded the user's locale format into
`formatNumber` for the other branches (so they silently fell back to
`COMMAS_AND_DOT`).
Its existing `numberFormat` param actually held the chart `SHORT`/`FULL`
abbreviation setting, so it is renamed to `chartNumberFormat`, and a new
`numberFormat: NumberFormat` now carries the locale separators.
## Surfaces fixed
- Record table footer aggregates, including the raw **"Count all"**
total
- Record board column / group-section aggregates
- Aggregate chart and pie-chart center metric (including their raw
`COUNT` early-returns)
- View picker `<view> · <count>` total
- Record show breadcrumb pagination `(x/y)`
- Record index header and side panel `N selected` counts
The board-column header needs no change — it now receives an
already-formatted string from the transform.
## Out of scope (intentionally left raw)
The editable `SettingsCounter` input (formatting would break parsing),
the advanced-filter pill, the `+N` overflow badge, and the AI routing
debug display.
## Testing
- New + existing unit tests pass
(`transformAggregateRawValueIntoAggregateDisplayValue`, `formatNumber`,
`useNumberFormat`), with added locale-aware coverage (`SPACES_AND_COMMA`
→ `153 909`, `DOTS_AND_COMMA` → `153.909`).
- `nx typecheck twenty-front`, oxlint and oxfmt on the diff all pass.
> Note: two i18n strings change placeholder shape (`{count} selected` →
`{0} selected`); a `lingui:extract` will refresh the catalogs (runtime
falls back to source text meanwhile).
https://claude.ai/code/session_013XNL2Xa11Bw7fsnPFQgsGX
---
_Generated by [Claude
Code](https://claude.ai/code/session_013XNL2Xa11Bw7fsnPFQgsGX)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21894?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. -->
|
||
|
|
2e1da86535 |
i18n - docs translations (#21884)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21884?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> |
||
|
|
6423c4cd3c |
Add recall io webhook endpoint (#21879)
## Context
Bot-recording integrations (e.g. the Recall.ai meeting bot) receive
webhooks from a third-party provider that delivers **every
tenant's events to a single URL**. Our existing `route-trigger` (`/s/…`)
resolves the workspace from the request host, which can't
work for one shared multi-tenant webhook URL. We need an instance-scoped
ingress that identifies the target workspace from the payload
instead.
## Strategy
Add a new **`ingress-trigger`** logic-function trigger, mirroring
`route-trigger`:
- A public endpoint keyed by the app's identifiers: `POST
/webhooks/ingress/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier`.
- The logic function declares an `ingressTriggerSettings` block in its
manifest describing how to find the workspace in the payload
(`workspaceId: { source: 'body' | 'query' | 'header', path }`).
- Core only **resolves the workspace** (declarative, fail-closed,
prototype-safe path getter), verifies the app is installed in that
workspace, then runs the function **synchronously** so the provider sees
the response (status codes / retries).
- **Signature verification stays in the logic function** (it gets
`rawBody` + forwarded headers), keeping core provider-agnostic.
- Shared execution logic (`build event → execute → map response`)
extracted into `LogicFunctionTriggerService`, now reused by both
`route-trigger` and `ingress-trigger`.
## Major changes
- **twenty-shared**: new `ingressTriggerSettings` on
`LogicFunctionManifest` (`IngressTriggerSettings` type).
- **twenty-server**: new `ingress-trigger` module (controller, service,
exception + filter, workspace-id resolver util).
- **twenty-server**: extracted `LogicFunctionTriggerService` +
`route-trigger-response.util` (response builder + sender); refactored
`RouteTriggerService` and both controllers to reuse them.
- **twenty-docs**: documented the ingress trigger (endpoint, workspace
resolution, signature responsibility, provider HMAC examples).
- Unit tests for the resolver and the ingress service.
|
||
|
|
dd6fbbe854 |
Rename meeting bot app variables from RECALL_BOT_* to MEETING_BOT_* (#21878)
sorry recall :) <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21878?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. --> |
||
|
|
c2e3296d85 |
Add transcript tab to calendar record page (#21792)
Adds a read-only "Transcript" tab to the CalendarEvent record page, contributed by the Twenty Meeting Bot app. Renders the diarized transcript stored on `CallRecording.transcript`, with placeholder states for pending and failed transcription. Front-end only — the transcription pipeline (request/download/reconcile + PENDING/FAILED markers) already landed on main. Deferred: live-mount verification via `yarn twenty dev`. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21792?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. --> |