Commit Graph

449 Commits

Author SHA1 Message Date
Félix Malfait 084fa8eaba fix(server): auto-index target<X>Id join columns on polymorphic standard objects (#20820)
Closes #20726

## The bug

`timelineActivity` (and the three other polymorphic standard objects —
`attachment`, `noteTarget`, `taskTarget`) store relations as N nullable
`target<X>Id` columns, one per related object. Each one is a join key
queried as `WHERE target<X>Id IN (...) AND deletedAt IS NULL`.

For **built-in** related objects (Person, Company, Opportunity, …), each
`target<X>Id` column gets a BTREE index, declared statically in
`compute-{timelineActivity,attachment,noteTarget,taskTarget}-standard-flat-index-metadata.util.ts`.

For **custom** related objects, the same `target<CustomObject>Id` column
was added — **without an index**. On a `timelineActivity` table at
issue-reporter scale (~21.9M rows, 7.1 GB), this turned record loads
into 20–40s sequential scans and produced `QueryFailedError: Query read
timeout` for end users.

## Diagnosis

The morph/relation field generator
(`generateMorphOrRelationFlatFieldMetadataPair`) already creates a BTREE
index for the field that owns the join column and returns it alongside
the field metadata pair. The two user-driven entry points
(`fromRelationCreateFieldInput…`, `fromMorphRelationCreateFieldInput…`)
correctly destructure and propagate that index.

But the **custom-object creation path** —
`buildDefaultRelationFlatFieldMetadatasForCustomObject`, called when a
user creates a new custom object — destructured only `{
flatFieldMetadatas }` and threw away `indexMetadatas`. So every
`target<CustomObject>Id` column added to the four polymorphic standard
objects has been shipping unindexed since custom morph relations went
in.

## The fix

Three commits.

### 1. `fix(server): index target<CustomObject>Id columns on standard
polymorphic objects`

13 lines across 2 files.

-
`build-default-relation-flat-field-metadatas-for-custom-object.util.ts`
— also destructure `indexMetadatas` from the pair generator and
accumulate them into the returned record (new field
`standardTargetFlatIndexMetadatas`).
-
`from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util.ts`
— append the accumulated indexes to `flatIndexMetadataToCreate`. The
migration pipeline at `object-metadata.service.ts:559–562` already
passes `flatIndexMetadataToCreate` to the migration runner, so no
further wiring is needed.

From now on, creating a custom object also creates the four BTREE
indexes — one per polymorphic standard object's new
`target<CustomObject>Id` column — atomically with the rest of the
migration.

### 2. `feat(server): backfill workspace command for relation join
column indexes`

For existing workspaces whose custom objects were created before the
forward-fix.

`upgrade:2-8:backfill-relation-join-column-indexes` is a
`@RegisteredWorkspaceCommand('2.8.0', 1798100000000)` matching the
pattern from
`2-7-workspace-command-…-drop-connected-account-standard-object.command.ts`.

Per workspace:
1. Load `flatObjectMetadataMaps`, `flatFieldMetadataMaps`,
`flatIndexMaps` from the workspace cache.
2. Resolve the four polymorphic standard object IDs by `nameSingular`
against `DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS`.
3. Collect every field ID that's already covered by any existing index.
4. Filter `flatFieldMetadataMaps` to MORPH_RELATION fields on those four
objects whose `settings.relationType === MANY_TO_ONE` (i.e. owns a join
column) and whose ID isn't in the indexed set.
5. Generate a BTREE `UniversalFlatIndexMetadata` for each via
`generateIndexForFlatFieldMetadata` (same helper the forward-fix uses).
6. Create the indexes in the workspace schema with **CONCURRENTLY** (see
commit 3).
7. Submit the metadata through
`WorkspaceMigrationValidateBuildAndRunService` so it lands in
`indexMetadata` and the cache — same pipeline as a normal metadata
change. The pipeline's own `CREATE INDEX IF NOT EXISTS` no-ops because
the index already exists.

Properties:
- **Idempotent.** Re-running is a no-op once indexes exist.
- **Scoped.** Only the four polymorphic standard objects, only their
MANY_TO_ONE morph relation fields, only those with no covering index.
- **Same code path as the forward-fix.** The backfill produces exactly
the indexes the forward-fix would have created at custom-object creation
time.
- **`--dry-run` supported** via the base
`ActiveOrSuspendedWorkspaceCommandRunner`.

### 3. `feat(server): create index CONCURRENTLY in relation join column
backfill`

Adds an opt-in `concurrently` flag to
`WorkspaceSchemaIndexManagerService.createIndex` (threaded through
`createIndexInWorkspaceSchema`). When `true`, emits `CREATE INDEX
CONCURRENTLY IF NOT EXISTS …`. Defaults to `false` — every existing
caller keeps the current transactional `CREATE INDEX` behavior.

The backfill command opts in. It creates a QueryRunner **without**
`startTransaction()`, issues the CONCURRENTLY indexes one-by-one (each
waits for the previous to finish), then submits the metadata through the
normal migration pipeline whose own `CREATE INDEX IF NOT EXISTS` is now
a no-op.

Why not flip the default for the helper:
- `CREATE INDEX CONCURRENTLY` cannot run inside a transaction — Postgres
errors out. The migration pipeline calls `createIndex` from inside a
transactional schema migration.
- CONCURRENTLY doesn't roll back with the transaction. If the
surrounding migration fails, the index remains and you end up with
metadata/schema drift.
- Failed CONCURRENTLY builds leave an INVALID index behind that needs
manual `DROP`.
- UNIQUE indexes have different failure semantics under CONCURRENTLY
(deferred, not immediate).

So CONCURRENTLY is opt-in, used only where it's the right tool (post-hoc
backfills on populated tables).

## Decisions / tradeoffs

- **Single-column BTREE vs partial `WHERE deletedAt IS NULL` vs
composite.** Twenty's queries always include `deletedAt IS NULL`. A
partial index would be slightly better than a plain BTREE (smaller, no
wasted seeks on soft-deleted rows). This PR ships single-column to match
the existing built-in target index pattern, which already covers >95% of
the available speedup (the 20s→4ms drop the reporter saw comes from
having any index — composite/partial is a second-order effect).
Switching all relation indexes to partial is a separate, broader change.
- **CONCURRENTLY operator caveat.** If a CONCURRENTLY build is
interrupted (kill, connection drop, OOM), Postgres leaves the index as
INVALID. We deliberately don't probe `pg_index` for invalid leftovers on
every create — catalog-table queries can be slow at multi-tenant scale
and the failure mode is rare. Recovery is manual: `DROP INDEX <name>`
and re-run the backfill.
- **Forward-fix is not gated** behind a feature flag. The change is
metadata-pipeline-internal; before, custom-object creation silently
produced a degraded state. After, it produces the correct state. No new
public API, no behavioural change for end users besides the indexes
existing.

## Risk

- Forward-fix: changes only the metadata produced during custom-object
creation. New objects get four extra `FlatIndexMetadata` rows and four
extra `CREATE INDEX` statements during their creation migration. Tables
are empty at that point so the index builds in microseconds.
- Helper change: API-compatible, default behavior unchanged. The new
`concurrently` parameter is optional.
- Backfill: read-only state probe → CONCURRENTLY index creation (no
write blocking) → metadata insert via the normal migration pipeline.
Idempotent. Reverting is `DROP INDEX`.

## Test plan

- [ ] Verify forward-fix: create a custom object, confirm four new BTREE
indexes appear on `timelineActivity`, `attachment`, `noteTarget`,
`taskTarget` for the new `target<CustomObject>Id` columns, and that
`flatIndexMaps` has matching entries.
- [ ] Verify backfill on a workspace that had custom objects created
before the fix: run `--dry-run` first, confirm the expected indexes are
listed; then run for real, confirm the indexes appear in pg (and as
`indisvalid = true` in `pg_index`) and in `flatIndexMaps`. Re-run;
confirm no-op.
- [ ] Verify backfill on a clean workspace: should log "no missing
indexes" and exit.
- [ ] Verify CONCURRENTLY behavior under load: run backfill against a
workspace with active writes on `timelineActivity`; confirm
inserts/updates keep working during index build (no `ShareLock` waits in
`pg_stat_activity`).
- [ ] On the affected reporter-scale workspace, confirm `EXPLAIN
ANALYZE` switches from sequential scan to index scan and timeline
activity timeouts go away.
2026-05-22 11:56:33 +02:00
Weiko d13cc7c349 Drop legacy rolePermissionFlag.flag column + fallback logic (#20730)
## Summary
- **New fast migration**
`2-7-instance-command-fast-1779600000000-finalize-role-permission-flag-cutover.ts`:
  - `DROP CONSTRAINT IDX_ROLE_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE`
  - `ALTER COLUMN permissionFlagId SET NOT NULL`
  - `DROP COLUMN flag`
- `down()` repopulates `flag` from the catalog via `permissionFlagId`
and restores the old unique.
- **Entity**: `RolePermissionFlagEntity` hides the `flag` column by
using the new decorator + drops old `@Unique` decorator;
`permissionFlagId` and the `permissionFlag` relation become
non-nullable.
- **Deletes** the synthesizer
`synthesize-flat-permission-flag-from-flag.util.ts` and every fallback
branch that used it

(`from-role-permission-flag-entity-to-flat-role-permission-flag.util.ts`,
`from-flat-role-permission-flag-to-role-permission-flag-dto.util.ts`,
`permissions.service.ts`,
`workspace-roles-permissions-cache.service.ts`,
`fromRoleEntityToRoleDto.util.ts`,
`flat-role-permission-flag-validator.service.ts`,
`role-permission-flag.service.ts:getEffectiveUniversalIdentifier`).
- **Write path**: ~~drops `flag` from `CreateRolePermissionFlagInput`,
the create util, and the application-manifest converter.~~
- **Metadata configs**: ~~removes `flag` from
`all-entity-properties-configuration-by-metadata-name.constant.ts`
(rolePermissionFlag block)~~ and flips `permissionFlag.isNullable` to
`false` in `all-many-to-one-metadata-relations.constant.ts`.

### Why the `flag` field stays declared in the entity

The decorator (`@WasRemovedInUpgrade`) is the right tool for the
lifecycle marker, but it's a **reflect-metadata** runtime decorator —
TypeScript can't see it at compile time. So while
the adapter
([`UpgradeAwareEntityMetadataAdapter`](packages/twenty-server/src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter.ts))
now correctly flips
`isSelect`/`isInsert`/`isUpdate` to `false` once the drop migration's
cursor is crossed, the *static* TypeScript types derived from
`RolePermissionFlagEntity`
(`UniversalFlatRolePermissionFlag`, `FlatRolePermissionFlag`,
`MetadataEntityPropertyConfiguration<'rolePermissionFlag'>`, etc.) still
see `flag` as a required scalar property — because the entity declares
it.

That means every producer of one of those derived types must include
`flag`:
-
`from-create-role-permission-flag-input-to-flat-role-permission-flag-to-create.util.ts`
plumbs it through.
- `from-permission-flag-to-universal-flat-role-permission-flag.util.ts`
(the application-manifest converter) sets `flag: permissionFlag.flag`.
- `all-entity-properties-configuration-by-metadata-name.constant.ts` has
a `flag` entry under `rolePermissionFlag`.
- `CreateRolePermissionFlagInput` keeps the `flag` field.
- `RolePermissionFlagService.upsertPermissionFlags` passes `flag:
permissionFlag.key as PermissionFlagType` to the create util.

Explored phantom-brand approach (`RemovedInUpgrade<T>` wrapper on the
field type, key-filter mapped type applied inside `ScalarFlatEntity` /
`UniversalFlatEntityFrom`) but previous commands could have `flag ===
undefined` (downcast from the brand since we can't compare with
UpgradeMigrationName like we do with a decorator). That's a
**silent-read** failure mode: compiles fine, comparisons against `flag`
silently always-false, no error surfaces. Probably worth too much risk
for what's a small amount of plumbing?

The eventual full deletion of `flag` (entity field included) is a future
cleanup once we drop cross-upgrade support for versions ≤ 2.6

Note: Not sure if this PR (and even the decorator) is really needed in
the end, seems we need to keep a lot of code in place to handle legacy.
Maybe a simple noop [At]Deprecated is enough @charlesBochet (and a
migration to set the column nullable if that was not the case before +
remove associated constraints)
2026-05-21 11:56:06 +00:00
Charles Bochet 1a9f786e42 refactor(filters): pass fieldMetadataItems array to dispatcher (#20737)
## Summary

Alternative to #20717. Same goal (clean up the filter dispatcher API
after #20670) but smaller and follows the codebase's "pass data, not
behavior" style.

The dispatcher takes a `fieldMetadataItems: FieldShared[]` array
directly instead of a `findFieldMetadataItemById: (id) => FieldShared |
undefined` callback. The util builds the id lookup internally — once per
call, used for both source-field and relation-target-field lookups. No
new types, no separate hydration step.

## What changes

**`twenty-shared`**
- `computeRecordGqlOperationFilter` /
`turnRecordFilterIntoRecordGqlOperationFilter` /
`turnRecordFilterGroupsIntoGqlOperationFilter`: replace
`findFieldMetadataItemById` param with `fieldMetadataItems` /
`fieldMetadataItemById` (internal Map).
- Remove the exported `FindFieldMetadataItemById` type.
- `turnAnyFieldFilterIntoRecordGqlFilter`: rename its internal
`fieldById` Map for consistency.
- Tests updated to pass arrays.

**Frontend (15 call sites)**
- Switch from `fieldMetadataItemByIdMapSelector` to
`flattenedFieldMetadataItemsSelector`.
- Pass `fieldMetadataItems: flattenedFieldMetadataItems` to the
dispatcher.
- `useFindManyRecordsSelectedInContextStore` keeps the Map selector
because it still does a per-filter lookup for the soft-delete check.

**Server (5 call sites)**
- Pass
`Object.values(flatFieldMetadataMaps.byUniversalIdentifier).filter(isDefined)`.

## Why this over #20717

#20717 moves resolution into a separate hydration step + introduces a
`HydratedRecordFilter` type. The bug that #20717 originally surfaced was
Sentry catching 4 critical runtime errors during review
(`fieldMetadataItemByIdMap` declared but not passed). The added type and
the explicit hydration boundary are extra surface area for not much
benefit — the existing API was a callback wrapping a Map at every call
site, and the natural simplification is to just pass the Map (or its
array) directly.

Net diff: **196 insertions, 203 deletions** (~7 lines net removed). 32
files.

## Test plan
- [x] Shared filter unit tests pass (461 tests)
- [x] Frontend filter/context-store tests pass (13 tests)
- [x] Frontend typecheck passes
- [x] Server typecheck passes
- [x] Lint passes (frontend + server)
- [ ] Integration tests on #20670 still pass — workflow find-records +
chart-data with relation-traversal filter still work end-to-end through
the new array param
2026-05-19 22:49:53 +02:00
Weiko ac432d3195 Add @WasRemovedInUpgrade decorator (#20729)
## Summary

Adds the symmetric counterpart to `@WasIntroducedInUpgrade` for the
upgrade-aware ORM. Today the framework can describe "this column will
exist once upgrade X applies" but not "this
column will stop existing once upgrade X applies". Plain field deletion
only works when nothing writes to the table during the mid-state window
between the binary booting and the drop
migration completing for a given workspace — fine for sparse tables
(`DropWorkspaceVersionColumn`, `DropPostgresCredentialsTable`), risky
for hot-write tables.

This PR ships the primitive on its own so the upcoming
`rolePermissionFlag.flag` drop has the framework support it needs. No
in-tree consumer yet — coverage is via unit tests against
synthetic entities.

### What's in it

- **New `@WasRemovedInUpgrade({ upgradeCommandName })` decorator**
(class- or property-scope) — mirrors `@WasIntroducedInUpgrade`, uses the
shared
`defineUpgradeMetadataOnClassOrProperty` helper, exposes class +
property getters.
- **`resolveEntityShapeAtUpgradeCursor`** now folds applied-removals
into the existing `hiddenPropertyNames` set. Intro-pending and
removal-applied share one hide bucket — both ask
TypeORM for the same thing.
- **`UpgradeAwareEntityMetadataAdapter`** now disables `isSelect`,
`isInsert`, **and** `isUpdate` for any hidden column, restoring
canonical values when the column comes back.
Previously only `isSelect` was flipped, which left an
INSERT-into-nonexistent-column hole the intro path was tacitly relying
on application code to avoid; this PR closes that hole for
both directions.
- **`validateUpgradeAwareEntityDecorators`** validates
`@WasRemovedInUpgrade` `upgradeCommandName` references, and surfaces a
new `removal-before-introduction` problem when a property
has both decorators with the removal step preceding the introduction
step.
2026-05-19 14:57:05 +00:00
Félix Malfait 291ce5ccdb fix(filters): make filter dispatcher own relation-target resolution (#20670)
## Summary

Two relation-traversal bugs surfaced post-merge of #20533, both rooted
in the same architectural smell: the GraphQL filter dispatcher took a
flat `fields: FieldShared[]` array and silently dropped any filter whose
`relationTargetFieldMetadataId` wasn't in that array. Callers had to
remember to pre-augment the list with relation targets — and 16+ call
sites did not all know this.

This PR fixes both bugs and removes the smell.

### Bug 1 — Save as new view loses the relation target

`useCreateViewFromCurrentView` built the create-filter input without
`relationTargetFieldMetadataId`. The saved view's filter persisted
without the traversal — on reload the chip showed "Company contains
'air'" instead of "Company → Name contains 'air'". Discarded at save
time, not at read time.

Fix: include `relationTargetFieldMetadataId` in the create input.
(Commit 1.)

### Bug 2 — Workflow Search Records drops one-hop traversals

`FindRecordsWorkflowAction` built its fields list from
`flatObjectMetadata.fieldIds` only (source object's fields). The shared
dispatcher then couldn't resolve the relation target field on the
related object and silently dropped the filter — a configured "People
where Company → Name Contains 'Airbnb'" came through as `{ and: [] }`.

This was the same shape as bugs already fixed in 5 other call sites
(chart filters, view filters, record table, etc.). The pattern was:
caller forgets to augment fields → dispatcher silently drops the filter.

Fix (commit 2): change the dispatcher to take a
`findFieldMetadataItemById: (id) => FieldShared | undefined` resolver
callback. Both source-field and relation-target-field lookups go through
the same resolver, so callers no longer need to know about the
augmentation requirement. Frontend callers pass a workspace-wide
resolver built from `flattenedFieldMetadataItemsSelector`; server
callers wrap `findFlatEntityByIdInFlatEntityMaps` on
`flatFieldMetadataMaps`. In both cases relation-target lookups just
work, because the resolver can see fields on related objects.

## Why this matters

Before: "if you call the dispatcher, pre-augment your fields list with
relation targets, or filters get silently dropped." An invariant only
enforceable by code review, broken often enough to ship two user-visible
bugs in one week.

After: the dispatcher resolves field ids itself. There's no list to
forget to augment. The failure mode (filter silently dropped) becomes
structurally impossible at the dispatcher boundary.

Net diff: 240 insertions, 319 deletions. Removed
`augmentFieldsWithRelationTargets` (frontend) and the workflow
whack-a-mole code (server).

## Test plan
- [ ] Save view: create an advanced filter using a one-hop relation
traversal, click "Save as new view", reload, confirm the chip still
reads "Source → Target operator value"
- [ ] Workflow: configure a Search Records action with a
relation-traversal filter, run the workflow, confirm the filter is
actually applied
- [ ] Dashboard chart: configure a chart with a relation-traversal
filter, confirm the chart data respects it
- [ ] Record table, group-by, calendar, total count, footer aggregates:
all continue to work with both plain and relation-traversal filters
2026-05-19 11:55:22 +02:00
Charles Bochet 1d3d3999e2 feat(server): upgrade-aware entity decorators for cross-version upgrades (#20686)
## What

When the same PR introduces a new core entity *and* adds a cache
provider that queries it, every workspace step from older versions that
runs before the introducing instance step hits `relation … does not
exist` — the cause of the failed [v2.6.0 staging-ci
run](https://github.com/twentyhq/twenty-infra/actions/runs/26042742000).
Same class of failure for renamed core entities and for new FK columns
hidden inside relation loads.

This PR adds **upgrade-aware entity decorators** + a runtime that adapts
TypeORM's view of the schema to the current `core.upgradeMigration`
cursor.

## Strategy

```
                    ┌────────────────────────────────┐
                    │  @Entity classes (final shape) │
                    │   + @WasIntroducedInUpgrade    │
                    │   + @WasRenamedInUpgrade       │
                    └───────────────┬────────────────┘
                                    │
              UpgradeSequenceRunner.run()
              ┌─────────────────────┴─────────────────────┐
              ▼                                           ▼
       step N+1 begins                          step N just completed
              │                                           │
              └────────► adapter.refresh() ◄──────────────┘
                            │
            reads core.upgradeMigration via
            UpgradeMigrationService.getLastAttemptedInstanceCommand
                            │
                            ▼
       ┌────────────────────────────────────────────────────────┐
       │  UpgradeAwareEntityMetadataAdapter                     │
       │  • mutates EntityMetadata.tableName / tablePath        │
       │     -> historical name for renames not yet applied     │
       │  • flips column.isSelect = false for not-yet-introduced│
       │     columns                                            │
       │  • tracks per-entity availability sidecar              │
       └─────────────────┬──────────────────────────────────────┘
                         │
                         ▼
       DataSource.getRepository wrapped at TypeOrmModule.forRoot:
       repo.find() / findOne() / count() / …
       ┌─────────────────────────────────────────┐
       │  wrapRepositoryWithUpgradeAwareProxy    │
       │  • entity unavailable -> short-circuit  │
       │     (find -> [], count -> 0,            │
       │      findOneOrFail -> EntityNotFound)   │
       │  • write -> Promise.reject(             │
       │      UpgradeUnavailableEntityWriteEx)   │
       │  • find({ relations: ['X'] }) with X    │
       │     unavailable -> X stripped           │
       └─────────────────────────────────────────┘
```

The decorator strings reference real `core.upgradeMigration.name` values
(`${version}_${className}_${timestamp}`). A boot-time validator walks
the actual `UpgradeSequenceReaderService.getUpgradeSequence()` and fails
fast on typos.

## Files

- New decorators:
`engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator.ts`,
`was-renamed-in-upgrade.decorator.ts`
- Runtime: `engine/twenty-orm/upgrade-aware/` (adapter, proxy, install
hook, state singleton, exceptions)
- Wired into `UpgradeSequenceRunnerService` (`refresh()` between steps)
and `TypeOrmModule.forRoot` (proxy install)
- 2-6 entity decorations: `RolePermissionFlagEntity` (rename history +
new `permissionFlagId` column), `PermissionFlagEntity` (new catalog)

## Validation

End-to-end local cross-version upgrade (v1.22 → HEAD): `28 workspace(s)
succeeded, 0 failed`; `upgrade:status → Instance: Up to date, 4 up to
date, 0 behind, 0 failed`. Full log excerpts and the
second-failure-found-and-fixed (`WorkspaceRolesPermissionsCacheService`
relation load) in [this
comment](https://github.com/twentyhq/twenty/pull/20686#issuecomment-4480036816).

## Test plan

- [x] Adapter spec covers rename mutation; proxy spec covers `find()`
short-circuit on unavailable entity. Resolver + validator + decorators
are covered by `resolve-entity-shape-at-upgrade-cursor.util.spec.ts`
(integration-level via real decorator application).
- [x] `nx lint:diff-with-main twenty-server` + `nx typecheck
twenty-server` clean
- [x] All 82 affected tests passing
- [ ] Cross-version-upgrade CI re-runs after this lands; v2.6.0 retag
once green

## Follow-ups deferred

- v2.7 `connectionProvider` rename repro as a permanent end-to-end test
artifact
- Extending the proxy to also cover `EntityManager.getRepository` and
`createQueryBuilder` if a non-`find()` upgrade-time consumer surfaces
2026-05-18 21:38:20 +02:00
Félix Malfait af4765effe feat(twenty-server): one-hop relation filters in GraphQL API (#20527)
## Summary

Adds support for filtering records by fields on a related MANY_TO_ONE
object via the GraphQL API. Backend only — no frontend, no REST, no
view-filter persistence yet.

```graphql
{
  people(filter: { company: { name: { like: "%Airbnb%" } } }) {
    edges { node { id } }
  }
}
```

### Where the work lands

- **Schema** — `relation-field-metadata-gql-type.generator.ts` now emits
`{relationName}: TargetFilterInput` alongside the existing
`{joinColumnName}: UUIDFilter` for MANY_TO_ONE relations. Mirrors the
order-by generator that already does this for sort. Lazy thunks in
`object-metadata-filter-gql-input-type.generator.ts` handle the cycle
between filter inputs.
- **Arg processor** — `FilterArgProcessorService` no longer hard-rejects
accessing a relation by its name. When the value is a nested object on a
MANY_TO_ONE field, it recurses into the target object's metadata so each
leaf still gets validated and coerced. Depth-capped at 1.
- **Query parser** — new `parseRelationSubFilter` branch in
`graphql-query-filter-field.parser.ts`. When triggered: looks up the
target object metadata, calls `ensureRelationJoin` against the outer
query builder, and recurses via a child
`GraphqlQueryFilterConditionParser` scoped to the target.
`and`/`or`/`not` inside the relation filter keep working because the
child dispatches through the same `parseKeyFilter`.
- **Shared join utility** — `ensureRelationJoin.util.ts` is a single
function that inspects `queryBuilder.expressionMap.joinAttributes` for
the alias before adding a `LEFT JOIN`. Rewired the existing inline
`qb.leftJoin` calls in the order parser and group-by service to use it,
so filter-driven joins no longer collide with sort-driven joins on the
same relation.

### Out of scope (explicit)

- ONE_TO_MANY reverse traversal (needs EXISTS subqueries)
- Aggregates (`company.people.count > 5` — needs HAVING)
- View-filter storage (no `relationPath` column on `ViewFilterEntity`)
- REST DSL changes
- Frontend filter-picker UX
- Nesting deeper than one hop (parser and arg-processor both reject)

### Open question for review

Permissions. The order-by-on-relation code path already lets users sort
People by Company.name without a Company read-permission check, and this
PR matches that behavior for filters — felt wrong to add a stricter gate
only on the filter side. If we want object-permission gating on the
relation target, it should be a follow-up that covers both paths
consistently. The only attack surface today is existence inference via
timing, identical to what sort already exposes.

## Test plan

- [x] `tsc --noEmit` — clean for changed files (5 unrelated pre-existing
errors on main untouched)
- [x] `oxlint --type-aware` + `prettier --check` — 0 errors on all 17
changed/new files
- [x] `jest filter-arg-processor.service.spec` — 229 tests pass (the new
optional `flatObjectMetadataMaps` arg is backwards-compatible)
- [x] Integration test (`filter-by-relation-field.integration-spec.ts`,
6 cases) — needs to be verified against a seeded test DB. Could not
exercise the happy path in my isolated worktree; depth-2 rejection
passed there.
- [ ] EXPLAIN ANALYZE on the integration test query to confirm the FK on
`person.companyId` is indexed for both standard and custom MANY_TO_ONE
relations.

### Integration test cases

1. Filter People by `company.name = "Airbnb"` (exact match)
2. Filter People by `company.name like "%irbnb%"`
3. Non-matching filter returns empty
4. Combined with a scalar filter at root via `and`
5. **Combined with `orderBy` on the same relation** — proves the
join-dedupe works (without `ensureRelationJoin`, TypeORM throws
"duplicate alias")
6. Depth-2 nesting (`company.accountOwner.name`) returns
`INVALID_ARGS_FILTER`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:45:32 +02:00
Charles Bochet 04eb913551 chore(page-layout): remove IS_RECORD_PAGE_LAYOUT_* feature flags (#20556)
## Summary

- Both \`IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED\` and
\`IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED\` are force-enabled on
every existing workspace by the 1.23.0 upgrade command
\`BackfillRecordPageLayoutsCommand\` and seeded enabled for new
workspaces via \`DEFAULT_FEATURE_FLAGS\` +
\`seed-feature-flags.util.ts\`. They are no longer load-bearing.
- Unwrap all \`if (flag) { … }\` conditionals to their enabled branch on
both server and front.
- Delete legacy fallback files that only the disabled branch reached:
\`PageLayoutRelationWidgetsSyncEffect\`,
\`usePageLayoutWithRelationWidgets\`,
\`reInjectDynamicRelationWidgetsFromDraft\`,
\`injectRelationWidgetsIntoLayout\`, \`isDynamicRelationWidget\` (and
their tests).
- Strip the two \`enableFeatureFlags\` calls from the 1.23 upgrade
command — the page-layout backfill data logic itself is kept intact
since old workspaces upgrading from < 1.23 still need it.
- No DB cleanup migration: stale \`featureFlag\` rows are left in place,
matching the precedent set by #20531 and #20460.

Net diff: 37 files, +106 / -1727.

## Test plan

- [x] \`npx nx typecheck twenty-shared twenty-server twenty-front\` —
all pass
- [x] \`npx nx lint:diff-with-main twenty-server twenty-front\` — all
pass
- [x] \`cd packages/twenty-front && npx jest page-layout\` — 1240 tests,
all pass
- [x] \`cd packages/twenty-server && npx jest
workspace-entity-manager.spec\` — pass
- [ ] Manual smoke: open a record page, verify tabs render and \"Edit
Layout\" command-menu action is available
- [ ] Manual smoke: Settings → Data model → object → Layout tab is
visible (and hidden for remote / Dashboard objects)
- [ ] Manual smoke: edit a tab title, save, reload — confirm persistence
2026-05-14 08:12:00 +02:00
Etienne fcd2d586ee chore(billing) - remove feature flag (#20531)
- remove feature flag
- remove old enforce cap usage logic
2026-05-13 18:52:29 +02:00
Paul Rastoin 643ec121a7 [twenty-server] no-misused-promise lint (#20529)
# Introduction
Adding `no-miused-promise` lint rule to the twenty-server
In order to flag such pattern
```ts
//  Flagged — forEach doesn't await the callback
items.forEach(async (item) => {
  await process(item);
});
```

## What happened
- Refactored the code-interpreter driver to have a async onResult (
which is also expected by e2b )
- Workspace manager still dirty solution including force cast
- Basic fixes
2026-05-13 13:14:06 +00:00
Weiko 27fd124c2e Dedicated REST controllers for object & field metadata (#20364)
## Summary
- Replace the dynamic `RestApiMetadataController` (which parsed
`/rest/metadata/*path` and proxied to internal GraphQL) with two
dedicated controllers: `ObjectMetadataController` and
  `FieldMetadataController`.
- Drop the GraphQL hop: reads hit Postgres directly via TypeORM
repositories; writes call the existing
`{create,update,delete}One{Object,Field}` service methods.
- Introduce a new clean response shape behind a workspace feature flag
(`IS_REST_METADATA_API_NEW_FORMAT_DIRECT`) — see grace period below.
- Update the OpenAPI spec so the REST playground reflects the (default)
legacy shape during the grace period.

  ## Why
The legacy metadata controller was over-complex: it routed every method
through a path parser, a set of GraphQL query-builder factories, an
internal GraphQL call, and a
`cleanGraphQLResponse` post-processor. Operation names from GraphQL
(`createOneObject`, `updateOneField`, …) leaked straight into REST
responses. The internal-GraphQL hop also gave us
nothing on metadata reads — pagination, filtering, and serialization all
happen against the same Postgres tables either way.

  ## Feature flag & grace period
  `IS_REST_METADATA_API_NEW_FORMAT_DIRECT` (workspace-scoped):
- **Existing workspaces:** flag absent → resolves to `false` → **legacy
response shape** (no behavior change).
- **Newly created workspaces:** flag seeded to `true` via
`DEFAULT_FEATURE_FLAGS` → **new response shape** from day one.
- **Toggle:** support-assisted (no frontend); customers contact us to
opt into the new shape early.
- **Removal:** the flag, the legacy adapter utils
(`to-legacy-{object,field}-metadata-response.util.ts`), and the
parametrized test wrapper get deleted after the grace window. New shape
becomes the only shape; OpenAPI flips to new shape; POST loses the
conditional and reverts to a declarative response.

  ## Response shapes

| Operation | Legacy (flag OFF, default for existing) | New (flag ON) |
|-----------|-----------------------------------------|---------------|
| `GET /rest/metadata/objects` | `{ data: { objects: [...] }, pageInfo,
totalCount }` | `{ data: [...], pageInfo, totalCount }` |
| `GET /rest/metadata/objects/:id` | `{ data: { object: {...} } }` | `{
... }` |
| `POST /rest/metadata/objects` | `201 { data: { createOneObject: {...}
} }` | `201 { ... }` |
| `PATCH/PUT /rest/metadata/objects/:id` | `{ data: { updateOneObject:
{...} } }` | `{ ... }` |
| `DELETE /rest/metadata/objects/:id` | `{ data: { deleteOneObject: {
... } } }` | `{ ... }` |

Same matrix for `/rest/metadata/fields`. Cursor params
(`starting_after`, `ending_before`, `limit`) and `totalCount` are
preserved across both shapes. POST returns `201` in both (old
  controller already did — the doc on main saying `200` was wrong).

  ## Implementation notes
- Reads go straight to Postgres with TypeORM cursor pagination
(`paginateByIdCursor` util, mutually-exclusive `starting_after` /
`ending_before`). No cache on this path — caching +
  filterable pagination didn't combine cleanly.
- Object endpoints inline `fields[]` via a single follow-up `WHERE
objectMetadataId IN (...)` query.
- Controllers read the flag via `FeatureFlagService.isFeatureEnabled`
and conditionally pass the result through a legacy-shape adapter util
before returning.
- Per-domain REST exception filters
(`{Object,Field}MetadataRestApiExceptionFilter`); the `exceptionCode →
httpStatus` switch is extracted to a util so it can be merged with the
  existing GraphQL handler later.
- New controllers live inside the metadata domain modules
(`metadata-modules/{object,field}-metadata/controllers/`) to match
existing precedent (view-field, view, page-layout, …).
- Removes: `RestApiMetadataController`, `RestApiMetadataService`,
`metadata/query-builder/`, `clean-graphql-response.utils.ts`.
- Integration tests are parametrized over both flag values via
`describe.each` — both shapes are asserted in CI.
- OpenAPI fixes inherited from the migration (kept as-is): documents
flat `fields: [...]` rather than the obsolete `{edges:{node:[...]}}`
wrapping; always emits `totalCount`; POST
status `201`. These match what customers actually receive on both
shapes.

Note: Next goal is to implement something similar for graphql and remove
nestjs-query dependency for those 2 entities, then generalise it.
Note2: We have the same issue with Core Rest API such as
```json
{
  "data": {
    "createCompany": {
      "id": "123e4567-e89b-12d3-a456-426614174000",
      "createdAt": "2026-05-07T12:14:52.769Z",
      "updatedAt": "2026-05-07T12:14:52.769Z",
      "deletedAt": "2026-05-07T12:14:52.769Z",
   ...
```
with "createCompany" here which is odd compared to REST standards (FYI
@etiennejouan @charlesBochet)

## Before (Without feature flag)
<img width="1346" height="712" alt="Screenshot 2026-05-12 at 20 50 38"
src="https://github.com/user-attachments/assets/316ce225-1045-4aac-97a9-60fd537eb1ec"
/>
<img width="1378" height="729" alt="Screenshot 2026-05-12 at 20 52 24"
src="https://github.com/user-attachments/assets/a621ab6f-e4f8-44d5-817c-1efd25d33c30"
/>

## After (With feature flag)
<img width="1376" height="728" alt="Screenshot 2026-05-12 at 20 50 46"
src="https://github.com/user-attachments/assets/2424d9c5-e4ed-497c-8e5c-6b54d78675e4"
/>
<img width="1375" height="727" alt="Screenshot 2026-05-12 at 20 51 47"
src="https://github.com/user-attachments/assets/101d957f-38ed-45d9-ab7b-f4f4eb983397"
/>

---------

Co-authored-by: prastoin <paul@twenty.com>
2026-05-13 12:31:16 +00:00
MkDev11 bbc55193f5 Fix phone unique constraints (#20261)
## Summary

Closes #20195

Fix phone field unique constraints so phone numbers are considered
unique by both `primaryPhoneNumber` and `primaryPhoneCallingCode`.

- Include `primaryPhoneCallingCode` in the shared phone composite unique
constraint metadata
- Align the frontend settings composite field config with the backend
metadata
- Return all included unique composite subfields when building
create-many conflict fields
- Match composite unique conflict fields as a group during create-many
upserts

## Root Cause

Phone composite metadata only marked `primaryPhoneNumber` as part of the
unique constraint. That made different international phone numbers with
the same national number conflict, for example `+1 123456789` and `+32
123456789`.

## Test Plan

- `yarn workspace twenty-shared build`
- `jest --runTestsByPath <index action handler and create-many utility
specs>`
- `prettier --check <touched files>`
- `oxlint --type-aware <touched files>`
- `nx run twenty-shared:typecheck`
- `nx run twenty-server:typecheck`
- `nx run twenty-front:typecheck`

---------

Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: prastoin <paul@twenty.com>
2026-05-13 11:57:19 +00:00
Charles Bochet 5003fcbbf2 chore: remove dead feature flags (#20460)
## Summary

Two related cleanups, following the same pattern as #19916 and #19074.

### Dead feature flags

Drops four feature flags whose only references are the enum entry and
the generated GraphQL/SDK files:

- `IS_COMMAND_MENU_ITEM_ENABLED` — never read anywhere.
- `IS_DATASOURCE_MIGRATED` — already commented `@deprecated`. Zero
non-generated consumers.
- `IS_RICH_TEXT_V1_MIGRATED` — the 1-19 migration that gated it was
removed in #19074; the flag became dead at that point.
- `IS_CONNECTED_ACCOUNT_MIGRATED` — only read by the 1-21
`migrate-messaging-infrastructure-to-metadata` command as an
early-return guard, but the flag was never written anywhere in the
codebase, so the guard never fired (and that workspace command is now
removed entirely — see below).

Generated GraphQL/SDK schemas and the `workspace-entity-manager` test
mock are trimmed to match.

### 1-21 workspace commands

Same pattern as #19074 (which removed workspace commands ≤ 1.18). Twenty
is now on 2-5; the 1-21 workspace commands have long since run on every
active workspace and are dead code.

Removes:

- All 14 workspace commands under `upgrade-version-command/1-21/`
(compose-email menu item, key-value-pair index, datasource backfill,
message-thread backfill, dedup engine commands, select-all fixes, AI
response format migration, edit-layout label, drop messaging FKs, folder
parent-id migration, messaging-infra-to-metadata, navigation refactor,
message-thread label fix, search-menu-item label).
- The `1-21-upgrade-version-command.module.ts` registration and the
`V1_21_UpgradeVersionCommandModule` import from
`WorkspaceCommandProviderModule`.

**Kept** (intentionally): the 3 `1-21-instance-command-fast-*` files.
Unlike workspace commands (which mutate data), instance commands carry
**schema deltas** still required by current entity definitions
(`AddViewFieldGroupIdIndex`, `MigrateMessagingCalendarToCore`,
`AddEmailThreadWidgetType`). They remain registered in
`INSTANCE_COMMANDS` and `'1.21.0'` stays in `TWENTY_PREVIOUS_VERSIONS`.
They will fold away naturally on a future version bump when a
`CoreMigrationCheck`-style snapshot picks them up.

## Test plan

- [x] `npx nx typecheck twenty-shared`
- [x] `npx nx typecheck twenty-server`
- [x] `npx nx typecheck twenty-front`
- [x] `npx prettier --check` on the changed files
- [x] `npx oxlint` on the changed server files
- [x] `npx jest feature-flag` (4 suites, 20 tests pass)
- [x] `npx jest workspace-entity-manager` (1 suite, 5 tests pass)
2026-05-12 11:25:20 +02:00
martmull e72a907baa Stop rejecting application token on calendar and message events requests (#20440)
fixes https://github.com/twentyhq/twenty/issues/20423 by authorizing
application token to perform calendarEvents and message queries
2026-05-11 15:24:24 +02:00
Félix Malfait 4da8878697 feat: add email forwarding message channel (#19535)
## Summary

- Add email forwarding as a new message channel type, allowing users to
forward emails from addresses like `support@mycompany.com` into Twenty
- Inbound emails arrive via S3 (SES → S3 bucket), are polled by a cron
job, parsed, routed to the correct workspace/channel, and persisted as
messages
- Dedicated settings page at `/settings/accounts/new-email-forwarding`
where users provide their source email handle and receive a unique
forwarding address
- Forwarding channels bypass the IMAP/mailbox sync state machine — they
skip cron-driven sync, relaunch, and message-list-fetch lifecycle stages
- Forwarding address section shown at the top of the Emails settings
page so users can find/copy their addresses after initial setup
- Tab names for forwarding channels display the user-provided handle
(e.g. `support@mycompany.com`) instead of the internal routing address
- Shared utilities extracted from IMAP driver: `extractThreadId`,
`extractParticipants`, `extractAddresses` to avoid code duplication
- Uses the existing S3 bucket (STORAGE_S3_*) with `inbound-email/`
prefix — no separate bucket needed
- Feature gated behind `isEmailForwardingEnabled` client config
(requires `INBOUND_EMAIL_DOMAIN` + S3 storage)

## New backend modules

- `InboundEmailS3ClientProvider` — lazy-initialized S3 client using
existing storage config
- `InboundEmailStorageService` — S3 operations (get, move to
processed/unmatched/failed)
- `InboundEmailParserService` — RFC 822 parsing via `postal-mime`,
builds `MessageWithParticipants`
- `InboundEmailImportService` — orchestrates download → parse → route →
persist → archive
- `MessagingInboundEmailPollCronJob` — polls S3 `incoming/` prefix,
enqueues import jobs
- `CreateEmailForwardingChannelInput` DTO — accepts user-provided
`handle`

## New frontend components

- `SettingsAccountsNewEmailForwardingChannel` — dedicated page with
handle input form + forwarding address result
- `SettingsAccountsEmailForwardingSection` — forwarding address list on
the Emails settings page
- `useConnectedAccountHandleMap` — shared hook for account ID → handle
lookup
- `useCreateEmailForwardingChannel` — mutation hook accepting handle
parameter

## Test plan

- [x] 17 unit tests for inbound email import service (all outcomes:
imported, unmatched, loop_dropped, unconfigured, parse_failed,
persist_failed)
- [x] 16 tests for `computeSyncStatus` including EMAIL_FORWARDING cases
- [x] 11 tests for `extractEnvelopeRecipient` utility
- [x] TypeScript typechecks pass for both twenty-server and twenty-front
- [x] Lint passes for both packages
- [ ] Manual: create forwarding channel, verify forwarding address
generated
- [ ] Manual: send email to forwarding address, verify it appears in
Twenty

https://claude.ai/code/session_01KpyF6p4cUEnuaT4h8DP5Pm

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: neo773 <neo773@protonmail.com>
2026-05-09 11:00:57 +02:00
Weiko cb0b71dbdc fix: validate enum values before opening transaction in alterEnumValues (#20376)
## Context
The validation throws after startTransaction() and outside the
surrounding try.
If the empty-enum branch ever fires, a BEGIN is left open on the
borrowed QueryRunner and never rolled back by this method the caller has
no way of knowing it now owes a ROLLBACK. Whatever the caller does next
on that QueryRunner runs inside the leftover transaction, and if its
lifecycle ends with a release() instead of a rollbackTransaction(), the
connection goes back to the pool with state still pending.
2026-05-08 07:00:13 +00:00
Etienne 9fc5be1c4c Billing - Migrate from Stripe metering (#20298)
**Overall strategy**
**1. Introduce “Billing V2” behind a workspace flag**
Gate the new model with FeatureFlagKey.IS_BILLING_V2_ENABLED so existing
workspaces stay on the old behavior until they’re migrated or explicitly
on V2.

**2. Replace workflow metered SKUs with a resource-credit product**
Conceptually, billable “workflow execution” usage is not the primary
subscription line item anymore. Add a RESOURCE_CREDIT product (and keep
WORKFLOW_NODE_EXECUTION as deprecated for the transition). Usage and
limits are expressed through credit buckets (e.g. price metadata like
credit_amount), so one product can represent pooled credits instead of a
narrow workflow-only meter.

**3. Migrate subscriptions in two layers**
Schema/catalog: persist extra price metadata (instance upgrade) so the
server knows credit amounts and can match Stripe prices to the new
model.
Per workspace: the registered workspace command
upgrade:2-2:migrate-to-billing-v2 finds subscriptions that still have
WORKFLOW_NODE_EXECUTION, swaps those items to the right RESOURCE_CREDIT
prices (using existing Stripe schedule +
BillingSubscriptionUpdateService stack), then treats the workspace as V2
(flag). Workspaces without that legacy item or without a subscription
are skipped.

**4. Unify subscription lifecycle + usage on the server**

**5. Refresh the product surface in Settings**

Test : 

- [x] Subscribe v1 +  Update subscribe + Migrate
- [x]  Subscribe v2 + Update subscribe
2026-05-07 15:42:11 +00:00
Charles Bochet 10876138d2 refactor: stop reading joinColumnName from relation field settings (#20304)
## Summary

`joinColumnName` on relation field settings is always derivable from the
field name (and the target object name for morph relations). This PR
stops reading it from settings anywhere in production code; the stored
value is no longer used.

The settings field is **not** removed from data yet — a follow-up can
drop it once we are confident nothing depends on the stored value.

## Helpers

The helpers are split by layer because frontend and backend hold morph
relations differently: the frontend has a base name plus a
`morphRelations[]` array, the backend has one row per target with the
name already morph-resolved.

| Helper | Layer | When to use |
|---|---|---|
| `computeRelationGqlFieldJoinColumnName` | Shared / frontend
(`gqlField`) | Non-morph relation on the frontend. |
| `computeMorphRelationGqlFieldName` | Shared / frontend (`gqlField`) |
Need the per-target morph gqlField name (e.g. `targetCompany`). |
| `computeMorphRelationGqlFieldJoinColumnName` | Shared / frontend
(`gqlField`) | Per-target morph join column on the frontend. Prefer over
the non-morph helper for any morph field — it forces the per-target
inputs. |
| `computeMorphOrRelationFieldJoinColumnName` | Backend
(`FlatFieldMetadata.name`) | Any backend read or write — the flat name
is already morph-resolved, so one helper covers both cases. |
| `computeMorphRelationFlatFieldName` | Backend
(`FlatFieldMetadata.name`) | **Mutation paths only** (create / update /
object rename). Reads consume the stored `field.name` and never call
this. |

## Test plan

- [x] Typecheck and lint (front, server, shared)
- [x] Existing unit tests pass
- [ ] CI green
2026-05-07 09:53:00 +02:00
Félix Malfait 30b8663a74 chore: remove IS_AI_ENABLED feature flag (#19916)
## Summary
- AI is now GA, so the public/lab `IS_AI_ENABLED` flag is removed from
`FeatureFlagKey`, the public flag catalog, and the dev seeder.
- Drops every backend `@RequireFeatureFlag(IS_AI_ENABLED)` guard (agent,
agent chat, chat subscription, role-to-agent assignment, workflow AI
step creation) and the now-unused `FeatureFlagModule`/`FeatureFlagGuard`
wiring in the AI and workflow modules.
- Removes frontend gating from settings nav, role
permissions/assignment/applicability, command menu hotkeys, side panel,
mobile/drawer nav, and the agent chat provider so AI UI is always on.
Tests and generated GraphQL/SDK schemas updated accordingly.

## Test plan
- [x] `npx nx typecheck twenty-shared`
- [x] `npx nx typecheck twenty-server`
- [x] `npx nx typecheck twenty-front`
- [x] `npx nx lint:diff-with-main twenty-server`
- [x] `npx nx lint:diff-with-main twenty-front`
- [x] `npx jest --config=packages/twenty-server/jest.config.mjs
feature-flag`
- [x] `npx jest --config=packages/twenty-server/jest.config.mjs
workspace-entity-manager`
- [ ] Manual smoke test: AI features still accessible without any flag
row in `featureFlag`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:49:46 +02:00
neo773 be9616db60 chore: remove draft email feature flag (#19842) 2026-04-18 21:12:01 +02:00
Charles Bochet 4103efcb84 fix: replace slow deep-equal with fastDeepEqual to resolve CPU bottleneck (#19771)
## Summary

- Replaced the `deep-equal` npm package with the existing
`fastDeepEqual` from `twenty-shared/utils` across 5 files in the server
and shared packages
- `deep-equal` was causing severe CPU overhead in the record update hot
path (`executeMany` → `formatTwentyOrmEventToDatabaseBatchEvent` →
`objectRecordChangedValues` → `deepEqual`, called **per field per
record**)
- `fastDeepEqual` is ~100x faster for plain JSON database records since
it skips unnecessary prototype chain inspection and edge-case handling
- Removed the now-unnecessary `LARGE_JSON_FIELDS` branching in
`objectRecordChangedValues` since all fields now use the fast
implementation
2026-04-16 17:23:50 +02:00
Marie bc28e1557c Introduce updateWorkspaceMemberSettings and clarify product (#19441)
## Summary

Introduces a dedicated **metadata** mutation to update **standard
(non-custom)** workspace member settings, moves profile-related UI to
use it, and aligns **workspace member** record permissions with the rest
of the CRM so users cannot escalate visibility via RLS by editing their
own member record.

## Product behaviour

### Profile and appearance (standard fields)

- Users can still update **their own** standard workspace member fields
that the product exposes in **Settings / Profile** (e.g. name, locale,
color scheme, avatar flow) via the new
**`updateWorkspaceMemberSettings`** mutation.
- The mutation returns a **boolean**; the app **merges** the updated
fields into local state so the UI stays in sync without refetching the
full workspace member record.
- **Locale** changes also keep **`userWorkspace`** in sync when a locale
is present in the payload (including from the workspace `updateOne` path
when applicable).

### Custom fields on workspace members

- The dedicated metadata mutation **rejects** any **custom** workspace
member field (and unknown keys). Those updates must go through the
normal **object** `updateOne` pipeline, which is subject to **object-
and field-level** permissions like other records. But since we don't
have object- and field-level permission configuration for system objects
yet, this permission is derived from Workspace member settings
permission.
- **Workspace member** is no longer exempt from ORM permission
validation for updates merely because it is a **system** object. Users
who **do not** have workspace member access (e.g. no **Workspace
members** settings permission and no equivalent broad settings access on
the role) **cannot** use `updateOne` on `workspaceMember` to change
**custom** (or other) fields on their own row—even though that row is
used for RLS predicates.
- This closes a path where someone could widen what they can see by
writing to fields that drive row-level rules.

### Who can change another member

- Updating **another** user’s workspace member still requires
**Workspace members** (or equivalent) settings permission, consistent
with admin tooling.
2026-04-14 16:29:00 +00:00
Félix Malfait 69d228d8a1 Deprecate IS_RECORD_TABLE_WIDGET_ENABLED feature flag (#19662)
## Summary
- Removes the `IS_RECORD_TABLE_WIDGET_ENABLED` feature flag, making the
record table widget unconditionally available in dashboard widget type
selection
- The flag was already seeded as `true` for all new workspaces and only
gated UI visibility in one component
(`SidePanelPageLayoutDashboardWidgetTypeSelect`)
- Cleans up the flag from `FeatureFlagKey` enum, dev seeder, and test
mocks

## Analysis
The flag only controlled whether the "View" (Record Table) widget option
appeared in the dashboard widget type selector. The entire record table
widget infrastructure (rendering, creation hooks, GraphQL types,
`RECORD_TABLE` enum in `WidgetType`) is independent of the flag and
fully implemented. No backend logic depends on this flag.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-13 21:13:15 +00:00
Félix Malfait 65b2baca7a Remove IS_USAGE_ANALYTICS_ENABLED feature flag (#19566)
## Summary
This PR removes the `IS_USAGE_ANALYTICS_ENABLED` feature flag and makes
usage analytics features universally available. The feature flag guard
has been removed from the usage analytics resolver and all conditional
rendering based on this flag has been eliminated.

## Key Changes
- **Removed feature flag dependency**: Deleted
`IS_USAGE_ANALYTICS_ENABLED` from the `FeatureFlagKey` enum in
`twenty-shared`
- **Updated AI Usage tab**: Simplified `SettingsAIUsageTab` to remove
enterprise access checks and feature flag conditionals, now only checks
if ClickHouse is configured
- **Updated Usage Analytics section**: Removed feature flag guard from
`SettingsUsageAnalyticsSection` and added loading/empty state handling
- **Updated AI settings navigation**: Made the Usage tab always visible
in the AI settings tabs, removing conditional rendering based on feature
flag
- **Updated Billing Credits section**: Removed feature flag check before
showing the "View usage" button
- **Updated Settings routes**: Removed `SettingsProtectedRouteWrapper`
with feature flag requirement from usage routes
- **Updated GraphQL resolver**: Removed `@RequireFeatureFlag` decorator
and `FeatureFlagGuard` from the `getUsageAnalytics` query
- **Updated dev seeder**: Removed the feature flag seed entry for
`IS_USAGE_ANALYTICS_ENABLED`

## Implementation Details
- Usage analytics now gracefully handles loading states with
`UsageSectionSkeleton`
- Empty state messaging is shown when no usage data is available yet
- ClickHouse configuration remains the only requirement for usage
analytics functionality
- All enterprise-specific gating for AI usage analytics has been removed
in favor of ClickHouse availability checks

https://claude.ai/code/session_01MRFVXtquL3wS7qmQkDU3AT

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-10 19:12:33 +02:00
Charles Bochet b284c8323c Remove Favorite and FavoriteFolder from workspace schema (#19536)
## Summary

- Removes all workspace schema definitions for `Favorite` and
`FavoriteFolder` entities, which have been fully migrated to
`NavigationMenuItems`
- Deletes 26 standalone files including workspace entities, NestJS
modules, services, listeners, jobs, standard application builders (field
metadata, views, view fields, view field groups, indexes, page layouts),
mocks, and integration tests
- Cleans up ~40 modified files: removes `favorites` relation from 10
workspace entities and their field metadata utils, removes entries from
all builder maps, shared constants (`STANDARD_OBJECTS`,
`CoreObjectNameSingular`, `DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS`), SDK
default relations, AI tool filtering, and standard object icons
2026-04-10 12:47:06 +02:00
Charles Bochet f6423f5925 Remove DataSourceService and clean up datasource migration logic (#19532)
## Summary

- **Drop the `objectMetadata.dataSourceId` foreign key and index** via a
1-22 fast instance command — column kept nullable for data preservation
- **Delete `DataSourceService`, `DataSourceModule`, and
`DataSourceException`** — all code now uses `workspace.databaseSchema`
directly
- **Remove `IS_DATASOURCE_MIGRATED` feature flag** from default flags
and all branching logic
- **Simplify workspace/object creation pipelines** —
`WorkspaceManagerService`, `DevSeederService`, and the object creation
action handler no longer route through `DataSourceService`
- **Keep `DataSourceEntity` and the `dataSource` table** for historical
data — entity stripped of all ORM relations
2026-04-10 07:34:05 +00:00
Etienne caac791421 Cleaning - Remove logs (#19498) 2026-04-09 14:19:53 +00:00
Etienne 3306d66f5b cleaning - remove logs (#19445) 2026-04-08 13:05:47 +00:00
martmull 8702300b07 App feedbacks fix option id required in apps (#19386)
fixes
https://discord.com/channels/1130383047699738754/1488226371032453292
2026-04-07 13:53:16 +00:00
neo773 6ad9566043 Add messaging upgrade command 1 21 (#19389)
```
[Nest] 50057  - 04/07/2026, 4:14:10 PM     LOG [WorkspaceIteratorService] Running on workspace 20202020-1c25-4d02-bf25-6aeccf7ea419 1/2
[Nest] 50057  - 04/07/2026, 4:14:10 PM     LOG [MigrateMessagingInfrastructureToMetadataCommand] Migrated 5 connected accounts for workspace 20202020-1c25-4d02-bf25-6aeccf7ea419
[Nest] 50057  - 04/07/2026, 4:14:10 PM     LOG [MigrateMessagingInfrastructureToMetadataCommand] Migrated 6 message channels for workspace 20202020-1c25-4d02-bf25-6aeccf7ea419
[Nest] 50057  - 04/07/2026, 4:14:10 PM     LOG [MigrateMessagingInfrastructureToMetadataCommand] Migrated 6 calendar channels for workspace 20202020-1c25-4d02-bf25-6aeccf7ea419
[Nest] 50057  - 04/07/2026, 4:14:10 PM     LOG [MigrateMessagingInfrastructureToMetadataCommand] Migrated 5 message folders for workspace 20202020-1c25-4d02-bf25-6aeccf7ea419
[Nest] 50057  - 04/07/2026, 4:14:10 PM     LOG [WorkspaceIteratorService] Running on workspace 3b8e6458-5fc1-4e63-8563-008ccddaa6db 2/2
[Nest] 50057  - 04/07/2026, 4:14:10 PM     LOG [MigrateMessagingInfrastructureToMetadataCommand] Migrated 5 connected accounts for workspace 3b8e6458-5fc1-4e63-8563-008ccddaa6db
[Nest] 50057  - 04/07/2026, 4:14:11 PM     LOG [MigrateMessagingInfrastructureToMetadataCommand] Migrated 4 message channels for workspace 3b8e6458-5fc1-4e63-8563-008ccddaa6db
[Nest] 50057  - 04/07/2026, 4:14:11 PM     LOG [MigrateMessagingInfrastructureToMetadataCommand] Migrated 4 calendar channels for workspace 3b8e6458-5fc1-4e63-8563-008ccddaa6db
[Nest] 50057  - 04/07/2026, 4:14:11 PM     LOG [MigrateMessagingInfrastructureToMetadataCommand] Migrated 10 message folders for workspace 3b8e6458-5fc1-4e63-8563-008ccddaa6db
[Nest] 50057  - 04/07/2026, 4:14:11 PM     LOG [MigrateMessagingInfrastructureToMetadataCommand] Command completed!
```
2026-04-07 11:21:27 +00:00
neo773 d3f0162cf5 Remove connected account feature flag (#19286)
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-06 08:13:41 +00:00
martmull 80c5cfc2ec Add npm packages app settings list (#19324)
solves
https://discord.com/channels/1130383047699738754/1488499318762635344
2026-04-04 07:20:11 +00:00
Etienne d562a384c2 Remove direct execution feature flag - WIP (#19254)
Bug fixes exposed by always-on direct execution
1. GraphQL spec compliance — data[field] = null on resolver error
direct-execution.service.ts — Changed from Promise.allSettled (which
lost the responseKey on rejection) to Promise.all with per-field
try/catch; errors now set data[responseKey] = null per spec
2. Empty object arguments skipped (extractArgumentsFromAst)
extract-arguments-from-ast.util.ts — Removed isEmptyObject check;
filter: {}, data: {} now correctly passed to resolvers instead of
silently dropped (which caused permissions to never be checked)
3. orderBy: {} factory default treated as "no ordering"
direct-execution.service.ts — Before calling the resolver, strips
orderBy: {} and orderByForRecords: {} (empty-object factory defaults
that mean "no ordering")
assert-find-many-args.util.ts / assert-group-by-args.util.ts — Accept {}
for orderBy without throwing
4. orderBy: { field: '...' } object auto-coerced to [{ field: '...' }]
array
direct-execution.service.ts — Applies GraphQL list coercion: a
non-array, non-empty orderBy object is wrapped in an array before
assertion and resolver call
5. totalCount and aggregate fields returned as strings from PostgreSQL
graphql-format-result-from-selected-fields.util.ts — Added
coerceAggregateValue that parses numeric strings to numbers for
totalCount, sum*, avg*, min*, max*, count*, percentageOf* fields
Test updates
nested-relation-queries.integration-spec.ts — Updated expected error
message from Yoga schema-validation message to direct execution resolver
message
~30 snapshot files — Updated to reflect direct execution's error
messages (different from Yoga schema-validation messages for input type
errors)
2026-04-03 15:23:01 +00:00
Weiko 6f3a86c4a9 Fix insert conflict between field permission and RLS (#19244)
Insert operations blocked by field-level update permissions on
non-editable fields

The insert code path in permissions.utils.ts fell through to the update
case (no break), causing validateUpdateFieldPermissionOrThrow to reject
inserts when any field had "Edit disabled" which could conflict with RLS
predicates (used for insertion of new records)

Fixes https://github.com/twentyhq/twenty/issues/19201

We will keep checking update permissions for insertion (until we decide
to have a separate permission flag for insertion) but to fix the issue
we will skip this part if it conflicts with an RLS predicate
2026-04-02 14:01:51 +00:00
Etienne 579714b62f Investigate memory leak (#19213)
In search for cache leak + Remove old instrumentation
2026-04-02 11:23:10 +00:00
Raphaël Bosi 9f95c4763c [COMMAND MENU ITEMS] Remove deprecated code (#19199)
This PR is the first one of a cleanup after upgrading command menu items
to V2.
2026-04-01 15:56:52 +00:00
Thomas Trompette d10c0a6439 Fix: composite update events not received (#19053)
Database batch events (update / insert / soft-delete / hard-delete) were
building recordsBefore / recordsAfter after formatResult ran twice: once
inside WorkspaceSelectQueryBuilder.getMany() / getOne(), and again in
the CUD query builders. On the second pass, already-shaped composite
fields (e.g. emails) went through formatFieldMetadataValue and kept the
same object references as the live TypeORM row, so recordsBefore could
change when the entity was updated—breaking workflow triggers and diffs.
2026-03-31 11:58:12 +00:00
Charles Bochet 08b041633a chore: remove 1-19 upgrade commands and navigation menu item feature flags (#19083)
## Summary
- Deletes the entire `1-19` upgrade version command directory (7 files,
~1500 lines) and removes all references from the upgrade module and
command runner.
- Removes `IS_NAVIGATION_MENU_ITEM_ENABLED` and
`IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED` feature flags from the enum,
seed data, generated schema files, and test mocks. These flags had no
remaining feature-gated code — navigation menu items are now always
enabled.
2026-03-30 14:15:46 +00:00
Charles Bochet a3f468ef98 chore: remove IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED and IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED feature flags (#19082)
## Summary
- Removes `IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED` feature flag,
making row-level permission predicates always enabled. Removes
early-return guards from query builders (select, update, insert) and the
shared utility, the public feature flag metadata entry, and
`updateFeatureFlag` calls from integration tests.
- Removes `IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED` feature flag, making
whole-day datetime filtering always enabled. Simplifies filter input
components and hooks to always use date-only format for `IS` operand on
`DATE_TIME` fields.
- Cleans up enum definitions, seed data, generated schema files, and
test mocks for both flags.
2026-03-29 12:26:08 +02:00
Charles Bochet 4d74cc0a28 chore: remove IS_APPLICATION_ENABLED feature flag (#19081)
## Summary
- Remove the `IS_APPLICATION_ENABLED` feature flag — application
features are now always enabled
- Remove `@RequireFeatureFlag` decorators and `FeatureFlagGuard` from 7
application resolvers (registration, development, manifest, install,
upgrade, marketplace, oauth)
- Remove frontend feature flag checks: settings navigation visibility,
route protection wrapper, side panel widget type gating, and role
permission flag filtering
- Delete the integration test for disabled-flag behavior
- Clean up seed data, test mocks, and generated schema files
2026-03-29 11:43:35 +02:00
Charles Bochet 28de34cf20 chore: remove IS_DASHBOARD_V2_ENABLED feature flag (#19079)
## Summary
- Remove the `IS_DASHBOARD_V2_ENABLED` feature flag from the codebase
- Dashboard V2 features (gauge charts, line charts, pie charts) are now
always enabled
- Remove the validator gate that blocked gauge chart creation/update
without the flag
- Clean up all related code: seed data, dev-seeder service, widget
seeds, and test mocks
2026-03-29 10:45:54 +02:00
Charles Bochet e8cb086b64 chore: remove completed migration feature flags and upgrade commands <= 1.18 (#19074)
## Summary

- Remove 3 completed migration feature flags: `IS_ATTACHMENT_MIGRATED`,
`IS_NOTE_TARGET_MIGRATED`, `IS_TASK_TARGET_MIGRATED` — these were
already enabled by default for all new workspaces via
`DEFAULT_FEATURE_FLAGS`
- Delete all upgrade command directories for versions <= 1.18 (`1-16/`,
`1-17/`, `1-18/`) along with their module registrations, removing ~6,600
lines of dead migration code
- Simplify frontend utility functions
(`getActivityTargetObjectFieldIdName`, `getActivityTargetsFilter`,
`getActivityTargetFieldNameForObject`,
`generateActivityTargetMorphFieldKeys`,
`findActivitiesOperationSignatureFactory`) by removing the
`isMorphRelation` parameter and always using the morph relation path
- Remove feature flag checks from 7 frontend hooks/components that were
gating attachment and activity target behavior behind the removed flags
- Simplify `buildDefaultRelationFlatFieldMetadatasForCustomObject`
server util to always treat attachment, noteTarget, and taskTarget as
morph relations without checking feature flags
2026-03-29 09:15:26 +02:00
Charles Bochet c407341912 feat: optimize hot database queries with multi-layer caching (#19068)
## Summary

Introduces multi-layer caching for the 5 most frequent database queries
identified in production (Sentry data), targeting the JWT authentication
hot path and cron job logic.

### Problem
Our database is under heavy load from uncached queries on the auth hot
path:
- `WorkspaceEntity` lookups: **638 queries/min**
- `ApiKeyEntity` lookups: **491 queries/min**
- `UserEntity` lookups: **147 queries/min**
- `UserWorkspaceEntity` lookups: **143 queries/min**
- `LogicFunctionEntity` lookups: **1800 queries/min** (cron job)

### Solution

**1. New `CoreEntityCacheService`** for non-workspace-scoped entities
(Workspace, User, UserWorkspace):
- Mirrors `WorkspaceCacheService` architecture (in-process Map + Redis
with hash validation)
- Provider pattern with `@CoreEntityCache` decorator
- Keyed by entity primary key (not workspaceId)
- 100ms local TTL, Redis-backed hash validation for cross-instance
consistency
- Three providers: `WorkspaceEntityCacheProviderService`,
`UserEntityCacheProviderService`,
`UserWorkspaceEntityCacheProviderService`

**2. New `apiKeyMap` WorkspaceCache** for workspace-scoped API key
lookups:
- `WorkspaceApiKeyMapCacheService` loads all API keys for a workspace
into a map by ID
- Leverages existing `WorkspaceCacheService` infrastructure
- Cache invalidation on API key create/update/revoke

**3. `CronTriggerCronJob` refactored** to use existing
`flatLogicFunctionMaps` workspace cache:
- Eliminates per-workspace `LogicFunctionEntity` repository queries
(~1800/min)
- Filters cached data in-memory instead

**4. `JwtAuthStrategy` refactored** to use caches for all entity
lookups:
- Workspace, User, UserWorkspace → `CoreEntityCacheService`
- ApiKey → `WorkspaceCacheService` (`apiKeyMap`)
- Impersonation queries kept as direct DB queries (rare path, requires
relations)

**5. Cache invalidation** wired into mutation paths:
- `WorkspaceService` → invalidates `workspaceEntity` on
save/update/delete
- `ApiKeyService` → invalidates `apiKeyMap` on create/update/revoke

### Architecture

```
Request → JwtAuthStrategy
  ├── Workspace lookup → CoreEntityCacheService (in-process → Redis → DB)
  ├── User lookup → CoreEntityCacheService (in-process → Redis → DB)
  ├── UserWorkspace lookup → CoreEntityCacheService (in-process → Redis → DB)
  └── ApiKey lookup → WorkspaceCacheService (in-process → Redis → DB)

CronTriggerCronJob
  └── LogicFunction lookup → WorkspaceCacheService (flatLogicFunctionMaps)
```

### Expected Impact
| Query | Before | After |
|-------|--------|-------|
| WorkspaceEntity | 638/min | ~0 (cached) |
| ApiKeyEntity | 491/min | ~0 (cached) |
| UserEntity | 147/min | ~0 (cached) |
| UserWorkspaceEntity | 143/min | ~0 (cached) |
| LogicFunctionEntity | 1800/min | ~0 (cached) |

### Not included (ongoing separately)
- DataSourceEntity query optimization (IS_DATASOURCE_MIGRATED migration)
- ObjectMetadataEntity query optimization (already partially cached)
2026-03-28 22:53:34 +01:00
Charles Bochet 81fc960712 Deprecate dataSource table with dual-write to workspace.databaseSchema (#19059)
## Summary
- Starts deprecation of the `core.dataSource` table by introducing a
dual-write system: `DataSourceService.createDataSourceMetadata` now
writes to both `core.dataSource` and `core.workspace.databaseSchema`
- Migrates read sites (`WorkspaceDataSourceService.checkSchemaExists`,
`WorkspaceSchemaFactory`, `MiddlewareService`,
`WorkspacesMigrationCommandRunner`) to read from
`workspace.databaseSchema` instead of querying the `dataSource` table
- Removes the unused `databaseUrl` field from `WorkspaceEntity` and
drops the column via migration
- Adds a 1.20 upgrade command to backfill `workspace.databaseSchema`
from `dataSource.schema` for existing workspaces
2026-03-28 11:29:19 +01:00
Abdul Rahman 5efe69f8d3 Migrate field permission to syncable entity (#18751)
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-27 14:47:27 +01:00
Etienne ec5ab2b84a Fix format result (#18975)
For a field that is not a relation and not a composite, there is no
legitimate reason to recurse into a plain object value.

The only cases where recursion makes sense are:
- Relations, the value is a nested entity record (handled at line 149)
- Composite fields - the value is being reassembled from flat columns
(handled at line 176+)

For everything else, a plain object value is just data


#### Perf testing

Compare findManyCompanies with a jsonField having same 20-level nested
value (cf quote)
- with fix
Average ≈ 75.77 ms - min 52.43 ms - max 114.99 ms.

- without
Average: ≈ 376.62 ms - Min: 194.19 ms - Max: 1183.65 ms (39 requests)

```
{
  "document": {
    "id": "doc-nested-20",
    "title": "20-level nested sample",
    "tags": ["sample", "nested", "json", "fixture", "demo"],
    "counts": [100, 200, 300, 400, 500],
    "extras": {
      "a": true,
      "b": false,
      "c": null,
      "d": 3.14159,
      "e": "unicode-测试-αβγ"
    }
  },
  "users": [
    { "id": 1, "name": "Alice", "roles": ["admin", "editor"] },
    { "id": 2, "name": "Bob", "roles": ["viewer"] },
    { "id": 3, "name": "Carol", "roles": ["editor", "viewer"] }
  ],
  "matrix": [[1, 2, 3], [4, 5, 6], [7, 8, 9]],
  "settings": {
    "theme": "dark",
    "notifications": { "email": true, "push": false, "sms": false },
    "locale": "en-US"
  },
  "deep": {
    "level01": {
      "level02": {
        "level03": {
          "level04": {
            "level05": {
              "level06": {
                "level07": {
                  "level08": {
                    "level09": {
                      "level10": {
                        "level11": {
                          "level12": {
                            "level13": {
                              "level14": {
                                "level15": {
                                  "level16": {
                                    "level17": {
                                      "level18": {
                                        "level19": {
                                          "level20": {
                                            "leaf": true,
                                            "summary": "deepest object in this branch",
                                            "indices": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
                                            "meta": {
                                              "maxDepth": 20,
                                              "note": "Count deep.level01..level20 as 20 nested objects"
                                            }
                                          }
                                        }
                                      }
                                    }
                                  }
                                }
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "footer": {
    "checksum": "example-only-not-real",
    "generated": "2025-03-25"
  }
}
```
2026-03-26 14:40:36 +00:00
Lucas Bordeau 33a474c8e6 Add record table widget feature flag (#18960)
Introduce a new feature flag for the record table widget, enabling
conditional rendering and state management based on its status. Update
related components and tests accordingly.

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-25 16:47:20 +00:00
Félix Malfait d5b41b2801 Unify auth context → role permission config resolution into a single pure utility (#18927)
## Summary

- Consolidates duplicated auth-context-to-role-ID resolution logic
(previously in
`PermissionsService.resolveRolePermissionConfigFromAuthContext` and
`CommonBaseQueryRunnerService.getRoleIdOrThrow`) into a single pure
utility function `resolveRolePermissionConfig` in the ORM layer
- The utility is synchronous and operates on cached data
(`userWorkspaceRoleMap`, `apiKeyRoleMap`) already loaded into the
workspace context — no async calls, no service dependencies
- Adds `apiKeyRoleMap` to `ORMWorkspaceContext` (it was already in the
workspace cache, just not loaded into the ORM context)
- Removes `PermissionsService` dependency from
`NavigationMenuItemRecordIdentifierService`
- Removes `UserRoleService` and `ApiKeyRoleService` injections from
`CommonBaseQueryRunnerService`

## Test plan

- [ ] Existing typecheck passes (`npx nx typecheck twenty-server`)
- [ ] Verify record identifier resolution still works for navigation
menu items (user, system, API key, and application auth contexts)
- [ ] Verify GraphQL CRUD queries still enforce correct role-based
permissions
- [ ] Verify API key authenticated requests resolve permissions
correctly


Made with [Cursor](https://cursor.com)
2026-03-24 21:37:58 +01:00
Etienne 56ea79d98c Perf investigation - Time qgl query (#18911)
Added a new IS_GRAPHQL_QUERY_TIMING_ENABLED feature flag that, when
activated per workspace, logs the execution time and operation name of
every GraphQL query across both the standard Yoga pipeline and the
direct execution fast path. The timing context propagates via
AsyncLocalStorage to also instrument buildColumnsToSelect and
formatResult — giving a breakdown of column selection and result
transformation costs without any caller changes.
2026-03-24 13:41:15 +00:00
Charles Bochet 708e53d829 Fix multi-select option removal crashing when records contain removed values (#18871)
## Summary

- Fixes a bug where removing an option from a MULTI_SELECT field fails
with `invalid input value for enum` when existing records contain the
removed value alongside surviving values.
- The root cause was the `ELSE` branch in `updateArrayEnum` which tried
to cast removed enum values (e.g. `DISTRIBUTOR`) to the new enum type
that no longer includes them.
- The fix replaces the `ELSE` cast with a NULL-producing implicit CASE
default and uses `array_agg(...) FILTER (WHERE mapped_value IS NOT
NULL)` to silently strip removed values from existing arrays.

### Before (bug)
```sql
-- ELSE branch tries to cast removed value to new enum → crash
CASE unnest_value::text
  WHEN 'IMPL' THEN 'IMPL'::new_enum
  WHEN 'APP' THEN 'APP'::new_enum
  ELSE unnest_value::text::new_enum  -- 'DISTRIBUTOR' fails here
END
```

### After (fix)
```sql
-- No ELSE: removed values produce NULL, filtered out by array_agg
SELECT array_agg(mapped_value) FILTER (WHERE mapped_value IS NOT NULL)
FROM (
  SELECT CASE unnest_value::text
    WHEN 'IMPL' THEN 'IMPL'::new_enum
    WHEN 'APP' THEN 'APP'::new_enum
  END AS mapped_value
  FROM unnest(old_column) AS unnest_value
) enum_mapping
```
2026-03-23 20:06:34 +00:00