Commit Graph

1064 Commits

Author SHA1 Message Date
Félix Malfait 1efa3567ef Rename isUIReadOnly to isUIEditable, add isUICreatable, expose both to app developers (#21504)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# UI capability flags: `isUIEditable` + `isUICreatable`

## Per-verb capability model

This PR replaces the negative `isUIReadOnly` metadata flag with
positive, per-verb capability flags (à la Salesforce
`createable`/`updateable`):

- **`isUIEditable: boolean`, default `true`** — rename of `isUIReadOnly`
with inverted polarity, on **both** `objectMetadata` and
`fieldMetadata`. It is one concept ("can the user edit this through the
generic UI?") at two altitudes, so it carries one name at both levels.
- **`isUICreatable: boolean`, default `true`** — new, **object-level
only** (fields have no create verb). When `false`, no generic UI
affordance to create a record of this object appears anywhere (table "+"
buttons, board column add, calendar add, relation-section "Add new",
record picker "Add new", command-menu create action and its keyboard
shortcut).

Both flags are **UI-affordance flags only**: the server does not block
create/edit mutations based on them, so the system, API, and workflows
continue to mutate these records freely. They are orthogonal statements
about the object's nature with no implication rule in the data model.
Because today's inline creation UX creates a blank record the user must
then edit, the frontend create predicate currently requires both
`isUICreatable` and effective editability.

There is no CREATE permission in `ObjectPermissions`; the frontend keeps
gating creation on `canUpdateObjectRecords` as a proxy, ANDed with the
new flags.

## Unified create predicate

All generic creation entry points now flow through one predicate,
`canCreateRecordsForObjectMetadataItem` (`isUICreatable` && not
`isSystem` && not effectively read-only, where effective read-only
covers `isUIEditable`, `isRemote`, and the `canUpdateObjectRecords`
proxy via `isObjectMetadataReadOnly`). This deletes the previously
hardcoded suppression lists:

- `isRecordTableCreateDisabled.ts` and its hardcoded
`WorkflowRun`/`WorkflowVersion` list — deleted; those objects (plus
`workspaceMember`) now declare `isUICreatable: false` in the standard
application instead.
- The hardcoded `workspaceMember` guard inside
`useAddNewRecordAndOpenSidePanel.ts` — deleted.
- The `CREATE_NEW_RECORD` command menu item's availability expression
now checks `objectMetadataItem.isUICreatable`, `isUIEditable`,
`isSystem`, and `isRemote`; a workspace upgrade command re-syncs the
expression in existing workspaces.

Component-local conditions (soft-delete filter active, layout
customization mode) stay in their components.

## GraphQL compatibility and removal plan

The schema delta versus main is **purely additive plus deprecations —
zero breaking changes**:

- `isUIReadOnly` remains on both the ObjectMetadata and FieldMetadata
GraphQL output types for **one release** as a deprecated field computed
as `!isUIEditable` (`deprecationReason: 'Use isUIEditable'`). The Twenty
frontend no longer queries it.
- `isUIReadOnly` also remains on the **input side** for one release
(`CreateFieldInput`, `UpdateFieldInput`, `FieldFilter`, `ObjectFilter`),
keeping the schema shape identical to main for those members. On create
it acts as a legacy alias mapped to `!isUIReadOnly` (`isUIEditable` wins
when both are provided); on update it is ignored, exactly as on main (it
was never an editable property). Filtering on the deprecated member
keeps working until the column is dropped at upgrade time; after that it
is a deprecated no-op surface kept only for schema compatibility.

**Removal plan for next release: drop `isUIReadOnly` from the output
DTOs (and resolvers' `@ResolveField`s), from the input/filter types,
from the create-input mapping, and the `@WasRemovedInUpgrade`-retained
entity columns and decorators.**

## ⚠️ Webhook / database-event payload shape change

The `database-event-payload` type in `twenty-shared` got a clean rename
(no alias): metadata snapshots in webhook and database-event payloads
now carry `isUIEditable` (and `isUICreatable` at object level) **instead
of** `isUIReadOnly`, with inverted polarity. Consumers of these payloads
that read `isUIReadOnly` must switch to `isUIEditable`.

## New manifest properties (app-developer DX)

Application developers can now set these flags in their app manifests
(purely additive — existing manifests and older `twenty-sdk` versions
are unaffected, defaults apply when omitted):

- `objects[].isUICreatable?: boolean` (default `true`)
- `objects[].isUIEditable?: boolean` (default `true`)
- `fields[].isUIEditable?: boolean` (default `true`)

The manifest converters previously hardcoded `isUIReadOnly: false`; they
now read the manifest values with `?? true` defaults. The types are
re-exported through `twenty-sdk` from `twenty-shared`.

## Migration & backfill

- One fast instance command: adds `isUIEditable` (NOT NULL default
`true`) on `core."objectMetadata"` and `core."fieldMetadata"`, backfills
`isUIEditable = false` exactly where `isUIReadOnly = true`, drops
`isUIReadOnly`, and adds `isUICreatable` (default `true`) on
`objectMetadata`. The `down` is the exact inverse. Uses `ADD/DROP COLUMN
IF (NOT) EXISTS`, matching the 2-12 drop-`isCustom` precedent. Verified
up and down in separate transactions against a dev database with exact
backfill counts.
- **Cross-version upgrade safety (multi-version self-hosted jumps):**
the upgrade sequence interleaves per version (instance → workspace
commands), so pre-2.13 workspace commands run **before** the 2.13 rename
when an old instance jumps several versions. Following the `isCustom`
precedent: `isUIEditable`/`isUICreatable` are marked
`@WasIntroducedInUpgrade` and `isUIReadOnly` stays on both entities as
`@WasRemovedInUpgrade`, so the upgrade-aware entity metadata adapter
hides the not-yet-existing columns (and keeps the legacy column live) at
pre-2.13 cursors. **No committed upgrade command outside the 2-13
directory is modified**: the old 1-21/2-8/2-9 commands keep their
original `isUIReadOnly: true` inputs, which still compile (entity
property retained, deprecated create-input alias mapped) and still
produce the correct legacy column writes pre-rename.
- A 2-13 workspace command (`sync-standard-ui-capability-flags`)
re-syncs `isUICreatable` **and** `isUIEditable` on standard objects and
`isUIEditable` on standard fields from the standard-application
definitions. This backfills `isUICreatable: false` on
`workflowRun`/`workflowVersion`/`workspaceMember` and heals fields
created mid-cross-upgrade by pre-2.13 commands (whose hidden
`isUIEditable` value cannot reach the insert). Both 2-13 sync commands
pass `isSystemBuild: true` — the flat metadata validator otherwise
rejects direct updates to system objects (verified against a
deliberately drifted dev database; the run is idempotent).
- A second 2-13 workspace command re-syncs the create-record command
availability expression.

## Testing

- Unit tests for `canCreateRecordsForObjectMetadataItem`
(flag/permission/system combinations) and for the manifest converters
(flags set / omitted → defaults).
- Full `upgrade --dry-run` boots the sequence (107 steps) and validates
the upgrade-aware decorator references; both 2-13 sync commands verified
end to end against real drift and re-run idempotently.
- Schema verified by live introspection after the input-alias restore:
all four input/filter members match main, output deprecations intact;
frontend metadata types and `twenty-client-sdk` schema regenerated from
the running server.
- Read-only-related and touched jest suites pass on both packages;
typecheck and lint pass on `twenty-server` and `twenty-front`.
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-0f3e04cb-b04a-40be-8330-5609c4538e8a"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-0f3e04cb-b04a-40be-8330-5609c4538e8a"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21504?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: Cursor Agent <cursoragent@cursor.com>
2026-06-13 07:10:22 +02:00
Charles Bochet 577b22df46 fix(upgrade): invalidate upgrade-status cache on command end (#21497)
## Problem

The "Twenty / Upgrade Status" Grafana dashboard shows stale workspace
counts (e.g. `N behind / 0 up-to-date` while the instance reads
`UP_TO_DATE`) that disagree with `command:prod upgrade:status`. The CLI
is correct; the dashboard lags, sometimes for the full hour.

## Root cause

The dashboard is fed by the `twenty_upgrade_workspaces_*` gauges, which
read their workspace counts from a Redis snapshot
(`UpgradeStatusCacheService`). That snapshot is only invalidated
**per-command, inside the runners' `finally` blocks**. Two gaps:

1. An instance command that is already applied returns **before** its
invalidation runs (`isAlreadyCompleted` early-return in
`InstanceCommandRunnerService`). So a plain **redeploy** — which changes
the deployed upgrade sequence, and thus the "behind" answer, without
executing any command — never refreshes the snapshot. This is most
visible on an instance-only release.
2. The snapshot then stays frozen until its 60-minute TTL, while the CLI
reads live and disagrees.

"Behind" is derived from the deployed sequence, not just the ledger, so
the correct answer changes on events (deploys) that run no command —
which is exactly why per-command invalidation isn't enough on its own.

## Fix

Invalidate the upgrade-status cache **once, unconditionally, at the end
of both upgrade entrypoints** — `run-instance-commands` (the
deploy/migrate step) and `upgrade` — in a `finally`. Every run,
including a no-op redeploy where all commands are already applied, now
clears the snapshot, so the next gauge scrape recomputes against the
current sequence. Best-effort (failures are logged, never block the
command). The existing per-command invalidation is kept for mid-run
progress.

This keeps the read path untouched.

## Reproduction + verification (live, local)

Served twenty-server (`NODE_PORT=4000`, `METER_DRIVER=prometheus`)
against the seeded DB, whose latest version `2.12.0` is instance-only.

1. Froze the gauge at `behind 4 / up_to_date 0` while the DB was brought
up-to-date (snapshot not invalidated) — reproduced the dashboard/CLI
divergence.
2. Ran the **patched** `run-instance-commands --force`. Every step
logged `already executed, skipping` — and the `finally` still deleted
the Redis snapshot.
3. On the next recompute the gauge self-healed to `instance_health 1,
behind 0, up_to_date 4`, matching the live CLI.

With the old code the snapshot stayed frozen at `behind 4` until the
TTL.
2026-06-12 16:01:59 +00:00
Weiko cfb9772179 feat(server): convert view to overridable entity (#21436)
## Context

Every entity created as a side effect of object creation must support
the overridable pattern (`isActive` + `overrides` + override routing)
before we can re-own side effects to their true application. Starting
with View.

viewField, viewFieldGroup, pageLayoutTab and pageLayoutWidget already
extend `OverridableEntity`. This PR brings `view` to the same pattern.

## What this does

- `ViewEntity` now extends `OverridableEntity<ViewOverrides>` (adds
`isActive` boolean + `overrides` jsonb). All editable view properties
are overridable; the 3 fieldMetadata foreign keys are converted to/from
universal identifiers like viewField's `viewFieldGroupId`.
- **Update**: mutations on a view not owned by the caller (e.g. standard
views like "All Companies") are written into `overrides` instead of
mutating the row. Reads merge overrides in
the DTO.
- **Delete/destroy**: views not owned by the caller are deactivated
(`isActive = false`) instead of deleted.
~~- **INDEX invariant**: `key = INDEX` views can only be created via
object-creation side effect. The API now rejects creating, deleting or
destroying INDEX views (object-deletion cascade is unaffected). This was
not really needed for this migration but was flagged during
implementation.~~
- **Front**: views with `isActive = false` are filtered out of the views
selector.
- Fast instance command adds the two columns
(`2-12-instance-command-fast-...-view-overridable-entity.ts`).

## Notes

- Custom (caller-owned) views behave exactly as before: direct updates,
soft delete.
- View-group side effects (kanban groups) are computed on the
override-merged view so overridden `mainGroupByFieldMetadataId` works.
2026-06-11 16:00:52 +00:00
Weiko 9c66975520 isCustom deprecation for Objects and Fields (#21228)
## Context

`isCustom` was a legacy denormalized boolean on `ObjectMetadataEntity`
and `FieldMetadataEntity`.
Now that every metadata row carries `applicationId` (via
`SyncableEntity`), "is this custom" is fully derivable, and the stored
boolean was a redundant second source of truth that could drift.

The real meaning of `isCustom` is **"the owning application is not the
twenty-standard application"** — i.e. `!belongsToTwentyStandardApp`.
Note this is *not* "belongs to the workspace custom app" as I initially
thought: third-party-application
objects/fields are custom too. 
The standard application has a globally stable `universalIdentifier`, so
the value derives with no per-workspace lookup.

## Changed
## `isCustom` checks — before → after

`isCustom` is no longer a stored column. The table below lists every
site that branched on it and how it resolves now. The unifying rule:
`isCustom ≡
!isTwentyStandardApplicationUniversalIdentifier(applicationUniversalIdentifier)`.

### Server — behavioural checks

| Location | Purpose | Before | Now |
|---|---|---|---|
| `utils/compute-object-target-table.util.ts` | Physical table name `_`
prefix | `computeTableName(nameSingular, objectMetadata.isCustom)` |
derives from `applicationUniversalIdentifier` (single source for all
table-name callers) |
| `twenty-orm/factories/entity-schema.factory.ts` +
`…/entity-schema-metadata.type.ts` | ORM table name (hot path) |
`object.isCustom` | `object.applicationId !== standardApplicationId`
(computed in `buildEntitySchemaMetadataMaps`) |
|
`twenty-orm/repository/workspace-{delete,soft-delete,update}-query-builder.ts`
| Table name for mutations | `computeTableName(nameSingular,
objectMetadata.isCustom)` | `computeObjectTargetTable(objectMetadata)` |
| `index-metadata/utils/generate-deterministic-index-name-v2.ts` | Index
name hash (must stay bit-identical) | `flatObjectMetadata.isCustom` |
derives from `applicationUniversalIdentifier` |
| `object-metadata/object-record-count.service.ts` | Table name for
record count | `computeTableName(nameSingular, isCustom)` |
`computeObjectTargetTable(flatObjectMetadata)` |
|
`workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts`
| Match seed config by table name | `computeTableName(item.nameSingular,
item.isCustom)` | `computeObjectTargetTable(item)` |
| `commands/workspace-export/workspace-export.service.ts` +
`…/utils/generate-workspace-schema-ddl.util.ts` | Export table name (raw
entity) | `objectMetadata.isCustom` |
`!isTwentyStandard…(objectMetadata.application?.universalIdentifier)` |
|
`flat-field-metadata/services/flat-field-metadata-type-validator.service.ts`
| Block users creating reserved field types |
`args.flatEntityToValidate.isCustom` |
`!args.flatEntityToValidate.isSystem` |
| `api/common/.../common-create-many-query-runner.service.ts` | Don't
let client overwrite system `createdBy` |
`createdByFieldMetadata.isCustom === false` |
`createdByFieldMetadata.isSystem === true` |
|
`field-metadata/utils/resolve-field-metadata-standard-override.util.ts`
| Skip i18n/overrides for custom fields | `if (fieldMetadata.isCustom)
return raw` | **removed** — falls through on
`isDefined(standardOverrides)` |
|
`object-metadata/utils/resolve-object-metadata-standard-override.util.ts`
| Skip i18n/overrides for custom objects | `if (objectMetadata.isCustom)
return raw` | **removed** — same fall-through |
|
`command-menu-item/utils/build-navigation-interpolation-context.util.ts`
| Override context for nav labels | passed `isCustom` into resolver |
dropped (resolver no longer needs it) |
| `api/common/.../data-arg-processor.service.ts` | `isCustom` for
record-position table name | `flatObjectMetadata.isCustom` | derives
from `applicationUniversalIdentifier` |
| `metadata-modules/minimal-metadata/minimal-metadata.service.ts` |
Minimal DTO + override context | `flatObjectMetadata.isCustom` | derives
from `applicationUniversalIdentifier` |
|
`commands/upgrade-version-command/1-23/…backfill-record-page-layouts.command.ts`
| Filter to custom objects | `objectMetadata.isCustom` |
`!isTwentyStandard…(applicationUniversalIdentifier)` |

### Server — DTO / API population

| Location | Before | Now |
|---|---|---|
|
`flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util.ts`
| passthrough `isCustom` | derives from `applicationUniversalIdentifier`
|
|
`flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util.ts`
| passthrough `isCustom` | derives from `applicationUniversalIdentifier`
|
|
`object-metadata/utils/from-object-metadata-entity-to-object-metadata-dto.util.ts`
(REST) | `entity.isCustom` | `entity.applicationId !==
standardApplicationId` |
|
`field-metadata/utils/from-field-metadata-entity-to-field-metadata-dto.util.ts`
(REST) | `entity.isCustom` | `entity.applicationId !==
standardApplicationId` |
| `dataloaders/dataloader.service.ts` | passed
`flatFieldMetadata.isCustom` into override resolver | dropped (resolver
no longer needs it) |

> REST controllers (`object-metadata.controller.ts`,
`field-metadata.controller.ts`) resolve `standardApplicationId` once per
request from the cached `flatApplicationMaps`.

### Frontend

| Location | Purpose | Before | Now |
|---|---|---|---|
| `settings/.../SettingsObjectFieldDisabledActionDropdown.tsx` | Whether
an inactive field is deletable | `isDeletable = isCustomField` |
`isDeletable = isCustomField && !isSystemField` |

### Unchanged (out of scope)

`isCustom` on `IndexMetadata` / `View` / `Skill` / `Agent` and their
guards still read the persisted column.

Breaking change is on the isCustom filter on field and object APIs, this
is never used in the FE and unlikely used by external consumers
2026-06-09 13:57:19 +00:00
Félix Malfait dfb3da1f8d feat(emailing-domain): add LOG driver for local development (#21286)
## What

Adds a `LOG` driver to the emailing-domain feature, selected via a new
`EMAILING_DOMAIN_DRIVER` config variable (defaults to `AWS_SES`, so
production behavior is unchanged).

The LOG driver:
- resolves domains to `VERIFIED` instantly (no DNS / SES setup)
- logs each `sendEmail` and returns a synthetic `messageId` instead of
calling SES

It also dev-seeds a pre-verified domain per workspace
(`<workspaceId>.dev.twenty.local`) so the feature works out of the box.

## Why

The emailing-domain feature currently ships only the AWS SES driver, so
the verify → send flow can't be exercised locally (or in CI) without
real AWS credentials. This unblocks local development and review of
anything built on emailing domains.

## Usage

```
EMAILING_DOMAIN_DRIVER=LOG
```

The seeded `*.dev.twenty.local` domain is already verified; sends are
logged (`[log-driver] sendEmail ...`).

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-06-08 14:59:11 +02:00
claude[bot] dfe0b5bfd4 chore(server): rename TypeORM migrations dir to legacy-typeorm-migrations-do-not-add (#21295)
## What

Renames the historical TypeORM migrations directory so it's obvious at a
glance the path is frozen.

- `packages/twenty-server/src/database/typeorm/core/migrations/common/`
→ `…/typeorm/core/legacy-typeorm-migrations-do-not-add/common/`
- `packages/twenty-server/src/database/typeorm/core/migrations/billing/`
→ `…/typeorm/core/legacy-typeorm-migrations-do-not-add/billing/`
- `packages/twenty-server/src/database/typeorm/core/migrations/utils/` —
**left in place** (those SQL helpers are still imported by current
instance/workspace commands, see e.g.
`1-21-workspace-command-1775500002000-add-global-key-value-pair-unique-index.command.ts`)
- Updates `core.datasource.ts` globs to the new path and adds an inline
comment explaining the dir is frozen
- Adds a `README.md` at the new dir pointing readers at
`UPGRADE_COMMANDS.md` and the active `upgrade-version-command/` tree

## Why

The TypeORM migration system was replaced by fast/slow instance commands
+ workspace commands (PR #19356), but `typeorm/core/migrations/common/`
still looked structurally identical to an active migrations folder, with
new files merging in as recently as last week. New contributors — and AI
agents — kept inferring it was the active path and adding TypeORM
`MigrationInterface` files. See #21286 for the most recent instance.

This is recommendation **1b** from #21286
(`https://github.com/twentyhq/twenty/pull/21286#discussion_r3369356792`).
A renamed folder is the single strongest signal — no one instinctively
adds to a `do-not-add` directory.

## Notes

- Imports from `src/database/typeorm/core/migrations/utils/…` keep
working because `utils/` did not move.
- No behavior change at runtime: TypeORM loads the same files via
`_typeorm_migrations`, just from the new path.

## Test plan

- [ ] CI green
- [ ] `nx build twenty-server` succeeds
- [ ] Fresh `nx database:reset twenty-server` from a clean DB still
replays the legacy TypeORM migrations (i.e. `_typeorm_migrations` rows
get inserted at boot/init)
- [ ] Spot-check that an instance command that imports from
`migrations/utils/` still resolves at build time (e.g.
`1-21-workspace-command-1775500002000-add-global-key-value-pair-unique-index.command.ts`)

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

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
2026-06-07 14:54:36 +02:00
Félix Malfait 91f2f08995 feat(server): unify workspace-event ingestion behind one EventSink pipeline (#21197)
## Why

The five event-log streams (`workspaceEvent`, `pageview`, `objectEvent`,
`usageEvent`, `applicationLog`) each wrote to ClickHouse through their
own fire-and-forget writer (`AuditService`, `UsageEventWriterService`,
and the `application-logs` driver), with the per-type knowledge (table
names, normalization, access rules) spread across several modules. Three
of them reimplemented the same ClickHouse insert, and the read side, the
live stream, and the producers lived in different modules under two
different names.

This consolidates them into one `core-modules/event-logs/` subsystem
(emit, write, live, read), with the per-type config in a single registry
so adding an event type is roughly one file.

The base Logs settings tab and free application logs shipped separately
in #21180 (merged). This PR adds the unified backend, the registry, and
the viewer's live mode and entitlement gating.

## Pipeline

```mermaid
flowchart TB
    subgraph PROD["Producers"]
      A["auth, billing, impersonation,<br/>webhook, custom-domain"]
      U["usage listener"]
      F["logic-function executor (app logs)"]
      R["record CRUD (entity events)"]
    end
    EM["EventLogEmitterService<br/>createContext().insert* / dispatch()"]
    EQ(["entityEventsToDbQueue<br/>(existing, shared with timeline)"])
    CIE["CreateEventLogFromInternalEvent"]
    SINK["WorkspaceEventSinkService.ingest()"]
    C1["ClickHouseEventSink"]
    C2["ConsoleEventSink"]
    LIVE["EventLogLiveService.publishWatched()<br/>(presence-gated)"]
    CH[("ClickHouse, 5 tables, async_insert")]
    CHAN(["WORKSPACE_EVENTS_CHANNEL"])
    RS["EventLogsService (registry-driven read)"]
    LR["EventLogsLiveResolver"]
    UI["Settings > Logs"]

    A --> EM
    U --> EM
    F --> EM
    EM -->|direct| SINK
    R --> EQ --> CIE -->|ingest| SINK
    SINK --> C1 --> CH
    SINK --> C2
    SINK --> LIVE -.->|if a viewer is watching| CHAN --> LR --> UI
    CH --> RS --> UI
```

## What it does

- Producers call `EventLogEmitterService.createContext().insert*()`,
which builds a typed `WorkspaceEventEnvelope` and writes it through
`WorkspaceEventSinkService` to the configured sinks (ClickHouse,
Console) plus a presence-gated live fan-out. Record/CRUD events reach
the same sink through the existing `entityEventsToDbQueue`. There is no
dedicated queue; ClickHouse `async_insert` batches server-side. Writes
are best-effort, as on main today.
- `EVENT_LOG_TYPES[table]` is the per-type source of truth: the
ClickHouse table, the required entitlement, the free-text filter column,
and the row-to-GraphQL mapping. Read row shapes derive from the write
rows.
- Four modules along their dependency boundaries:
`EventLogEmitterModule` (producer API), `EventLogIngestionModule` (sink
layer), `EventLogLiveModule` (fan-out), and `EventLogsViewerModule` (the
entitlement-gated GraphQL read, which is where
billing/enterprise/permissions stay so producers stay light).
- Logs viewer: per-table columns, filters (text, date, record), live
mode, and an upgrade card that points to Billing on Cloud or the Admin
Panel on self-hosted. Application logs are free on every plan; the other
four require the `AUDIT_LOGS` entitlement (with a `NO_ENTITLEMENT`
fallback to the upgrade card).
- Renames `AuditService` to `EventLogEmitterService`, and the generic
`Monitoring` event to a typed `Impersonation` event (`level` +
`action`).
- Removes `UsageEventWriterService`, the `application-logs`
driver/module, and `AuditService`'s direct inserts.

## Durability

Writes are best-effort, the same as main today (the old writers were
fire-and-forget). A dedicated queue was tried mid-PR and removed:
`async_insert` already batches server-side, so the queue only added
durability, which isn't a requirement right now. The `EventSink` seam
keeps a durable transport (e.g. a Redis-Streams buffer) easy to add
later without touching producers.

## Out of scope

S3 peer sink (seam only), Postgres or any second read path,
`ReplicatedMergeTree`, ClickHouse table-schema changes, and the
record-data `EVENT_STREAM_CHANNEL` (unchanged, separate concern).

## Testing

Unit tests cover the registry definitions and row normalization, the
entitlement gating, the envelope builders, and the producers.
Integration tests cover the write paths (record create produces an
`objectEvent`; the track mutation produces a `workspaceEvent`) and the
read/query path across all five tables. Verified with typecheck, lint, a
server boot, and GraphQL/SDK codegen.
2026-06-06 10:32:56 +02:00
nitin f72898063a upgrade command patch - warn instead of throw when no calendarEvent object found in the workspace (#21258) 2026-06-05 16:32:16 +02:00
nitin e485b679ea [Call Recording] Add standard object (#21158)
Adds **Call Recording** as a first-class standard object (Twenty's
flat-metadata
standard-object system), with a hidden junction to calendar events and a
backfill
command for existing workspaces. Everything is gated behind the
`IS_CALL_RECORDING_ENABLED` feature flag.

### What's included
- **`CallRecording`**: audio/video files, transcript, status, recording
policy,
timing, external bot/recording ids. Label identifier is
`meetingOccurrenceKey`.
- **`CallRecordingCalendarEventAssociation`**: hidden junction linking a
recording
to a calendar event (dedupes one bot to many subscribers of the same
meeting).
- Full metadata graph via the flat-metadata builders: fields, indexes,
views,
  view fields/groups, record page layout, and navigation items.
- **Metadata-only reverse relation** on `CalendarEvent`: present in
standard
metadata, omitted from the TS entity class to avoid expanding recursive
  nested-insert types.
- **Upgrade command (2.9.0)** backfilling active/suspended workspaces:
  - Creates the full graph; idempotent (skips when it already exists).
- Moves a colliding custom `callRecording` object aside to
`callRecordingOld`
    (numeric suffix if that name is also taken).
- Navigation items (commands) are flag-gated by `universalIdentifier`,
so a custom object
    reusing the name is never gated.

### QA
Run locally against existing workspaces (with and without a name
collision) and a
freshly created workspace:
- [x] Backfill, collision: custom `callRecording` renamed to
`callRecordingOld`;
  standard graph created.
- [x] Backfill, no collision: standard graph created; unrelated custom
object untouched.
- [x] Idempotent: re-run is a no-op, with no duplicate metadata and
counts unchanged.
- [x] New workspace via `init()` produces an identical graph to the
backfill
  (`universalIdentifier` set-diff = 0).
- [x] Label identifier (`meetingOccurrenceKey`) holds position 0 in
non-widget views.
- [x] Nav items gated behind the feature flag; collision-renamed
object's nav
  expression re-pointed to its new name.
- [x] Unit tests cover collision name resolution and nav-gating logic.
2026-06-05 13:02:50 +00:00
Félix Malfait c3dd6b25a6 fix: use canonical oxlint rule id in lint-disable directives (#21253)
## What

Many `oxlint-disable` / `eslint-disable` directives across the repo
carry a corrupted rule id — `@typescripttypescript/<rule>` — most likely
a find-and-replace accident that mangled the eslint-era
`@typescript-eslint/` prefix.

oxlint matches disable directives **loosely by rule name**, so these
still suppress in practice (not a silent no-op), but the id is malformed
and misleading.

## Change

Replace them with the **canonical oxlint id** `typescript/<rule>` —
matching the plugin name and rule keys declared in `.oxlintrc.json` —
**127 files, 262 directives**:

| rule | count |
| --- | ----- |
| `typescript/no-explicit-any` | 250 |
| `typescript/ban-ts-comment` | 6 |
| `typescript/no-misused-promises` | 4 |
| `typescript/no-empty-object-type` | 2 |

- `twenty-server`: 122 files
- `twenty-front`: 5 files

Comment-only — no code or runtime changes.

## Verification

`oxlint --type-aware -c .oxlintrc.json` reports **0 warnings / 0
errors** for both `twenty-server` and `twenty-front`. Every changed line
is exactly the id correction inside a disable directive (262 insertions
/ 262 deletions, no collateral edits).

> Addresses the cubic review, which flagged that the canonical oxlint id
is `typescript/...` (no `@`). Worth noting the original
`@typescripttypescript/` was not actually a silent no-op — oxlint
matches these directives loosely by rule name — but `typescript/` is the
correct, config-aligned id.
2026-06-05 13:52:32 +02:00
Raphaël Bosi 41d5d80a65 Migrate Company and Person standard fields in preparation for the enrichment app (#21171)
# Migrate Company and Person standard fields in preparation for the
enrichment app

## Why

Our standard `Person`/`Company` objects accumulated fields that aren't
generic to every
business, while missing a more universal revenue field that essentially
every CRM ships.
This PR makes the **Standard application** hold a tighter, more
universal set of fields,
and sets the stage for a follow-up PR that introduces a **People Data
Labs enrichment app**
to populate them.

## What changes

### Standard fields

**Demoted (Standard → Workspace Custom application)** — not generic
enough to ship as standard:

| Object  | Field                          | Type     |
| ------- | ------------------------------ | -------- |
| Company | annualRecurringRevenue (ARR)   | CURRENCY |
| Company | employees                      | NUMBER   |
| Company | idealCustomerProfile (ICP)     | BOOLEAN  |
| Company | xLink (X/Twitter)              | LINKS    |
| Person  | xLink (X/Twitter)              | LINKS    |
| Person  | city                           | TEXT     |

**Added (new generic Standard field)** — present in
Salesforce/HubSpot/Zoho, PDL-populatable:

| Object | Field | Type |
| ------- | ------------- |
-------------------------------------------------------- |
| Company | annualRevenue | CURRENCY (generic total revenue; replaces
the niche ARR) |

### Behavior by workspace

* **New workspaces:** demoted fields are gone; `annualRevenue` is
**active**.
* **Existing workspaces:** demoted fields are **preserved as active
custom fields, data intact**;
`annualRevenue` is created **inactive (opt-in)** with its column ready,
so a later activation
  is a metadata-only toggle.

### Upgrade commands (v2.9)

Three idempotent, per-workspace commands, run in timestamp order:

1. **`upgrade:2-9:move-demoted-standard-fields-to-custom-application`**
(1799000040000) —
re-owns the 6 demoted fields to the workspace custom application
(`isCustom = true`,
new `applicationId` + fresh `universalIdentifier`), keeping their data
and active state.
2. **`upgrade:2-9:rename-conflicting-custom-fields`** (1799000045000) —
if a workspace already
has a *custom* field named `annualRevenue`, renames it to
`annualRevenueCustom`
(data preserved via column rename) so the standard field can be added.
Skips non-custom matches.
3. **`upgrade:2-9:add-inactive-generic-standard-fields`**
(1799000050000) — creates
`Company.annualRevenue` on existing workspaces as inactive, guarded to
skip workspaces
   missing the target object or where the name is still taken.

**Failure model:** the workspace iterator isolates failures per
workspace (one workspace failing
never affects others); within a workspace the runner records per-command
status and resumes on the
next run, and every command is idempotent, so partial runs self-heal.

### Supporting changes

* **Field-option color palette:** widened the `TagColor` union
(`twenty-shared` `FieldMetadataOptions`
+ the field-metadata `options.input` DTO) from 10 colors to the full
theme palette, benefiting any
  future SELECT/MULTI_SELECT field.
* **Dev seeder:**
* The default "Annual Recurring Revenue" dashboard widget now points at
the generic
    `annualRevenue` field (renamed to "Annual Revenue").
* Removed the "Companies by Size (Stacked by City)" widget (relied on
the demoted `employees`).
* `employees` is dropped from company data seeds and re-added as a
**custom** field seed, so dev
workspaces still get an `employees` column matching the demoted
behavior.

### Cleanup

Front-end record types (`Company.ts`/`Person.ts`), the
`getDisplayNameFromParticipant` test mock,
metadata integration specs, the Zapier `crud_record` test, and the
regenerated
`get-standard-object-metadata-related-entity-ids` snapshot.

## ⚠️ Breaking change (intentional)

Removes standard fields `Company.annualRecurringRevenue`,
`Company.employees`,
`Company.idealCustomerProfile`, `Company.xLink`, `Person.xLink`, and
`Person.city` from the core
GraphQL schema (replaced by `Company.annualRevenue`).

This is why the breaking-changes check reports a large number of
removals — `graphql-inspector`
flags any removed object field plus its derived
aggregate/order-by/filter/update types.

**Mitigation:** the
`upgrade:2-9:move-demoted-standard-fields-to-custom-application` command
re-owns these fields as custom fields per workspace, preserving their
name and data, so existing
tenants keep working. New workspaces won't have them.
2026-06-04 15:54:04 +00:00
Paul Rastoin 3d49642d12 [AUDIT] Run knip over twenty-server (#21159)
# Introduction
Run [knip](https://knip.dev/) over twenty-server
Used config:
```json
{
  "$schema": "https://unpkg.com/knip@5/schema.json",
  "workspaces": {
    "packages/twenty-server": {
      "entry": [
        "src/main.ts",
        "src/command/command.ts",
        "src/queue-worker/queue-worker.ts",
        "src/database/scripts/setup-db.ts",
        "src/database/scripts/truncate-db.ts",
        "src/database/clickHouse/migrations/run-migrations.ts",
        "src/database/clickHouse/seeds/run-seeds.ts",
        "src/instrument.ts",
        "lingui.config.ts",
        "test/integration/graphql/codegen/index.ts",
        "test/integration/utils/setup-test.ts",
        "test/integration/utils/teardown-test.ts",
        "scripts/**/*.ts",
        "**/*.spec.ts",
        "**/*.integration-spec.ts"
      ],
      "project": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"],
      "ignore": [
        "src/database/typeorm/**/migrations/**",
        "src/database/typeorm/**/*.entity.ts",
        "**/*.workspace-entity.ts",
        "**/logic-function-resource/constants/seed-project/**"
      ],
      "ignoreDependencies": ["@types/psl", "@types/aws-lambda"],
      "ignoreBinaries": ["nest", "lingui", "typeorm"]
    }
  }
}
```
2026-06-04 10:05:22 +00:00
nitin 475cd9e7fe fix(upgrade): resequence workflow step logs command (#21213) 2026-06-04 08:27:43 +00:00
Marie 4b15b949f3 Provide additional logsobservability to workflow runs (per node) (#21142)
Surfaces per-step "Logs" tabs in the workflow run side panel so users
can see what each step actually did (model + tokens + tool calls for AI,
console output for serverless functions, request/response for HTTP,
recipients/body for Email).

<img width="546" height="501" alt="ai_agent_without_websearch"
src="https://github.com/user-attachments/assets/c6ca3518-9489-4484-a570-3d0569ff3b03"
/>

## Storage

- New `stepLogs` JSONB column on the `workflowRun` workspace entity,
typed as `Record<string, WorkflowRunStepLog>` (keyed by step id).
- Schema lives in `twenty-shared`: `workflowRunStepLogSchema` with a
discriminated `details.type` union for `AI_AGENT | CODE | HTTP_REQUEST |
EMAIL` — frontends and backends consume the same Zod-inferred type.
- Field is added to existing workspaces via a workspace upgrade command
(`2-9 add-workflow-run-step-logs-field`); the standard-object metadata
declares it for new workspaces.
- Writes happen atomically per step in
`WorkflowRunStepLogWorkspaceService.setStepLog` using `jsonb_set`. That
lets concurrent steps in the same run write their own keys without
contending with the existing lock around `workflowRun.state`.
- Per-step payload is hard-capped at 256 KB; anything larger is dropped
with a `logger.warn`, so a pathological tool call can never bloat a row.
See below for more information.

## How logs are produced

**Aalmost everything was already being collected; this PR mostly
persists and renders it.**

- **AI agent** — `AgentAsyncExecutorService` already tracked token
usage, model id, native web-search count, and the AI SDK's `steps[]`. We
map those into the log via `mapAiStepsToToolCallLogs` (`searchVector`
stripped from record outputs, per-call input/output capped at 32/64 KB,
max 200 tool calls per step). The only new measurement is a wall-clock
`durationMs` taken around `executeAgent`, and we now fold native
web-search cost into the displayed `totalCostInDollars` (it was already
billed, just not shown).
- **Code / serverless function** — reuses the `console.log` output the
function runner already returns (`logsByLevel`);
`build-code-step-log.util` only repackages it.
- **HTTP request** — built from the action's existing input/output via
`build-http-request-step-log.util`. No new signals collected.
- **Email (send / draft)** — added `sanitizedHtmlBody` + `plainTextBody`
to the existing tool outputs (a small additive change), then
`build-email-step-log.util` consumes them.

No additional AI inference or external calls are made for logging — the
cost is a small CPU overhead per step plus the JSONB write.

## Security

The log surface intentionally shows whatever the workflow touched, which
made redaction and sanitization the main design concern.

- **HTTP — secrets in headers**: existing `SENSITIVE_HEADER_NAMES` set
(Authorization, Cookie, …) replaced with `[redacted]` in both request
and response.
- **HTTP — secrets in URLs**: `SENSITIVE_URL_PARAM_NAMES` (e.g.
`api_key`, `token`, `access_token`) replaced in the query string via
`URL`-based parsing.
- **HTTP — secrets in bodies**: `SENSITIVE_BODY_KEY_REGEX` deep-walks
JSON request/response bodies (object input or stringified JSON) and
redacts matching keys. Applied to the `error` field too, since
transport-layer errors sometimes embed structured payloads.
- **Email — XSS risk in body preview**: tool outputs now expose a
server-side `sanitizedHtmlBody`; the log builder prefers it over the raw
user-authored `input.body`, with `plainTextBody` as a second fallback.
The original raw body is only used if sanitization didn't happen (e.g.
tool failed before composing).
- **AI — internal/noisy data**: `searchVector` (Postgres tsvector
strings) is stripped from record outputs returned by Twenty tools to
avoid leaking internal full-text-search payloads.
- **DB bloat / runaway agents**: 256 KB per-step cap + 32 KB / 64 KB
per-tool-call input/output cap + 200 tool calls per step.

<img width="547" height="307" alt="logic_function"
src="https://github.com/user-attachments/assets/dd4a3d16-67f2-434b-95b3-bdcaf9ed053d"
/>

## More details on Log size & truncation

Logs are stored in `workflowRun.stepLogs` (JSONB), keyed by `stepId`.

### Per-step cap

Each step's log is hard-capped at **256 KB** (`MAX_STEP_LOG_BYTES` in
`WorkflowRunStepLogWorkspaceService.setStepLog`).

For ~99% of workflows this is roomy — typical real-world sizes:
- Code / serverless function: 1–20 KB
- HTTP request: 5–70 KB
- Email: 5–30 KB
- AI agent (a handful of tool calls): 5–50 KB

### Two layers of bounding

1. **Per-field truncation** in each builder (before writing):
   - **Code**: ≤ 500 entries, ≤ 4 KB per message, ≤ 8 KB stack trace
   - **HTTP**: ≤ 32 KB per body (request + response), UTF-8 byte-aware
   - **Email**: ≤ 8 KB body preview, UTF-8 byte-aware
- **AI agent**: ≤ 32 KB tool input, ≤ 64 KB tool output, ≤ 200 tool
calls/step

2. **Global per-step safety net** at write time: if the assembled
`stepLog` still exceeds 256 KB, the write is **dropped entirely** with a
`logger.warn`. The workflow itself keeps running unaffected.

### What this means in practice

- **Safe**: workflow execution, step results, downstream steps — never
blocked by log size.
- **Safe**: iterators (each iteration overwrites the previous log for
that `stepId`, so they can't accumulate).
- **Safe**: step retries (same `stepId` is overwritten, not appended).
- **Possible**: an AI agent step with many large tool outputs (e.g., 50+
heavy `web_search` calls) can exceed 256 KB → the **entire** step's log
is dropped, side panel shows "No logs were recorded for this step". The
user has no explicit signal that the log was dropped due to size (only
server-side warn).
- **Possible** (theoretical): a workflow with hundreds of distinct steps
could push the row toward Postgres's internal ~256 MB jsonb limit.
Beyond that, individual `jsonb_set` writes would error and be swallowed
by the action's try/catch — workflow still completes.

### Possible future hardening (not in this PR)

- Replace "drop entire log" with a stub that preserves the summary card
(cost, duration, status) and marks `truncated.reason = 'size_cap'`.
- Surface size-drops in the UI (similar to the existing
`<StyledTruncatedNotice>`).
- Emit a metric so dropped logs are observable in dashboards.
2026-06-03 16:53:47 +00:00
Weiko cc76b7bc50 Fix fields widget new field visibility (#21111)
Fixes https://github.com/twentyhq/twenty/issues/21043

## Context
Newly created fields were never added to FIELDS widgets, regardless of
the "Set fields created in the future as visible" toggle. The widget's
newFieldDefaultVisibility was null on widgets that never explicitly set
it (it was never populated at creation), so the backend skipped them and
no view field was created.

## Implementation
Keep newFieldDefaultVisibility nullable with false (not visible) as the
behavior when not provided.
The FE now reflects that properly and shows "un-toggled" when it's null
(iso with BE behavior).

The fix also ensures the value is explicitly set to true wherever it
should be:
- Set newFieldDefaultVisibility: true at every FIELDS widget creation
path (backend default record-page layout, frontend
createDefaultFieldsWidget + useTemporaryFieldsConfiguration);
- Added a 2-9 workspace upgrade command that backfills true onto
existing standard FIELDS widgets where the value is null.
2026-06-03 15:29:57 +00:00
Charles Bochet 58907b733c feat(logic-function): add LIVE / PREBUILT execution modes (#20873)
## Summary

### Why

1. Sending the code to the lambda (~1Mb usually) is heavy on network and
results to a constant traffic of ~30Mb/s on AWS which results into TB of
network data every month
2. eval(1MB of code) is not that fast, it's heavy on memory and CPU on
lambda side

### High level

Adds two execution modes for logic functions, gated behind the new
`IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED` workspace feature flag (off
everywhere by default):

- **LIVE** (current behavior, preserved bit-for-bit): the compiled
bundle is read from object storage and shipped in every Lambda invoke
payload. Used for fast iteration in the workflow editor / Settings test
runs.
- **PREBUILT** (new): the bundle is installed onto the per-function
Lambda alongside the unified executor, and invocations carry only `{
params, env, handlerName }` — saving JSON payload egress and warm-start
`import()` cost on every call.

### Key design choices

- **Unified Lambda handler** (`constants/executor/index.mjs`) dispatches
at runtime: `event.code` present ? LIVE (write to `/tmp`, dynamic
import) : `import('./prebuilt-logic-function.mjs')`. Both code paths
always coexist on the deployment package, so the same Lambda can serve
either mode without redeploying.
- **Install runs inside the `validateBuildAndRun` migration pipeline**,
not at execute time. `Create/UpdateLogicFunctionActionHandlerService`
calls `driver.installPrebuiltBundle` when `executionMode` flips
LIVE?PREBUILT or `checksum` changes while PREBUILT, gated on
`isBuildUpToDate=true` and a fresh checksum.
- **Strict execute, no reconciliation**:
`LogicFunctionExecutorService.execute` resolves `effectiveExecutionMode`
(caller override > feature flag > entity column). For PREBUILT it asks
the driver `getInstalledBundleChecksum` (Lambda `twenty:bundle-checksum`
tag for AWS, sidecar file locally) and throws
`LOGIC_FUNCTION_PREBUILT_BUNDLE_NOT_INSTALLED` on mismatch.
- **Feature flag gates every side effect**: with the flag off the
executor forces LIVE, the action-handler install hooks bail before AWS,
and workflow activation does not flip the mode. Rollback is just turning
the flag off.

### Lifecycle

- New workflow CODE step ? `LIVE`, no install.
- Workflow activated ? build + activation flips `executionMode=PREBUILT`
? action-handler installs the bundle + sets the Lambda tag.
- Draft from active version ? duplicated logic function reset to `LIVE`.
- App install ? manifest converter sets `PREBUILT`, create-action
handler installs.
- Test runs (`executeOneFromSource`, workflow editor) pass
`executionMode=LIVE` explicitly.

### Observability

`[lambda-timing]` log lines now include `effectiveExecutionMode` and
`payloadBytes`; the action handler logs `install_duration_ms` for each
install.

## Test plan

- [x] `npx nx typecheck twenty-server` ? passes
- [x] `npx oxlint --type-aware` on all changed files ? 0 warnings, 0
errors
- [x] `npx nx test twenty-server` ? 588 suites / 5009 tests pass (no
regressions vs main)
- [x] New unit suite `flat-logic-function-validator.service.spec.ts` ?
9/9
- [x] Existing
`workflow-version-step-operations.workspace-service.spec.ts` ? 8/8
(verified the new token-based DI avoids a circular-import regression)
- [x] Snapshot for
`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY` updated
to include `executionMode`
- [x] Integration suite `logic-function-execution.integration-spec.ts`
extended to assert `executionMode=LIVE` on newly-created functions and
continues to exercise the LIVE happy path
- [ ] Manual staging rollout: flip
`IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED` per workspace, observe
`[lambda-timing]` `payloadBytes` drop + `install_duration_ms`, then ramp
in prod.
2026-06-02 11:14:39 +00:00
Paul Rastoin 989b45db15 Strictly type encryption rotation key site maps constants through entity type derivation (#21085)
# Introduction
Followup https://github.com/twentyhq/twenty/pull/21001

Now that the typeorm entities provide grains over their
`encryptedString` value, we can strictly type the sitemaps of the
encrypted string to rotate in case of encryption key rotation and also
the integration tests tests cases
2026-06-01 15:25:58 +00:00
nitin 13f09d8946 [Dashboards] Remove gauge chart types and code (#20410)
Follow-up cleanup to #20172.
2026-05-29 11:08:50 +02:00
martmull 961745e9ba Fix latency spike on application lookup (#21042)
Fixes
https://discord.com/channels/1130383047699738754/1509645089062781058

Caching application entities to improve authentication latency
2026-05-29 11:06:36 +02:00
Félix Malfait f4ead89956 refactor(twenty-orm): migrate 23 grandfathered entities to WorkspaceScopedRepository (#20987)
## Summary

Follow-up to #20953. Migrates 23 of the 30 entities that were left in
`WORKSPACE_SCOPED_EXEMPTIONS` last time, so the lint rule's
workspaceId-enforcement default now covers most of the core/metadata
schema.

### Migrated (23 entities, 88 files, 22 commits)

| Family | Entities |
|---|---|
| Trivial caches | `NavigationMenuItem`, `Skill`, `DataSource`,
`Webhook`, `CommandMenuItem`, `IndexMetadata` |
| Views | `View`, `ViewField`, `ViewFieldGroup`, `ViewFilter`,
`ViewFilterGroup`, `ViewGroup`, `ViewSort` |
| Layouts | `PageLayout`, `PageLayoutTab`, `PageLayoutWidget` |
| Roles & permissions | `Role`, `RoleTarget`, `PermissionFlag`,
`ObjectPermission`, `FieldPermission`, `RowLevelPermissionPredicate`,
`RowLevelPermissionPredicateGroup` |

For each entity: swap `@InjectRepository(X)` →
`@InjectWorkspaceScopedRepository(X)` (and the field type →
`WorkspaceScopedRepository<X>`); rewrite every call site to pass
`workspaceId` as the first arg (stripped from `where`/criteria — the
wrapper throws if you include it now); register
`provideWorkspaceScopedRepository(X)` in every owning NestJS module;
update affected spec providers to
`getWorkspaceScopedRepositoryToken(X)`.

### Rule update

- `ApplicationRegistrationVariableEntity` was misclassified — moved to
`STRUCTURAL_EXEMPTIONS` (no `workspaceId` column; it's keyed on
`applicationRegistrationId` at the instance level).
- 22 of the 23 migrated entities removed from
`WORKSPACE_SCOPED_EXEMPTIONS` entirely (zero remaining raw
`@InjectRepository` sites).
- `RoleTargetEntity` also removed; one call site in
`user-workspace.service.ts` keeps a raw injection with an
`eslint-disable` + reason because `softRemove(...)` is not on the
wrapper API yet (the migration would require threading `workspaceId`
through `deleteUserWorkspace`'s three callers).

### Still exempted (7 entities, follow-up PRs)

| Entity | Why deferred |
|---|---|
| `ApplicationEntity` | ~50 sites with several cross-workspace lookups
by id (auth, OAuth, file-storage, cleanup) |
| `CalendarChannelEntity` / `MessageChannelEntity` | Use
`.increment(...)` (not on wrapper) and
`repository.manager.transaction(...)` — wrapper needs to grow
`.increment` + the transaction sites need `withManager` or dual-inject |
| `FieldMetadataEntity` / `ObjectMetadataEntity` | The metadata services
`extends TypeOrmQueryService<X>` and `super(rawRepo)` — requires
dual-inject or reworking the inheritance |
| `KeyValuePairEntity` | Allows `workspaceId: IsNull()` for
instance-level config; wrapper rejects null |
| `UpgradeMigrationEntity` | Same — instance-level + cross-workspace
ledger |

## Test plan

- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx nx lint twenty-server` — clean (0/0)
- [x] All 10 affected unit specs pass (115 tests) — api-key, agent-role,
permissions, workspace-roles-permissions-cache, view-filter-group,
workflow-version-step-operations, two-factor-authentication (service +
resolver), user-workspace, file
- [ ] Server integration tests in CI
2026-05-28 20:46:21 +02:00
Paul Rastoin ebfaca5b3d EncryptedString PlaintextString branded string types (#21001)
## Summary

closes https://github.com/twentyhq/core-team-issues/issues/2464

Introduces compile-time branded types to distinguish encrypted
ciphertext from plaintext strings, preventing mix-ups like the one fixed
in #20819 — but at the type level rather in addition to the one existing
at runtime.

### Branded string primitives

- Created `EncryptedString` and `PlaintextString` as hard nominal brands
using `z.string().brand(...)`, making them non-assignable to each other
or to raw `string`
- Created `isEncryptedString` type predicate to narrow `string` to
`EncryptedString` based on the `enc:v2:` envelope prefix
- Retyped `SecretEncryptionService`: `encryptVersioned` accepts
`PlaintextString`, `decryptVersioned` returns `PlaintextString`

### Entity typing

- Typed encrypted columns across entities:
`SigningKeyEntity.privateKey`,
`TwoFactorAuthenticationMethodEntity.secret`,
`ApplicationRegistrationVariableEntity.encryptedValue`,
`ApplicationVariableEntity.value`
- Parameterized JSONB types for connected account connection parameters
(`ImapSmtpCaldavParams<Pwd>`) with reusable aliases
`EncryptedImapSmtpCaldavParams` / `DecryptedImapSmtpCaldavParams`
- Typed DTOs (`CreateApplicationRegistrationVariableInput`,
`UpdateApplicationRegistrationVariablePayload`,
`UpdateApplicationVariableEntityInput`) with `PlaintextString`

### ApplicationVariable always-encrypt uniformization

- Retyped `ApplicationVariableEntity.value` to `EncryptedString | ''` —
all values are now encrypted regardless of `isSecret`
- Updated `ApplicationVariableEntityService` to always encrypt on write
and always decrypt on read
- Simplified `UpdateApplicationVariableActionHandlerService` by removing
conditional encrypt/decrypt-on-isSecret-toggle logic
- Added slow instance command (`2.9.0`) to backfill-encrypt existing
`isSecret=false` plaintext rows and tighten the `CHECK` constraint

### ConfigStorageService refactor

- Split `convertAndSecureValue` (which used `any`) into two well-typed
methods: `convertAndDecrypt` and `convertAndEncrypt`
- Introduced `isSensitiveStringValue` type predicate to narrow values
before encryption/decryption

### What's next
- Typeorm entity derivation to strictly type sitemap configuration as
code + handler logic for encryption rotation
- https://github.com/twentyhq/core-team-issues/issues/2465
2026-05-28 17:41:16 +02:00
neo773 c5606212f2 Ses outbound followup (#20610)
This pull request unifies outbound with inbound under the new feature
and the new email groups feature.

These are workspace level shared inboxes that are shared between all
workspace members.

outbound sending with SES works, we only listen for tenant status
events, rest is managed by AWS

PR refactors old code and webhook to be split for outbound and inbound
for proper separation


| Area | Change |
|---|---|
| AWS SES driver | Split into `AwsSesRegisterDomainService` (tenant +
identity + DKIM + MAIL FROM + configuration-set + EventBridge dest +
contact list) and `AwsSesSendEmailService` (SendEmail). |
| Reputation webhook | New `/webhooks/messaging/ses/outbound` route. SES
→ EventBridge (`Sending Status Enabled/Disabled` on default bus) → SNS →
router → `SesOutboundSendingStateHandlerService` updates
`emailing_domain.tenantStatus`. |
| Inbound webhook | Refactored into `SesInboundWebhookRouterService` +
`SesInboundMailHandlerService`. Shared `SnsSignatureVerifierService` +
`SnsSubscriptionConfirmerService` across both routes. |
| Global uniqueness | New migration + instance command:
`emailing_domain.domain` is now globally unique (one tenant per domain
across workspaces). |
| Tenant status | New `emailing_domain.tenantStatus` column (`ACTIVE` /
`PAUSED`) + `EmailingDomainTenantStatusService`. |
| Send-email mutation | New `sendEmailViaDomain` GraphQL mutation +
DTOs. |
| Cleanup | `EmailingDomainWorkspaceCleanupJob` wired into
`WorkspaceService.deleteWorkspace` — tears down SES tenant association +
identity on workspace delete. |
| Settings UI | Rewritten around reusable `SettingsTableListSection`.
"Email Group" → "Email Handle" rename. New cells for
status/source/forwarding. Outbound domains surfaced on workspace
settings page. |

### Env vars (new)

All in `config-variables.ts`, group `AWS_SES_SETTINGS`, all optional:

- `AWS_SES_REGION` — `@IsAWSRegion`, consumed by `AwsSesClientProvider`
+ driver factory
- `AWS_SES_ACCOUNT_ID` — used for ARN construction in driver factory
- `SES_SNS_TOPIC_ARN_ALLOWLIST` — **shared** by inbound + outbound
webhook routers, comma-separated list of accepted SNS topic ARNs
(verified via `sns-payload-validator`)

### Migrations

- `1778862608620-add-emailing-domain-tenant-status` (fast) — adds
`tenantStatus` column.
- `1778865501791-unique-emailing-domain-globally` (slow, idempotent) —
enforces global uniqueness on `domain`.
- Instance commands bumped to `2.5`.

### Infra dependency

Two coupled twenty-infra PRs:

- `ses-inbound-email` — receipt-rule + inbound SNS topic + S3 bucket
policy + KMS grant + `email_group_*` outputs.
- `ses-outbound-tf` — EventBridge rule + outbound SNS topic + SES IAM
policy + outbound `webhook_url` subscription. **Based on
`ses-inbound-email`.**

Merge order: inbound first, then outbound. Outbound PR's chart edit owns
the comma-joined `SES_SNS_TOPIC_ARN_ALLOWLIST` value (both ARNs).


Features lives under `/settings/general`

<img width="1496" height="845" alt="SCR-20260519-ofhi-2"
src="https://github.com/user-attachments/assets/a025485a-09f7-4131-91cd-0067690ff18d"
/>

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
2026-05-27 19:38:44 +02:00
Félix Malfait 4797d2f270 feat(twenty-orm): introduce WorkspaceScopedRepository for core/metadata workspace-scoped entities (#20953)
## Summary

Adds a third tenancy enforcement layer for entities that live in shared
schemas (`core`, `metadata`) and carry a `workspaceId` column —
previously the only safeguard at this layer was developer discipline
(remembering to put `workspaceId` in every WHERE clause).

### The three layers, after this PR

| Layer | Scope | How it's enforced |
|---|---|---|
| 1. Workspace data | per-workspace schema (companies, people, custom
objects) | `twentyORMManager.getRepository(workspace, E)` — physical
isolation (own data source) |
| 2. Metadata | shared `metadata` schema (objectMetadata, fieldMetadata,
views, roles…) | Flat-entity-maps cache — workspace-scoped in-memory
map, lookups by id within it |
| 3. Core (new) | shared `core` schema (agent threads/turns/messages,
app tokens, etc.) | `WorkspaceScopedRepository<T>` — `workspaceId` is a
required positional argument on every read/write |

## What's in the PR

### The wrapper
(`packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/`)
- `WorkspaceScopedRepository<T extends WorkspaceScopedEntity>` — wraps a
TypeORM `Repository<T>`, requires `workspaceId` on every
`find`/`findOne`/`findOneOrFail`/`update`/`delete`/`softDelete`/`insert`/`save`/`count`
call, merging it into the WHERE or stamping it on the entity.
`createQueryBuilder` is an explicit escape hatch (caller scopes
manually).
- Provided via Nest DI with
`@InjectWorkspaceScopedRepository(EntityClass)` and the
`provideWorkspaceScopedRepository(EntityClass)` provider factory.
- 19 unit tests cover the merge behavior, override-on-conflict, and the
array-where (OR) case.

### Lint enforcement
(`packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts`)
- New `twenty/prefer-workspace-scoped-repository` rule (level:
**error**).
- Blacklist of entity names: raw `@InjectRepository(E)` is rejected if
`E` is on the list.
- Initial list: `AgentTurnEntity`, `AgentMessageEntity`,
`AgentMessagePartEntity`, `AgentChatThreadEntity`,
`AgentTurnEvaluationEntity`, `AgentEntity`.
- Designed to grow over time as more consumers are migrated.
- 5 rule tests.

### Migration in this PR
All consumers of the six blacklisted entities, including:
- AI agent / chat / monitor resolvers, services, and jobs
- `AgentService`, `AiAgentRoleService`, `AiAgentWorkflowAction`,
`ApplicationService`, `WorkspaceFlatAgentMapCacheService`
- Admin-panel chat (migrated where the lookup is workspace-known; one
documented `eslint-disable` on the threadId-discovery lookup that
necessarily precedes the `allowImpersonation` permission check)
- `AiAgentRoleService` unit spec updated to mock the scoped wrapper

## Future work (deliberately not in this PR)

A standalone audit identified ~14 additional `core`/`metadata` entities
with `workspaceId` that currently use raw `@InjectRepository` and could
be added to the blacklist. Notable candidates: `UserWorkspaceEntity` (42
sites), `AppTokenEntity` (10), `FileEntity` (7),
`BillingCustomerEntity`/`BillingSubscriptionEntity` (~22 combined). Each
should be its own PR — the migration is mechanical but the surface is
wide.

## Test plan
- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx nx lint twenty-server` — 0 warnings, 0 errors
- [x] `npx jest workspace-scoped-repository` — 19/19 pass
- [x] `npx nx test twenty-oxlint-rules` — 215/215 pass
- [x] `npx jest src/engine/metadata-modules/ai` — 44/44 pass
- [ ] Manual smoke: end-to-end AI agent chat send/receive (reviewer)
- [ ] Manual smoke: AI agent monitor — list turns, run evaluation
(reviewer)
- [ ] Manual smoke: admin-panel chat thread inspection (reviewer)
2026-05-27 18:52:53 +02:00
Etienne 4a82cddad6 remove ai-model-preferences var env and config (#20859)
Split the single AI_MODEL_PREFERENCES JSON config into 4 array configs
and migrates existing workspace data.
2026-05-27 06:47:47 +00:00
neo773 bb5c2bd00c Fix/restore channel association scalar field metadata (#20920)
[#20836](https://github.com/twentyhq/twenty/pull/20836) dropped the
channel objects but even though
calendarChannelId/messageChannelId/messageFolderId already existed in
compute standard flat field, there was never an upgrade command to readd
them on the surviving association objects

so existing workspaces lost the field metadata (columns survived) and
import workers throw
```Error: Unknown error importing calendar events for calendar channel <REDACTED> in workspace <REDACTED>: Query runner already released. Cannot run queries anymore.```

This PR adds that command

---------

Co-authored-by: prastoin <paul@twenty.com>
2026-05-26 14:58:33 +00:00
Félix Malfait d602f35cbd feat(data-model): custom-indexes management UI and mutations (#20846)
## Summary

Brings indexes management into the per-object Settings tab as a section
under Search (no feature flag, advanced mode only). Admins can create /
delete non-unique indexes with the UI; apps can declare indexes in code
with `defineIndex`. Composite-typed fields are now indexable by picking
a specific sub-column (e.g. `Address > City`).

A few related polish items also land here (invite-user dropdown lands on
the Invite tab; standard warning callout above the new-index form).

## What ships

### UI — custom indexes on per-object Settings
- New section directly under Search, wrapped in
`AdvancedSettingsWrapper`.
- Filter dropdown on the search bar toggles system-index visibility
(shown by default since advanced mode).
- **+ Add Index** button (disabled with tooltip once the per-object cap
is reached) navigates to a dedicated `SettingsObjectNewIndex` page
(matches the field-creation pattern, not a modal):
- Field picker mirrors the webhook event-form layout (rows of dropdowns,
implicit trailing empty row).
- Composite fields surface their sub-properties (`Address > City`,
`Currency > Amount`, …).
  - BTREE / GIN type selector.
- Standard warning Callout: "Use indexes sparingly — each one speeds
reads but slows writes."
- Trash icon on `isCustom: true` rows → confirmation modal →
`deleteOneIndex`.

### Server — `createOneIndex` / `deleteOneIndex` mutations
- Gated by `SettingsPermissionGuard(DATA_MODEL)`.
- `IndexMetadataService` wraps the existing migration runner via
`WorkspaceMigrationValidateBuildAndRunService` so the metadata row and
the SQL index land atomically.
- Validation: rejects empty fields, duplicate `(fieldMetadataId,
subFieldName)` pairs, fields not on the object, requires `subFieldName`
for composite parents, forbids `subFieldName` on scalar/relation,
enforces `MAX_CUSTOM_INDEXES_PER_OBJECT = 10`.
- Delete refuses on `isCustom: false` rows so system indexes can't be
removed via this API.
- Dedicated GraphQL exception handler maps each typed error to the right
transport error class.

### Composite sub-field indexing
- Adds `subFieldName: string | null` column to
`IndexFieldMetadataEntity` (fast instance command).
- The flat-entity flow (`UniversalFlatIndexFieldMetadata`,
`FlatIndexFieldMetadata`, `from-universal-flat-index-to-flat-index`,
runner column resolution) all carry `subFieldName` through.
- For composite parents, the runner uses
`computeCompositeColumnName({...}, property)` for the picked sub-column;
for non-composite parents, behavior is unchanged.
- The `'::'` separator encodes `(fieldMetadataId, subFieldName)` for
dedup on the wire; the frontend uses the same separator inside the
Select component's string value.

### Apps can declare indexes in code (`defineIndex`)
- New `IndexManifest` + `IndexFieldManifest` types in
`twenty-shared/application` wired into the `Manifest` type.
- `defineIndex` SDK helper + `IndexConfig`. CLI manifest builder +
extractor recognize `defineIndex` / `ManifestEntityKey.Indexes`.
- Server: `from-index-manifest-to-universal-flat-index` converter
resolves field IDs, validates composite/scalar `subFieldName` rules, and
delegates to `generateFlatIndexMetadataWithNameOrThrow` for the
deterministic name.
- Orchestrator wires the loop after the field-resolution pass;
per-object cap enforced inline against the manifest.
- Cascade on uninstall is automatic — when an app disappears its indexes
drop with it (universal-flat-entity diff handles it).
- Rich-app fixture ships a real `defineIndex` on `PostCard.status`,
exercising the full manifest → install path in CI.

### Closed for now (open later if needed)
- Apps cannot declare `isUnique` indexes — unique constraints stay with
the field-creation flow.
- Apps cannot use a partial-`indexWhereClause` — the UI surface keeps
the framework's hardcoded allowlist.
- UI cannot create unique or partial indexes either; same reasons.

### Cleanups along the way
- Reused the existing `getCompositeSubFieldLabel` +
`COMPOSITE_FIELD_SUB_FIELD_LABELS` (deleted the duplicates I'd created
early in the PR).
- Moved `MAX_CUSTOM_INDEXES_PER_OBJECT` to `twenty-shared/constants`
(single source for FE + BE).
- Replaced inline `isDefined(x) && x !== ''` with `isNonEmptyString`
(from `@sniptt/guards`).
- Hoisted the per-object fields Map + inlined the cap counter into the
indexes orchestrator loop (drops the install scan from O(indexes ×
totalFields) to O(totalFields + indexes)).
- Per design-feedback: page-based create flow (not a modal), filter
dropdown on the SearchInput (not a separate toggle), webhook-style
picker, field icons.

### Unrelated polish that lands here
- "Invite user" link in the multi-workspace dropdown now lands on the
Invite tab directly (`#invite`) instead of the first tab of the members
page.

## Test plan
- [ ] `npx nx typecheck twenty-server / twenty-front / twenty-sdk /
twenty-shared` — passes
- [ ] `npx nx lint:diff-with-main twenty-server / twenty-front` — clean
- [ ] `npx jest index-metadata.service.spec` — green
- [ ] `npx jest from-index-manifest-to-universal-flat-index` — green
(new converter spec, 8 cases)
- [ ] `npx vitest run
src/sdk/define/indexes/__tests__/define-index.spec.ts` (twenty-sdk) —
green (6 cases)
- [ ] `npx vitest run --config vitest.integration.config.ts -t
"rich-app"` — green (rich-app app-dev integration exercises the new
manifest path with the PostCard.status index)
- [ ] Advanced mode → Settings → any object → Settings tab → Indexes
section is visible under Search
- [ ] Create a single-field BTREE index, confirm SQL index exists
(verify via `pg_indexes`)
- [ ] Create a composite-field index (`Address > City`) and confirm the
column is `addressAddressCity`
- [ ] Create an index spanning two columns; column order matches the
picker order
- [ ] Attempt to create an 11th custom index → button is disabled with
tooltip
- [ ] Delete a custom index → confirmation modal → row disappears, PG
index dropped
- [ ] System indexes have no trash icon and are hidden by default
2026-05-25 17:47:09 +02:00
Marie 85d649e831 [Fix] Backfill missing command menu items conditional availability expression (#20852)
## Description

Following [report in
discord](https://discord.com/channels/1130383047699738754/1498690477044793386/1506602927412744242)

Some command menu items were showing to all users because they had no
conditional availability expression, whereas users did not actually have
access to the page or feature behind. For instance: "Go to Admin panel",
"Go to AI settings", "Send email" etc.

<img width="833" height="1245" alt="image"
src="https://github.com/user-attachments/assets/8d2a9404-9b81-4d58-9522-558e9924c457"
/>


## Fix

- Add conditional availability expressions
- Backfill expressions for existing workspaces as they are stored in db
(commandMenuItems table)

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-05-23 13:22:25 +00:00
Charles Bochet 91ce59d8e2 refactor(jwt): gate signing-key auto-rotation cron on SIGNING_KEY_ROTATION_DAYS (#20866)
Only register the JWT signing-key rotation cron when
`SIGNING_KEY_ROTATION_DAYS` is set, and move that variable to Advanced
Settings.
2026-05-23 09:51:06 +00:00
Paul Rastoin 76e144e85a Deprecate messageChannel messageFolder calendarChannel standard objects (#20836)
# Introduction

Removing old standard objects `messageChannel` and `messageFolder` and
`calendarChannel`

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 13:40:53 +00:00
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
neo773 323e66433e lint: migrate prettier to oxfmt (#20783)
Most changes are `implements` being unwrapped this is not a oxfmt
regression
Prettier in 3.7 (we're on 3.1) changed this behaviour prettier blog
[post](https://prettier.io/blog/2025/11/27/3.7.0#change-18094)

This unifies our linting tooling

---------

Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-05-22 00:21:33 +02:00
Charles Bochet edf2bfbf76 feat(server): rotate connectedAccount.connectionParameters via secret-encryption:rotate (#20807)
## Summary

Adds a new `connected-account-connection-parameters` site to the
`secret-encryption:rotate` CLI introduced in #20613, so the nested
password envelopes inside `connectedAccount.connectionParameters` (IMAP
/ SMTP / CALDAV — encrypted at-rest in #20673) are re-encrypted under
the current `ENCRYPTION_KEY` alongside every other at-rest secret site.
Without this, rotating `ENCRYPTION_KEY` on a 2.7+ instance would
silently leave IMAP / SMTP / CalDav passwords on the old key id.

### Why a new handler

A dedicated handler is required (rather than reusing
`ColumnRotationSiteHandler`) because the envelope lives at
`connectionParameters->'<PROTOCOL>'->>'password'`, not in the whole
column, and up to three independent envelopes may need rotating per row.
The handler:

- Uses the same cursor-based, idempotent, online pattern as the existing
handlers, with a SQL predicate that skips rows where every non-null
protocol password is already on the current key id.
- Threads \`workspaceId\` into HKDF, matching how
\`EncryptConnectionParametersSlowInstanceCommand\` backfilled.
- Rebuilds only the protocols whose passwords are not yet current, so a
partial mid-row failure cannot cause unnecessary re-encryption on
resume.
- Guards the UPDATE with jsonb-level deep equality (\`IS NOT DISTINCT
FROM CAST(:json AS jsonb)\`) so optimistic concurrency is unaffected by
Postgres's internal jsonb key ordering vs. JSON.stringify ordering.
- Refuses to rotate plaintext passwords (counted as \`errors\`) —
operators must finish the 2.7 slow instance command
(\`EncryptConnectionParametersSlowInstanceCommand\`) before running
rotation.

### Sites covered (now)

| Site | Location | Scope |
| --- | --- | --- |
| \`connected-account-access-token\` | \`connectedAccount.accessToken\`
| workspace |
| \`connected-account-refresh-token\` |
\`connectedAccount.refreshToken\` | workspace |
| **\`connected-account-connection-parameters\`** (new) |
\`connectedAccount.connectionParameters.{IMAP,SMTP,CALDAV}.password\` |
workspace |
| \`application-variable\` | \`applicationVariable.value\` (isSecret) |
workspace |
| \`application-registration-variable\` |
\`applicationRegistrationVariable.encryptedValue\` | instance |
| \`signing-key-private-key\` | \`signingKey.privateKey\` | instance |
| \`totp-secret\` | \`twoFactorAuthenticationMethod.secret\` | workspace
|
| \`sensitive-config-storage\` | \`keyValuePair.value\` (sensitive
STRING configs) | instance |
2026-05-21 19:31:52 +02:00
Paul Rastoin abf5902ab5 Connected account deprecation system build (#20810) 2026-05-21 16:14:56 +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
Paul Rastoin 9b9c97a049 Deprecate and backfill delete ConnectedAccount twenty standard object (#20752)
# Introduction
Following connected account permissions refactor and encryption
Removing the old workspace schema twenty standard application
connectedAccount objects and related standard fields and index

- a lot of deadcode
- instance command backfill cleaning the connected account object from
workspaces
2026-05-21 13:42:09 +02:00
Charles Bochet 9988f98577 feat(server): idempotent CLI to rotate ENCRYPTION_KEY across enc:v2 rows (#20613)
## Summary
Adds the \`secret-encryption:rotate\` CLI command, which re-encrypts
every at-rest secret stored in an \`enc:v2:\` envelope under the current
\`ENCRYPTION_KEY\`. The command is **online** and **resumable**: a SQL
filter skips rows already on the current keyId, so interrupting it
(Ctrl-C, container restart, …) and re-running picks up where it left off
without re-rotating earlier rows.

### Sites covered (one handler each)
| Site | Table.column | Scope |
| --- | --- | --- |
| \`connected-account-tokens\` | \`connectedAccount.{accessToken,
refreshToken}\` | workspace |
| \`application-variable\` | \`applicationVariable.value\` (isSecret
only) | workspace |
| \`application-registration-variable\` |
\`applicationRegistrationVariable.encryptedValue\` | instance |
| \`signing-key-private-keys\` | \`signingKey.privateKey\` | instance |
| \`sensitive-config-storage\` | \`keyValuePair.value\` (isSensitive +
STRING configs) | instance |
| \`totp-secrets\` | \`twoFactorAuthenticationMethod.secret\` |
workspace |

Each handler:
- Filters at SQL level on \`value LIKE 'enc:v2:%' AND value NOT LIKE
'enc:v2:<primaryKeyId>:%'\` to enforce idempotency without re-decrypting
already-rotated rows.
- Uses cursor-based batching (default **200**, capped **5000**).
- Threads \`workspaceId\` into HKDF for workspace-scoped sites; runs
instance-scoped for the rest.

### CLI flags
| Flag | Description |
| --- | --- |
| \`-s, --site <site>\` | Limit to a single site. |
| \`-b, --batch-size <n>\` | Override per-batch row count. |
| \`-d, --dry-run\` | Decrypt + re-encrypt in memory, skip the
\`UPDATE\`. |

The runner logs progress via Nest \`Logger\` (per-site start,
completion, final summary) and exits non-zero when any site reports
\`errors > 0\`. \`FALLBACK_ENCRYPTION_KEY\` must be set to the previous
\`ENCRYPTION_KEY\` during rotation; the runner warns when it is unset.

Operator documentation lives in #20611 (docs PR).
2026-05-20 17:51:29 +00:00
Félix Malfait f3aadbbb66 chore(server): drop leftover favorite and favoriteFolder workspace objects (#20744)
## Summary

- Adds a 2.7.0 workspace upgrade command
`upgrade:2-7:drop-favorite-objects` that removes the legacy `favorite`
and `favoriteFolder` object metadata (and their workspace tables) from
every active or suspended workspace.
- The records were migrated to `navigationMenuItem` in the 1.17/1.18
upgrades and the entity code was deleted in #19536, but the
per-workspace metadata rows were never cleaned up — so they still
surface in the "Existing objects" settings list and expose stale CRUD
tools to the AI/MCP layer (e.g. the model can hallucinate
`create_favorite_folder` against a real-looking schema).

## Implementation notes

- Modeled on
[`upgrade:2-3:drop-message-direction-field`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/database/commands/upgrade-version-command/2-3/2-3-workspace-command-1777400000000-drop-message-direction-field.command.ts),
but at object granularity.
- Uses `ObjectMetadataService.deleteOneObject({ isSystemBuild: true })`
so all cascading is handled by the existing pipeline: field metadata,
indexes, relation fields on other workspace entities, command menu
items, and the workspace data tables. Views and orphaned
`navigationMenuItem` rows pointing at favorite views are removed by the
existing `onDelete: 'CASCADE'` FKs.
- Deletion order: `favorite` first (holds a relation to
`favoriteFolder`), then `favoriteFolder`.
- Both objects are flagged `isSystem: true`, hence `isSystemBuild: true`
on the call.
- Idempotent: workspaces where the object is already absent are logged
and skipped.
- Honors `--dry-run`.
- Universal identifiers are hard-coded because the matching
`STANDARD_OBJECTS` entries were deleted in #19536.

## Test plan

- [ ] Run on a workspace that still has `favorite` / `favoriteFolder` in
`core.objectMetadata` (verify in prod-like DB beforehand) and confirm
both objects, their fields, indexes, relation fields on linked objects,
views, and the workspace data tables are gone after running.
- [ ] Re-run on the same workspace — confirm it logs "already absent"
and exits clean (idempotency).
- [ ] Run on a workspace where the objects don't exist (e.g. fresh
local) — confirm clean no-op.
- [ ] Run with \`--dry-run\` first — confirm log output and no DB
mutations.
- [ ] Confirm the "Existing objects" settings page no longer lists
Favorites / Favorite Folders after the migration.

## Safety check before rollout

Before running in prod, verify no workspace has live (non-soft-deleted)
favorite data that didn't make it to \`navigationMenuItem\`:

\`\`\`sql
-- Per workspace
SELECT count(*) FROM workspace_xxx.favorite WHERE "deletedAt" IS NULL;
\`\`\`

Should be ~0 in workspaces that ran the 1.17 / 1.18 migrations.

---------

Co-authored-by: prastoin <paul@twenty.com>
2026-05-20 07:21:52 +00:00
nitin 83b10ad698 fix(server): sync command menu item availability expressions on existing workspaces (#20719)
Two fixes via one workspace command:

1. Gates 5 standard command menu items behind `pageType == "INDEX_PAGE"`
--
   `importRecords`, `exportView`, `seeDeletedRecords`, `createNewView`, 
`hideDeletedRecords`. They currently appear (and crash or do nothing) on
   RECORD_PAGE.

2. Fixes Edit Layout missing from older workspaces -- root cause is 
`conditionalAvailabilityExpression` drift between source-of-truth
constants
and the workspace DB (e.g. #20556 removed a feature flag from the
expression
   without syncing existing workspaces).

The 2-6 workspace command iterates all `STANDARD_COMMAND_MENU_ITEMS` and
reconciles any `conditionalAvailabilityExpression` that differs from the
constant. Idempotent -- already-correct rows are skipped.

Deferred: `deleteRecords` doesn't refetch the current record after
deletion
on RECORD_PAGE (mutation fires but UI shows stale state until refresh)
--
different fix shape (frontend handler), separate PR.
2026-05-19 14:42:40 +00:00
Paul Rastoin 57f13c9b92 [CONNECTED_ACCOUNT_BREAKING_CHANGE] Encrypt ConnectedAccount connectionParameters (#20673)
# Introduction
Prevent any cross user `connectedAccount` `connectionParamaters` leak
Also encrypt in db all `connectionParameters` password
Never return any password through `DTO` anymore
The settings now allow update mutation without providing the password in
edition mode

Verified all `connectionParameters.password` interaction

## Integration tests
- Added more coverage for both failing and successful paths
- Introduced a new env var that allow bypass the provider connection
test

## Legacy connected Account decryption support
Stop allowing non encrypted decryption on `accessToken` and
`refreshToken`, only allow legacy decryption on refactored
`connectionParameters`

## Upsert ownership
Completely got rid of the connected workspace schema context which is
legacy
Also now a user can only upsert a connected account for him only..

## New UI
<img width="1770" height="1852" alt="image"
src="https://github.com/user-attachments/assets/55c1dc89-42ff-4084-95e2-cc5f9e23753b"
/>
If in edition the password is by default disabled
It needs to be selected as being edited to be enabled

## Next
- Refactor tool permissions flag not to include connected accounts
- Remove the legacy connected standard object
- Refactor and improve connected account resolver auth
2026-05-19 12:56:44 +00:00
Charles Bochet 77514ad14a fix(server): backport relationTargetFieldMetadataId column-add to 2.4 and 2.5 fast instance (#20721)
## Summary

Cross-version upgrade from a **v2.3 or v2.4 baseline** to v2.6.x
currently fails at the 2.5 workspace command
`NormalizeCompositeFieldDefaults`:

```
[QueryFailedError] column ViewFilterEntity.relationTargetFieldMetadataId does not exist
  at WorkspaceFlatViewFilterMapCacheService.computeForCache
```

Reproduced locally via Docker cross-version upgrade (v2.6.1 against
`twentycrm/twenty:v2.3` and `:v2.4` images on a freshly-seeded DB).

### Root cause

The column-add is already declared in two places:
-
`2-3/.../1747234300000-add-relation-target-field-metadata-id-to-view-filter`
(backport from #20664)
-
`2-6/.../1798000005000-add-relation-target-field-metadata-id-to-view-filter`

But the runner's `resolveStartCursor`
(`upgrade-sequence-runner.service.ts`) advances forward from
`lastAttemptedCommandName` and never re-runs commands inserted *behind*
the cursor:

- **fresh install through 1.23 → 2.6.x**: cursor < 2.3 → 2.3 backport
runs → column added before 2.5 workspace ✓
- **v2.3 baseline → 2.6.x**: cursor past 2.3 → 2.3 backport skipped →
2.5 workspace `NormalizeCompositeFieldDefaults` crashes ✗
- **v2.4 baseline → 2.6.x**: cursor past 2.4 → 2.3 backport skipped →
same crash ✗
- **v2.5 baseline → 2.6.x**: cursor past 2.5 → 2.5 workspace already
applied (ran against v2.5 source's older entity without the column) →
2.6 fast adds the column ✓

The 2.6 fast `1798000005000` runs *after* the 2.5 workspace command, too
late to help v2.3 / v2.4 baselines.

### Fix

Mirror the existing 2.3 Early backport at two more versions:

-
`2-4/.../1747234400000-add-relation-target-field-metadata-id-to-view-filter`
— covers v2.3 baseline (runs in 2.4 fast, before any 2.4/2.5 workspace
command)
-
`2-5/.../1747234500000-add-relation-target-field-metadata-id-to-view-filter`
— covers v2.4 baseline (runs in 2.5 fast, before
`NormalizeCompositeFieldDefaults`)

Both use `ADD COLUMN IF NOT EXISTS` (idempotent) and `DROP COLUMN IF
EXISTS` for the down. No FK / index — those still live in the 2.6 file,
which runs as a no-op for the column on already-fixed DBs.

Pre-2.6 codebases can't use `@WasIntroducedInUpgrade` (#20686 only lands
in 2.6), so this "ladder of backports" remains the operative pattern.

## Audit context

Locally walked `v1.23 / v2.0 / v2.1 / v2.2 / v2.3 / v2.4 / v2.5 →
v2.6.1`:

| Baseline | Result |
|---|---|
| v1.23 | PASS |
| v2.0 | PASS |
| v2.1 | PASS |
| v2.2 | PASS |
| **v2.3** | **FAIL** (this PR) |
| **v2.4** | **FAIL** (this PR) |
| v2.5 | PASS |
2026-05-19 14:46:56 +02:00
Charles Bochet 72ce77864e feat(server): Enterprise cron that rotates the current JWT signing key (#20612)
## Summary
Adds a daily Enterprise-only cron that rotates the current ES256 JWT
signing key once it has been current for `SIGNING_KEY_ROTATION_DAYS`.
Manual rotation from the admin panel is unaffected.

### Behaviour
- `SIGNING_KEY_ROTATION_DAYS` is **opt-in**: when unset, the cron is a
no-op.
- Rotation flips `isCurrent` and clears the previous key's `privateKey`
in the same transaction, then inserts the new `isCurrent=true` row.
- The previous key's row is kept (`revokedAt` stays `null`) so its
`publicKey` can keep verifying tokens it signed until they expire; only
the encrypted `privateKey` is wiped since it can no longer be used to
sign.
- **No auto-revocation** — revoking a key remains a manual admin action,
reserved for leak / emergency response.
- The cron is also a no-op when `EnterprisePlanService.isValid()` is
`false`.

### Wiring
- `JwtKeyManagerService.rotateCurrent()`
- `SigningKeyRotationService.rotateIfDue()` (reads
`SIGNING_KEY_ROTATION_DAYS`, skips when unset)
- `RotateSigningKeysCronJob` (Enterprise-gated, rethrows on failure)
registered in `JwtModule`
- `RotateSigningKeysCronCommand` registered with `cron:register:all`
- `ROTATE_SIGNING_KEYS_CRON_PATTERN = '15 3 * * *'` (daily, no-op until
threshold)

Operator documentation lives in #20611 (docs PR).
2026-05-19 10:41:04 +00:00
Charles Bochet bad1f20012 fix(server): handle legacy PK name in 2.6 rename-permission-flag upgrade (#20697)
## Summary

The 2.6 `RenamePermissionFlagToRolePermissionFlag` upgrade command
failed on staging and dev with:

```
[QueryFailedError] constraint "PK_a02789db60620a1e9f90147b50f" for table "rolePermissionFlag" does not exist
in RenamePermissionFlagToRolePermissionFlag1778235340020 (2.6.0) (instance fast)
```

### Root cause

TypeORM names PKs as `PK_<sha1(tableName_sortedColumnNames)[:27]>`. So:
- `permissionFlag_id` → `PK_a02789db60620a1e9f90147b50f`
- `settingPermission_id` → `PK_8c144a021030d7e3326835a04c8`
- `rolePermissionFlag_id` → `PK_76591adc8035c2e7b0cd6115136`

On databases initially migrated before the v1.5.5 migration squash
(#15183), the table was renamed `settingPermission` → `permissionFlag`
via the pre-squash migration
`1753149175945-renameSettingPermissionToPermissionFlag.ts`. That
migration renamed the table, the column, the unique index, and the role
FK, but **never renamed the PK constraint** — and Postgres does not
auto-rename constraints on `ALTER TABLE ... RENAME TO`. Those instances
therefore still carry the legacy PK name
`PK_8c144a021030d7e3326835a04c8`.

Fresh installs (squashed `setupMetadataTables` migration) instead have
the expected `PK_a02789db60620a1e9f90147b50f`.

The 2.6 upgrade only handled the fresh-install name, so it broke for any
DB that went through the historical rename chain.

### Fix

Replace the brittle `RENAME CONSTRAINT` with `DROP CONSTRAINT IF EXISTS`
for both historical PK names, followed by `ADD CONSTRAINT ... PRIMARY
KEY ("id")` with the canonical new name. The migration now converges to
the same PK name regardless of the DB's history.

The same pattern is applied symmetrically in `down()`.

### Why this is safe

- The whole instance command runs in a transaction
(`InstanceCommandRunnerService.runFastInstanceCommand`).
- The first statement (`ALTER TABLE ... RENAME TO`) takes `ACCESS
EXCLUSIVE` on the table, so the drop/add window for the PK is invisible
to any concurrent writer — they queue on the lock until commit.
- No FK references `rolePermissionFlag.id` at this point in the sequence
(migration 22 introduces an FK pointing at the new `permissionFlag`
catalog created in migration 21, not at the renamed grant table), so
dropping the PK does not cascade or block.
- `NOT NULL` and the `uuid_generate_v4()` default on `id` are
column-level and remain in place when the PK is dropped.

## Test plan

- [ ] Run 2.6 upgrade against a fresh-install database (PK =
`PK_a02789db60620a1e9f90147b50f`) — should succeed.
- [ ] Run 2.6 upgrade against a pre-squash database (PK =
`PK_8c144a021030d7e3326835a04c8`, reproducible on current staging/dev) —
should now succeed.
- [ ] Verify post-migration: `rolePermissionFlag` exists, PK is named
`PK_76591adc8035c2e7b0cd6115136`, all FKs and indexes named as expected.
- [ ] Run `down()` and verify table returns to `permissionFlag` with PK
`PK_a02789db60620a1e9f90147b50f`.
- [ ] Subsequent migrations (`1778235340021` permission-flag catalog,
`1778235340022` link, `1778235340023` backfill) still apply cleanly.
2026-05-18 23:04:15 +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
Charles Bochet d03480472c perf(server): index messageChannel/calendarChannel for per-workspace sync crons (#20678)
## Summary

The messaging/calendar import crons each iterate every active workspace
and execute one `find` per workspace against `core."messageChannel"` /
`core."calendarChannel"` with the shape:

```
WHERE "workspaceId" = $1 AND "isSyncEnabled" = true AND "syncStage" = $2 [AND "type" <> $3]
```

There is currently no index supporting that shape, so the planner does a
seq scan on each table for every iteration. On prod-eu (RDS Performance
Insights, `rds-prod-eu-one`), these two queries are the top two by load
— together ~12 AAS, ~12 calls/sec — and have been the primary
contributor to the sustained 100% CPU since active workspace count grew.

This PR adds composite indexes on `(workspaceId, isSyncEnabled,
syncStage)` for both tables as an instance migration in 2.6.0.
2026-05-18 14:31:03 +00:00
nitin db0547f503 [1/3] Rename permissionFlag to rolePermissionFlag + add permissionFlag catalog/backfill (#20481)
Split of #20377.

## Summary

This PR separates available permission flags from per-role permission
flag grants.

Previously, `core.permissionFlag` stored the role assignment directly:
`roleId + flag`. This PR renames that legacy grant table to
`core.rolePermissionFlag`, then recreates `core.permissionFlag` as the
catalog of available permission flags.

## What changed

- Rename the existing `core.permissionFlag` grant table to
`core.rolePermissionFlag`.
- Add the new syncable `core.permissionFlag` catalog entity with key,
label, description, icon, permission type, relevance flags, and
custom/standard metadata.
- Add stable `SystemPermissionFlag` universal identifiers for the
built-in `PermissionFlagType` values.
- Seed the standard permission flags for every workspace under the
Twenty standard application.
- Backfill existing role grants:
  - create missing catalog rows for existing grant keys,
  - add `rolePermissionFlag.permissionFlagId`,
- migrate grants from the old string `flag` column to the new catalog
FK,
- replace the old `(flag, roleId)` uniqueness with `(permissionFlagId,
roleId)`.
- Rewire role permission flag caches, permission checks, role DTO
mapping, and `upsertPermissionFlags` to resolve through the catalog.
- Keep the existing public role permission API shape: product/app
surfaces still talk about `permissionFlags` and return `{ id, roleId,
flag }`.
- Update metadata flat-entity machinery, migration builders, validators,
action handlers, snapshots, generated schemas, docs, and app fixtures
for the new `permissionFlag` / `rolePermissionFlag` split.

## Behavior after this PR

- Existing permission flag grants keep working.
- Existing GraphQL role permission flows keep the same public naming.
- Standard permission flags are represented as catalog rows.
- Permission checks now compare grants through catalog universal
identifiers instead of the legacy `flag` column.
- Workspace deletion cleanup now verifies both `permissionFlag` and
`rolePermissionFlag`.

## What is not in this PR

- Public GraphQL CRUD for custom permission flags.
- App manifest support for declaring new custom permission flags.
- Frontend UI for creating or assigning custom permission flags beyond
the existing role permission flow.

---------

Co-authored-by: Weiko <corentin@twenty.com>
2026-05-18 11:57:47 +00:00
Charles Bochet 6b3064e2ba fix(server): add relationTargetFieldMetadataId column early in upgrade sequence (#20664)
## Summary

Cross-version upgrade fails at the 2.3
`DropMessageDirectionFieldCommand` stage:

```
[QueryFailedError] column ViewFilterEntity.relationTargetFieldMetadataId does not exist
  at WorkspaceFlatViewFilterMapCacheService.computeForCache
```

(see
https://github.com/twentyhq/twenty-infra/actions/runs/25929264129/job/76219964380)

Same shape as #20584 (subFieldName), one column over.

### Root cause

1. The 2.3 `DropMessageDirectionFieldCommand` builds a workspace
migration that deletes a `fieldMetadata` (the `direction` field).
2. `WorkspaceMigrationRunnerService.run` walks the metadata cascade
graph and pulls `viewFilter` into the dependency set because
`viewFilter` is the inverse one-to-many of `fieldMetadata`.
3. That maps to cache keys → `flatViewFilterMaps` gets requested →
`WorkspaceFlatViewFilterMapCacheService.computeForCache` runs.
4. `computeForCache` does `viewFilterRepository.find({ where: {
workspaceId }, withDeleted: true })` with no `select`, so TypeORM emits
a SELECT that includes `relationTargetFieldMetadataId` — column only
added by the 2.6 fast instance command `1798000005000`, not yet run at
the 2.3 stage. 💥

### Why v2.5.0 / v2.5.1 passed

They didn't include #20527 (one-hop relation filters, May 14), which
added `relationTargetFieldMetadataId` to `ViewFilterEntity` and the 2.6
instance command. The CI base image (v1.22) seeded the DB, then the
v2.5.0/v2.5.1 container ran upgrade commands against an entity that
didn't yet know about this column.
2026-05-18 10:42:40 +02:00
Félix Malfait c938fbf4d6 feat(twenty-front): relation traversal in filter dropdown (stacked) (#20533)
**Stacked on #20527** 




https://github.com/user-attachments/assets/48995655-401a-4c35-8094-e88da8408bdd





## Summary

Surfaces the one-hop relation traversal added in #20527 through the
existing **composite sub-field dropdown pattern**. Clicking a
MANY_TO_ONE relation field in the "+ Filter" picker now opens the same
second-level dropdown that composite fields (FULL_NAME, ADDRESS,
CURRENCY, etc.) already use — populated with the target object's
filterable fields. Picking one (e.g. `Company → Name`) builds a filter
that serializes to the nested GraphQL filter the backend now accepts: `{
company: { name: { ilike: "%X%" } } }`.

No new components. The whole feature reuses
`AdvancedFilterSubFieldSelectMenu` + the existing
`subFieldNameUsedInDropdownComponentState` + the existing `MenuItem
hasSubMenu` indicator. Only the conditions that gate the sub-menu (and
the sub-menu's content for relations) were broadened.

## What landed

| File | Change |
|---|---|
| `ObjectFilterDropdownFilterSelectMenuItem` | Sub-menu chevron now
shows on MANY_TO_ONE relations (`isManyToOneRelationField` util). |
| `AdvancedFilterFieldSelectMenu` | Relation clicks open the sub-menu
alongside composite clicks. |
| `AdvancedFilterSubFieldSelectMenu` | New branch: when the sub-menu
type is `'RELATION'`, render the target object's filterable fields via
`useFilterableFieldMetadataItems(targetObjectMetadataId)`. Composite
logic untouched. |
| `objectFilterDropdownSubMenuFieldType` state | Widened to accept a
`'RELATION'` sentinel. Role-permissions sub-field menu narrows it back
out (it doesn't traverse relations). |
| `useSelectFieldUsedInAdvancedFilterDropdown` | New optional
`targetFieldMetadataItem` arg. When present, the stored RecordFilter's
`type` is the target field's type so the operand picker and value input
render the target's operands (`'TEXT'` operators when filtering
`company.name`, etc.). |
| `turnRecordFilterIntoGqlOperationFilter` (shared) | When the filter
targets a `RELATION` field with a `subFieldName`, synthesize a
field-metadata for the target, recurse to build the inner filter, then
wrap it under the relation field's name → `{ relationName: {
targetFieldName: { ...operator } } }`. |

`RecordFilter.subFieldName` stays narrowly typed as
`CompositeFieldSubFieldName` so the wide downstream consumers
(`shouldShowFilterTextInput`, composite handlers in the serializer,
etc.) don't change. The relation target field's name is stored through a
narrowly-scoped cast at the dropdown's storage point — the serializer
checks `filter.type === 'RELATION'` before interpreting it as a target
field name, so the cast can't be mis-read by composite-only code paths.

## Test plan

- [ ] Open a table view on People, click "+ Filter", click "Company" →
sub-menu opens with Company's filterable fields
- [ ] Pick "Name" → operand picker shows TEXT operators (Contains,
Equals, …)
- [ ] Type "Airbnb" → filter applies, table shows people whose company
name contains "Airbnb"
- [ ] Verify network tab: the GraphQL filter variable is `{ company: {
name: { ilike: "%Airbnb%" } } }`
- [ ] Same flow with a composite target field (e.g. `Company →
annualRecurringRevenue → amountMicros`) — should work end-to-end
(backend supports composite-within-relation; #20527 has an integration
test covering this)
- [ ] Composite fields (FULL_NAME, ADDRESS) still open their normal
sub-menu and filter correctly — no regression
- [ ] Role-permissions field-select sub-field menu is unaffected (it
bails out early on the RELATION sentinel)

## Out of scope

- ONE_TO_MANY traversal (no backend support yet)
- Aggregates (`people.count > 5`)
- Persisting relation-traversal filters into a saved view (ViewFilter
has no `relationPath` column yet; that's a separate slice)
- REST API DSL changes
- AI Tools

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 16:42:47 +02:00
Charles Bochet eca92ca559 fix(server): rebuild unique phone indexes drops legacy non-empty partial WHERE clause (#20606)
## Summary

`RebuildUniquePhoneIndexesCommand` reuses each index's existing
`indexWhereClause` when recreating the physical index. For workspaces
whose unique phone indexes have a legacy clause like
`"primaryPhoneNumber" != ''` (created before PR #18024 hardened the
validator allowlist), the recreate path fails at
`validateAndReturnIndexWhereClause` because the clause isn't in
`ALLOWED_INDEX_WHERE_CLAUSES`.

Two workspaces are hitting this on the 2.5 upgrade:
- `3a797122-…` — `"companyPhonePrimaryPhoneNumber" != ''`
- `ea74716f-…` — `"phonesPrimaryPhoneNumber" != ''`

## Fix

Detect the legacy `"<col>" != ''` shape via a strict regex. When it's
there, before the existing drop+create, do three things inside the
workspace transaction:

1. **Normalize the data** that the legacy partial clause was masking —
`UPDATE "<schema>"."<table>" SET "<col>" = NULL WHERE "<col>" = ''` for
every column the index covers. Without this the next step would fail
because the new plain-unique index would see duplicate `''` values
across the rows the old partial clause was excluding.
2. **Null out `core."indexMetadata".indexWhereClause`** so the metadata
row matches what the UI would have created (`indexWhereClause: null`)
and doesn't carry the validator-rejected clause forward to any future
re-emit. Uses the same workspace `queryRunner` (Postgres lets one
connection write across schemas).
3. **Recreate** with an overridden flat index where `indexWhereClause:
null`. `createIndexInWorkspaceSchema` → `indexManager.createIndex` →
`validateAndReturnIndexWhereClause` short-circuits on null, no allowlist
check.

End state matches the shape a fresh "toggle unique in Settings UI"
creates: plain unique index, no `WHERE`, NULL semantics doing the
"exclude empty phones" work via PG's default NULL-distinct behaviour.

For indexes whose clause is already allowlisted (`"deletedAt" IS NULL`)
or null, behaviour is unchanged — just the column-list widening this
command already does.
2026-05-15 16:39:34 +02:00
Charles Bochet ca1571676c fix(server): treat plaintext-under-isSecret rows as plaintext in app variable encryption migration (#20590)
## Summary

Prod 2.5 upgrade failed on the slow instance command
`EncryptApplicationVariableSlowInstanceCommand`:

```
[Nest] LOG  [InstanceCommandRunnerService] 2.5.0_EncryptApplicationVariableSlowInstanceCommand_1798000005000 starting data migration...
[Nest] WARN [SecretEncryptionService] Decrypted a legacy unprefixed AES-CTR ciphertext...
[Nest] ERROR [InstanceCommandRunnerService] data migration failed
TypeError: Invalid initialization vector
```

### Root cause

The migration assumes every row matching `isSecret = true AND value <>
'' AND value NOT LIKE 'enc:v2:%'` is legacy AES-CTR ciphertext. In prod
we found multiple `isSecret = true` rows whose `value` is plaintext
(e.g. `SLACK_HOOK_URL = 'https://hooks.slack.com/services/...'`) — most
likely the result of `isSecret` being flipped to true on a row that
already held a plaintext value, or a write path that bypassed
`ApplicationVariableEntityService.update`. Those values can't decode
into the 16-byte IV that AES-CTR needs, so `Buffer.from(value,
'base64')` truncates at the first non-base64 char (`:`), the buffer is <
16 bytes, and `createDecipheriv` throws.

### Fix

Follow the same policy as
`EncryptConnectedAccountTokensSlowInstanceCommand`: anything that isn't
already in the `enc:v2:` envelope is plaintext. Concretely:

1. Try `decryptVersioned` — legacy CTR rows decrypt fine.
2. If it throws (mis-classified plaintext), log a warning naming the row
id and fall back to treating `row.value` as plaintext.
3. Encrypt the resulting plaintext into the `enc:v2:` envelope and
update the row.

In-loop `isSecret` guard is kept (alongside the SQL filter) so
non-secret rows are never touched even if the SQL filter is ever
loosened.

### Integration test coverage

Added one new case alongside the existing ones in
`…encrypt-application-variable.integration-spec.ts`:

- `treats plaintext-under-isSecret=true as plaintext and re-encrypts as
v2` — seeds a row with `isSecret = true` and a URL value (`:` and `/`
are not base64, so this is the exact failure shape from prod), runs the
migration, and asserts the value is now `enc:v2:...` and decrypts back
to the original URL.

Existing cases unchanged: legacy CTR happy path, non-secret rows
untouched, idempotent across re-runs, `up()` adds the CHECK constraint,
`down()` removes it.

### Why this is a 2-5 edit

`TWENTY_CURRENT_VERSION` is now 2.6.0, so editing a 2-5 file trips the
`server-previous-version-upgrade-mutation-guard` —
`ci:allow-previous-version-upgrade-mutation` label is on the PR. `up()`
and `down()` are unchanged; only `runDataMigration` is modified.

## Test plan

- [ ] Re-deploy 2.5 to prod and confirm
`EncryptApplicationVariableSlowInstanceCommand` completes
- [ ] Inspect warning log to count rows that went through the plaintext
fallback
- [ ] Verify resulting secret rows all satisfy `value = '' OR value LIKE
'enc:v2:%'` and the CHECK constraint is in place
2026-05-14 18:40:41 +02:00
Charles Bochet 09daccc3f9 fix(server): add subFieldName column early in upgrade sequence (#20584)
## Summary

Cross-version upgrades from pre-2.3 still fail after #20581 / #20583 —
different column, structurally similar problem:

```
column ViewSortEntity.subFieldName does not exist
  at WorkspaceFlatViewSortMapCacheService.computeForCache (...flat-view-sort/services/workspace-flat-view-sort-map-cache.service.js:40)
  ... triggered indirectly by DropMessageDirectionFieldCommand (2.3 workspace command)
```

(see
https://github.com/twentyhq/twenty-infra/actions/runs/25862573418/job/75997337604)

### Why narrowing the `select` doesn't fit here

In the previous two PRs the offender was a bare `findOne` on
`WorkspaceEntity` — easy to narrow. Here the chain is:

1. The 2.3 `DropMessageDirectionFieldCommand` builds a workspace
migration that deletes a `fieldMetadata` (the `direction` field).
2. `WorkspaceMigrationRunnerService.run` walks the metadata cascade
graph (`getMetadataRelatedMetadataNames`) and pulls `viewSort` into the
dependency set because `viewSort` is the inverse one-to-many of
`fieldMetadata` (deleting a field cascades to view sorts that reference
it).
3. That maps to cache keys → `flatViewSortMaps` gets requested →
`WorkspaceFlatViewSortMapCacheService.computeForCache` runs.
4. `computeForCache` does `viewSortRepository.find({ where: {
workspaceId }, withDeleted: true })` with no `select`, so TypeORM emits
a SELECT that includes `subFieldName` — the column doesn't exist in DB
yet (added by a 2.5 instance command much later in the sequence). 💥

Narrowing the cache provider's select would silently drop `subFieldName`
from the cache for runtime use too, until something invalidates it.
Brittle, and would re-break the next time anyone adds a `viewSort`
column.

### Structural fix

Ensure the column exists in DB before any 2.3 workspace command can
trigger that cascade. Within a version, the upgrade runner sorts: fast
instance → slow instance → workspace, so a new 2.3 fast instance command
lands before `DropMessageDirectionFieldCommand`.

- **Add**
`2-3/2-3-instance-command-fast-1747234200000-add-sub-field-name-to-view-sort.ts`
— `ALTER TABLE ... ADD COLUMN IF NOT EXISTS "subFieldName"`. Comment in
the file explains the cascade and why this lives in 2.3 instead of 2.5.
- **Make idempotent** the existing
`2-5/...-add-sub-field-name-to-view-sort.ts` — switched to `ADD COLUMN
IF NOT EXISTS` / `DROP COLUMN IF EXISTS` so it's a no-op on
cross-upgrade paths while still creating the column on fresh-from-2.5
installs.
- Register the new command in `instance-commands.constant.ts`.

The 2.5 command body change is semantically preserving (idempotent), and
v2.5.0 hasn't shipped to any production DB yet — so this doesn't violate
the "never rewrite committed instance commands" rule in spirit.

### Note on the previous two PRs

#20581 and #20583 narrowed `select` on `WorkspaceEntity` for
`isInternalMessagesImportEnabled`. That's a band-aid that works because
there's a small, enumerable set of bare `workspaceRepository.findOne`
call sites. It could in principle be replaced with the same pattern as
this PR (early 2.x instance command that adds the workspace column). Not
doing that here to keep the diff tight, but happy to follow up if
preferred.

## Test plan

- [ ] Re-run twenty-infra cross-version-upgrade CI and confirm 2.3
workspace commands complete
- [ ] Verify the new 2.3 instance command and the modified 2.5 instance
command are both idempotent (running upgrade twice should not error)
- [ ] Verify a fresh install path still ends with `subFieldName` present
on `core.viewSort`
2026-05-14 16:02:53 +02:00