Commit Graph

117 Commits

Author SHA1 Message Date
neo773 1e58c3073c Feat/email composer improvements (#23188)
- Move composer to dedicated page
- Add test email option
- Auto saved as draft can be revisited from `objects/messageCampaigns`
later
- Campaign stats component



https://github.com/user-attachments/assets/9e523116-e79b-496d-9c9d-3887e0c9213f



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23188?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-07-28 13:13:00 +02:00
Weiko 19903f89e5 Add indexes for channel webhook subscription external IDs (#23386)
## Context

Incoming Microsoft messaging, Microsoft calendar, and Google calendar
webhook notifications resolve their channel through
`webhookSubscriptionExternalId`.

The column was added without an index, so PostgreSQL has to scan the
corresponding channel table for every notification. Under sustained
webhook traffic, these repeated scans add unnecessary database work and
keep core database connections occupied longer.

## What changed

- Add a partial B-tree index on
`messageChannel.webhookSubscriptionExternalId`
- Add the equivalent index on
`calendarChannel.webhookSubscriptionExternalId`
- Register both indexes in the TypeORM entity metadata
- Add an idempotent 2.25 fast instance upgrade command to create and
remove them

The webhook handlers and their queries remain unchanged.

## Why this design

- The indexes contain only non-null subscription IDs, channels without
an active subscription do not add index entries
- A single-column index supports both the equality lookup used by Google
and the `IN` lookup used by Microsoft
- The indexes are intentionally non-unique, this preserves existing
behavior and avoids making the upgrade fail if historical duplicate
values exist
- Subscription IDs are read much more often than they are updated, so
index maintenance overhead should be negligible

## Expected impact

Webhook channel resolution should require a targeted index lookup
instead of a table scan. This reduces database work, shortens connection
occupancy, and improves latency on webhook notification paths.

This is a targeted database optimization. It complements the database
pool changes, but is not expected to resolve every source of API tail
latency by itself.

## Validation

- Server typecheck passes
- Oxlint and formatting checks pass
- Upgrade command uses idempotent `CREATE INDEX IF NOT EXISTS` and `DROP
INDEX IF EXISTS` statements

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23386?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. -->
2026-07-28 07:54:15 +00:00
Raphaël Bosi 56245a35af Stop leaking the refresh token in the social SSO redirect URL (#23061)
The Google/Microsoft callback for a sign-in with no target workspace
redirected to `/sign-in-up?tokenPair={...}`, putting a 60-day refresh
token in a query string. Those persist in browser history, `Referer`
headers and access logs.

It now carries a single-use, 5-minute opaque token in the URL fragment,
which the frontend exchanges over POST. Browsers never send the fragment
on the wire, so the token stays out of access logs, proxies and
`Referer` headers entirely. Redemption claims the row with a `DELETE`
guarded on `revokedAt`/`deletedAt` being null, so concurrent requests
cannot each mint a refresh token and a revoked token cannot redeem.
Enterprise SSO (OIDC/SAML) already used a POST exchange and is
unchanged.

```mermaid
sequenceDiagram
  participant Browser
  participant Server
  participant DB

  Note over Browser,Server: before, the redirect carried access + 60-day refresh in ?tokenPair
  Browser->>Server: GET /auth/google/redirect
  Server->>DB: store sha256(token), expires in 5 min
  Server-->>Browser: 302 /sign-in-up#ssoExchangeToken=opaque
  Note over Browser: fragment never sent back to any server
  Browser->>Server: POST getAuthTokensFromSSOExchangeToken
  Server->>DB: guarded DELETE, single-use claim
  Server-->>Browser: access + refresh token, in the response body
```

Since the token is single-use, the refresh token is minted at redemption
instead of at callback, so an abandoned redirect leaves an inert expired
hash rather than a live credential.

Redemption lives in its own `SignInUpSSOExchangeTokenEffect` +
`useRedeemSSOExchangeToken`, mirroring the existing
`VerifyLoginTokenEffect` + `useVerifyLogin` pair, so
`SignInUpGlobalScopeFormEffect` only loses the vulnerable branch. Like
`useVerifyLogin`, the hook clears any stale token pair before
exchanging. The effect reads `window.location.hash` live and strips it
synchronously, which doubles as the StrictMode double-invocation latch.

Remaining exposure is the browser itself (history until the synchronous
strip, client-side scripts), same as any fragment-based OAuth response.
`loginToken` on the workspace-targeted branch still travels as
`/verify?loginToken=` and is replayable for 15 minutes; moving it to the
fragment too is a separate change.

A fast instance command adds a unique partial index on `("type",
"value")` for live SSO exchange tokens, so redemption is an index lookup
instead of a full scan of the shared token table and at most one row can
ever match.
2026-07-27 12:58:42 +00:00
neo773 3fb29db28a Feat/email settings v2 (#23180)
Settings pages changes

- Add `displayName`
- Unsubscribers Page

<img width="1496" height="844" alt="Screenshot 2026-07-22 at 8 52 15 PM"
src="https://github.com/user-attachments/assets/69bc1993-4547-4a64-83a6-b47fef1a4e40"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23180?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-07-26 22:48:30 +02:00
Etienne 3a1067ec6d fix(server): index page-layout FKs to fix workspace cleanup timeout (#23289)
The cleanSuspendedWorkspacesJob cron timed out every run (Sentry monitor
"a timeout check-in was detected"): hard-deleting soft-deleted
workspaces hung on `DELETE FROM core.pageLayout`, hit the 10s query
timeout, rolled back, so those workspaces were never destroyed and got
retried hourly.

Root cause: the FKs in the pageLayout -> pageLayoutTab ->
pageLayoutWidget tree had no usable index on the referencing column. The
existing indexes lead with workspaceId and are partial ("deletedAt" IS
NULL), so ON DELETE CASCADE / SET NULL fell back to full sequential
scans of the shared core tables per deleted row; on layout-heavy
workspaces this exceeded 10s.

- Add non-partial FK-column indexes on pageLayoutTab(pageLayoutId) and
pageLayoutWidget(pageLayoutTabId)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23289?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. -->
2026-07-24 16:50:57 +00:00
Paul Rastoin bc7922da7d fix: move add-agent-foreign-key-to-role-target instance command to 2.25 (#23285)
## What

Moves the `add-agent-foreign-key-to-role-target` fast instance command
from `2.24.0` to `2.25.0`.

Introduced in #23206, the command was registered under version `2.24.0`.
Since `TWENTY_CURRENT_VERSION` is now `2.25.0`, `2.24.0` is an
already-released version, so its instance commands do not re-run on
upgrade and the foreign-key migration would never execute.

This is the same issue #23271 fixed for the message-list-members
backfill workspace command.

## Changes

- Moved the command file from `upgrade-version-command/2-24/` to `2-25/`
(renamed the file prefix).
- Updated the decorator from `@RegisteredInstanceCommand('2.24.0', ...)`
to `('2.25.0', ...)`.
- Updated the import in `instance-commands.constant.ts` to the new
relative path and reordered both the import and the array entry to sit
after the 2-24 commands.

The timestamp (`1784820332810`) and command logic (`up`/`down`) are
unchanged.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01KpCb9BK1eoM1WWXJU6g95e)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23285?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. -->
2026-07-24 15:32:33 +00:00
Paul Rastoin ba5cb6ba15 fix(server): repair missing keyValuePair.applicationId on 2.23 upgrades (#23272)
Fixes #23254

## Problem

Upgrading a self-hosted instance from `2.23.x` to `2.24.0` leaves
`core.keyValuePair` without the `applicationId` column. Database-backed
config loading then fails on startup and on every refresh (~every 15s)
with:

```
column KeyValuePairEntity.applicationId does not exist
```

The frontend shows "Unable to reach the backend".

## Root cause

`AddApplicationIdToKeyValuePairFastInstanceCommand` was added in #23089
(after `2.23.x` shipped) but registered under the already-released
`2.23.0` segment:

```ts
@RegisteredInstanceCommand('2.23.0', 1784659343818)
```

The upgrade cursor is **positional and forward-only**:

- `resolveStartCursor` resumes at `lastAttemptedIndex + 1`. A
fully-upgraded `2.23.x` instance has its cursor at the last `2.23`
workspace command, which sits *after* this newly-inserted fast command
in the sequence. So the runner steps right over it and the DDL never
runs.
- The upgrade-aware metadata layer decides "applied" the same way
(`stepIndex < currentCursor` in
`upgrade-aware-entity-metadata.adapter.ts`). Since the step index is
below the cursor, the column is considered applied and is **not** hidden
from TypeORM SELECTs, so every query references a column that was never
created.

Fresh `2.24.0` installs replay the whole sequence, so only `2.23.x ->
2.24.0` upgrades are affected. The instance log `1 fast instance ... for
2.24.0` confirms the command landed in the `2.23.0` bundle rather than
`2.24.0`.

## Fix

- Add `RepairKeyValuePairApplicationIdFastInstanceCommand` under the
current version (`2.24.0`) with a fresh timestamp, so it sorts last in
the sequence and runs for every existing instance regardless of cursor
position. Its DDL mirrors the original command and is fully idempotent
(`ADD COLUMN IF NOT EXISTS`, `DROP INDEX IF EXISTS` + recreate, `ADD
VALUE IF NOT EXISTS`), so it is a no-op on healthy instances. `down()`
is intentionally empty: the column lifecycle is owned by the `2.23.0`
introduction command.
- Repoint the entity's `@WasIntroducedInUpgrade` to the new command so
the column stays hidden from queries until the repair has actually run,
eliminating the error window during the migration itself.

## Notes

- `2.24.0` (`TWENTY_CURRENT_VERSION`) is the correct target: the upgrade
sequence only covers previous + current versions, so a command under
`2.25.0` (a next version) would not run. If a version bump lands before
this merges, the command should be moved to the new current version.
- Follow-up worth considering: nothing currently prevents registering a
command under a version in `TWENTY_PREVIOUS_VERSIONS`. A startup
validation rejecting that would have caught this at PR time.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23272?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. -->
2026-07-24 13:46:59 +00:00
Abdul Rahman 2c79093b74 feat: agent roleUniversalIdentifier for manifest-driven role assignment (#23206)
## Summary
- Adds optional `roleUniversalIdentifier` on `AgentManifest` /
`defineAgent` so apps can declaratively assign a role to an agent (same
config shape as `defaultRoleUniversalIdentifier`).
- Wires `agentUniversalIdentifier` as a sync many-to-one FK on
`roleTarget`, and emits a deterministic `roleTarget` from the agent
during app sync (create / update / delete).
- Enables app agents (e.g. Slack assistant) to get a role on install
without postInstall hooks or manual admin assignment.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23206?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. -->
2026-07-24 05:57:40 +05:30
Paul Rastoin b91c2a6457 fix(server): repair missing applicationRegistration.logoFileId on upgraded instances (#23215)
## Problem

closes https://github.com/twentyhq/twenty/issues/23210

Self-hosted instances on 2.23.x fail their workspace upgrade with:

```
column ApplicationEntity__ApplicationEntity_applicationRegistration.logoFileId does not exist
at UpgradePeopleDataLabsApplicationCommand.runOnWorkspace
```

The `2-21` instance command that adds
`core."applicationRegistration"."logoFileId"` was merged ~20 minutes
after the 2.22 version bump (PR #22827, `94192a2164`), so it first
shipped in 2.22 while registered under
`@RegisteredInstanceCommand('2.21.0', ...)`.

The upgrade runner resolves its start position from the last recorded
command and only moves forward. Any instance that had already run a
2.21.x binary has its cursor past that slot, so the command is skipped
permanently and the column is never created.
`UpgradeAwareEntityMetadataAdapter` decides column visibility
positionally (`index < currentCursor`), not by whether the command
actually ran, so it keeps `logoFileId` in the SELECT list and the
instance reports "Up to date" while the column is absent.

**Affected:** instances that ran 2.21.x, then upgraded to >= 2.22.
Instances that went from <= 2.20 straight to >= 2.22 replayed the full
sequence and are fine.

`logoFileId` is populated lazily by design (NULL is a supported state),
so no backfill is added.

## Changes

**1. Idempotent DDL guard in the failing workspace command**

`2-23-workspace-command-...-upgrade-people-data-labs-application.command.ts`
now ensures the column exists at the top of `runOnWorkspace`, before the
`findOne` that crashes on affected instances. It uses the core
`DataSource` (`@InjectDataSource()`) because
`core."applicationRegistration"` is instance-global, guards with a
per-process boolean in addition to the SQL-level `IF NOT EXISTS`, and
copies the full statement list (column + unique + FK constraints)
verbatim from the 2.21 command. In dry-run it probes
`information_schema.columns` and returns instead of running the crashing
query.

**2. Fast instance command in 2.23**
New
`2-23-instance-command-fast-1784823473532-add-logo-file-id-to-application-registration.ts`,
registered at the end of the 2.23 fast segment (highest timestamp),
running the same idempotent DDL. This covers the normal 2.22 -> 2.23
path and, critically, instances with zero provisioned workspaces where
the workspace command body never executes. The shared DDL lives in
`2-23/utils/ensure-application-registration-logo-file-id-column.util.ts`
so both paths stay byte-for-byte identical. Class name follows the
`Early2_4` / `Early2_5` precedent to avoid colliding with the 2.21
command.

The fix lives entirely in 2.23: instances stuck at the failing workspace
command retry it every run, and 2.22 -> 2.24 jumps still replay the 2.23
segment.

## Ops note

Instances failing right now can be unblocked immediately by running the
same `ALTER TABLE` block by hand against their core database
(byte-for-byte what the command does). Worth including in the 2.23 patch
release note.

## Verification

- New fast instance command re-slotted last in the 2.23 fast segment
(timestamp `1784823473532` > current max `1784659343818`).
- Manual repro path: boot `twentycrm/twenty:v2.21`, seed, stop, run
`upgrade` from this branch, assert the column exists and
`upgrade:status` reports 0 failed. The default v1.22 baseline does not
reproduce it (replays from cursor 0).

---
_Generated by [Claude
Code](https://claude.ai/code/session_01YAuDR585cx7FyAKoiT32j3)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23215?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: Paul Rastoin <paul.rastoin@gmail.com>
2026-07-23 17:12:32 +00:00
Weiko 66df0ac47c Switch application stop/start commands to Redis-backed global kill switch (#23202)
## Context

[#23183](https://github.com/twentyhq/twenty/pull/23183) introduced the
right enforcement point: every logic-function execution is rejected
centrally before consuming the shared workspace throttle when its
application is stopped.

However, its server-wide path reads PostgreSQL for every execution
attempt. A kill switch is most useful while an application is producing
abnormal load, potentially while PostgreSQL is already under pressure.
The enforcement mechanism should not add more database traffic in that
situation.

This state is also operational and temporary. It is used to troubleshoot
an application, not as durable application configuration.

## What this PR changes

- Uses one global Redis key per application universal identifier:

  ```text
  module:applications:kill-switch:{applicationUniversalIdentifier}
  ```

- Keeps the check in `LogicFunctionExecutorService`, before the
workspace execution throttle.
- Adds a 60-second process-local cache for both present and absent keys.
- Deduplicates concurrent cache refreshes, so an execution burst causes
at most one Redis read per application and process.
- Fails open when Redis cannot be read and caches that result for the
same minute, avoiding a Redis retry storm.
- Removes the database columns, upgrade command, workspace-cache
recomputation, registration lookup, and stop/start CLI commands
introduced by #23183.
- Keeps disabled queued executions non-retriable, without emitting one
warning for every skipped payload.

The switch is operated directly in Redis. For example:

```redis
SET module:applications:kill-switch:{applicationUniversalIdentifier} 1 EX 3600
DEL module:applications:kill-switch:{applicationUniversalIdentifier}
```

Any value means stopped; deleting or expiring the key means enabled.

## Why this is a better fit

| | #23183 | This PR |
|---|---|---|
| State | Durable PostgreSQL fields | Ephemeral Redis key |
| Server-wide hot path | PostgreSQL lookup per execution | At most one
Redis lookup per app/process/minute |
| Scope | Workspace and application registration | Application universal
identifier across all workspaces |
| Operational cleanup | Explicit start command | `DEL`, eviction,
restart, or operator-selected TTL |
| Database dependency during an incident | Required | None |

The trade-off is deliberate: a Redis change can take up to 60 seconds to
reach every process, and the switch is lost when the cache key
disappears. That is acceptable for a temporary troubleshooting control
and keeps the normal execution path inexpensive.

Existing in-flight functions are not interrupted. New direct or queued
executions are rejected when they reach the executor.
2026-07-23 12:29:22 +00:00
martmull a3f4acadb1 Add workspace and server level stop commands for applications (#23183)
## Context

When an installed application misbehaves (e.g. a logic function loop
DDoSing the server or the database), we currently have no targeted way
to shut it down in production: the only kill switch is
`LOGIC_FUNCTION_TYPE=DISABLED`, which disables logic functions for the
whole instance. This PR adds an emergency stop mechanism at two levels:

- **Workspace level**: stop one installed application in its workspace.
- **Server level**: stop every application installed from an
`applicationRegistration`, across all workspaces.

## How it works

**New nullable `stoppedAt` columns** on `core.application` and
`core.applicationRegistration` (fast instance command `2.24.0`, with
`up`/`down` and `@WasIntroducedInUpgrade` decorators on the entities).

**Enforcement in a single choke point**:
`LogicFunctionExecutorService.execute()` is the funnel behind every
execution path (public route triggers, server route triggers, cron
triggers, database event triggers, workflow actions, agent tool calls,
manual GraphQL execution, install hooks). A new
`assertApplicationNotStopped` guard runs right after the flat entities
are resolved and throws `LOGIC_FUNCTION_DISABLED` (already mapped to a
403 on route triggers and handled by the GraphQL exception handler)
when:
- `flatApplication.stoppedAt` is set (workspace-level stop, read from
the cached flat application maps: zero extra runtime cost), or
- the linked registration is stopped (one indexed PK lookup, same
pattern as the existing per-execution server-variable query).

**Propagation**: the workspace-level stop invalidates and recomputes
`flatApplicationMaps` for the workspace, so all server instances pick
the flag up within the local cache TTL (100ms). The registration-level
flag is read live, so it is effective immediately.

## Ops commands

```bash
# Workspace level
yarn command:prod application:stop -a <application-id>
yarn command:prod application:start -a <application-id>

# Server level (all applications of the registration, all workspaces)
yarn command:prod application-registration:stop -r <application-registration-id>
yarn command:prod application-registration:start -r <application-registration-id>
```

Each command logs what was stopped/started and, for registrations, how
many installed applications are affected.

## Notes

- Stopped executions fail fast at the guard, so queued trigger jobs
(cron/db-event) burn a negligible amount of work while stopped.
- The two flags are independent: lifting a registration-level stop does
not clear workspace-level stops that were set individually, and vice
versa.
- Unit tests added for `ApplicationStopService`.


---
_Generated by [Claude
Code](https://claude.ai/code/session_01CjEnKUACn89aSgK1wEMH2d)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23183?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. -->
2026-07-22 18:30:56 +00:00
Abdul Rahman 04d1c2035c feat(connections): run a logic function on connection provider connect (#23167)
## What

Adds an optional `onConnectLogicFunctionUniversalIdentifier` field to
the connection provider manifest. When set, the referenced logic
function is dispatched right after an OAuth connection is successfully
established for that provider.

This gives apps a first-class "on connect" hook — e.g. the Slack app can
resolve the workspace's `team_id` via `auth.test` and claim the `team_id
-> workspaceId` mapping in the SERVER key-value store immediately on
connect, instead of racing against later events.

Follow-up to the app key-value store PR (#23089).

## How

- **twenty-shared**: add `onConnectLogicFunctionUniversalIdentifier` to
`ConnectionProviderManifest`.
- **twenty-sdk**: expose the field in `defineConnectionProvider` and
validate it is a UUID `universalIdentifier`.
- **twenty-server**:
- add a nullable `onConnectLogicFunctionUniversalIdentifier` column to
`ConnectionProviderEntity` (+ fast instance command / migration).
  - map the field through the

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23167?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. -->
2026-07-22 17:38:01 +02:00
Abdul Rahman 4c4a154d31 key-value storage for applications (#23089)
## What

Key-value storage for applications, as proposed in
twentyhq/core-team-issues#2391 — built on the existing `keyValuePair`
entity:

- Nullable `applicationId` relation on `keyValuePair` + a new
`APPLICATION_VARIABLE` type (fast instance command included)
- GraphQL CRUD on the metadata schema (`appKeyValue`, `setAppKeyValue`,
`deleteAppKeyValue`), requiring an `APPLICATION_ACCESS` token —
`applicationId` always comes from the token, never from arguments, so
apps can't touch each other's entries
- `kv.get` / `kv.set` / `kv.delete` helpers in
`twenty-sdk/logic-function`

## Scopes

- **`INSTALL`** (default): entries are private to one workspace install;
arbitrary JSON values
- **`GLOBAL`**: entries are shared across every install of the app, with
claim semantics — the value is always the claiming `workspaceId` and
only that workspace can overwrite or delete the key (guarded writes,
race-safe via insert-if-absent)

Since `applicationId` identifies an install (one row per workspace),
GLOBAL entries are stored under the registration owner workspace's
install so all installs of the same app share one namespace.

The GLOBAL scope is what enables cross-workspace webhook routing: e.g.
the Slack app's `serverRoute` resolver (running in the owner workspace)
can resolve `kv.get('slack:team:' + team_id, { scope: 'GLOBAL' })` to
find the workspace that connected that Slack team — without a workspace
being able to hijack another's mapping.

## Follow-ups

- Wire the Slack assistant PR (#22984) to write the claim at connect
time and read it in the events resolver
- `kv.*` access from front components

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23089?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. -->
2026-07-22 11:19:44 +02:00
Etienne 5add5ea695 fix(billing) - expand billing sub uniqueness constraint (#23123)
fixes :
https://discord.com/channels/1130383047699738754/1528956737363771497

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23123?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. -->
2026-07-21 17:04:10 +00:00
Paul Rastoin 71a1ff7ac8 Cache twenty-client-sdk modules host-side via content-addressed URLs (#22981)
## Context

Front component sources are fetched host-side and integrity-verified by
the SHA-256 checksum embedded in their URL, cached in Cache Storage — a
layer that exists specifically because their download URLs are presigned
and rotate. The `twenty-client-sdk` modules (`core` and `metadata`) were
re-fetched on every render and could not be cached safely: their URLs
carried no checksum and the server exposed no freshness signal.

This PR makes the SDK module URLs **content-addressed** and relies on
the **browser HTTP cache** for immutability, and it keys the checksums
on their real owners: the **application** for `core`, the **instance**
for `metadata`. The checksum does double duty: cache invalidation
(regeneration changes the checksum → the URL changes → guaranteed cache
miss) and a server-side cacheability guard (the server only grants
`immutable` when the checksum in the URL matches the authoritative
checksum it knows for that module — persisted at generation time for
`core`, hashed once at bootstrap for `metadata` — so no per-request
hashing of the served bytes). Note this is **not** an end-to-end
integrity guarantee: there is no client-side hash verification, and on a
fingerprint mismatch the server still serves the current bytes with
`no-store` (self-healing for stale URLs) rather than failing.

<img width="2412" height="926" alt="image"
src="https://github.com/user-attachments/assets/d97935d2-0fdb-4c44-89ac-596b7ca8ca64"
/>

Closes twentyhq/core-team-issues#2688.

## Routes

| Module | URL | Scope |
| --- | --- | --- |
| `core` | `/rest/sdk-client/{applicationId}/core[/{checksum}]` | Per
application (generated bundle) |
| `metadata` | `/rest/sdk-client/metadata[/{checksum}]` |
**Instance-wide**: no application segment, so every application
converges on one URL and the browser downloads the module once per
release instead of once per application |

The previous application-scoped metadata path
(`/rest/sdk-client/{applicationId}/metadata[/{checksum}]`) is **kept for
backward compatibility**, new clients just stop generating those URLs.
The instance-wide route is declared before the parameterized route so
`metadata/{checksum}` is not swallowed as `:applicationId/:moduleName`.

## Caching model

| Request | `Cache-Control` | Effect |
| --- | --- | --- |
| Fingerprinted URL, checksum matches the known module checksum |
`immutable` | Cached indefinitely by the browser HTTP cache; a new
checksum is a new URL |
| Fingerprinted URL, checksum does not match | `no-store` | Current
bytes served uncached (self-healing for stale URLs) |
| Bare URL (pre-generation fallback, `core` only in practice) |
`no-store` | Never cached |

- Both responses also set `X-Content-Type-Options: nosniff` and
`Content-Type: application/javascript`.
- SDK modules are intentionally **not** placed in Cache Storage. That
layer stays reserved for the presigned/rotating component-source URLs;
SDK modules are served directly and authenticated, so the browser HTTP
cache (keyed by the content-addressed URL) is their single cache layer.

## Checksum provenance

- **core** — per **application**, persisted on
`application.sdkClientCoreChecksum` at generation time and read back
from `flatApplicationMaps` (never re-hashed per request).
- **metadata** — **instance-wide**, hashed once from the installed
`twenty-client-sdk/dist/metadata.mjs` package (warmed at bootstrap,
memoized per process) and served straight from that package, so it is
fresh from the first request after a release with no archive dependency.

## Server (twenty-server)

- Hash `dist/core.mjs` at SDK generation and persist
`sdkClientCoreChecksum` via `applicationRepository.update`. Adds the
nullable text column to `application.entity.ts` (mirroring
`packageJsonChecksum`) plus a fast instance command with up/down;
`FlatApplication` picks it up automatically.
- New **application-scoped** query
`applicationSdkClientChecksums(applicationId: UUID!):
SdkClientChecksums` on `ApplicationResolver` (metadata schema,
`WorkspaceAuthGuard` + `NoPermissionGuard`). `SdkClientChecksums.core`
is **nullable** and stays `null` until the SDK has been generated at
least once; `metadata` is **always present** (bootstrap-warmed), so the
metadata module is cacheable from the very first render of any app. The
query itself returns `null` only for unknown applications.
- `SdkClientChecksumsDTO` now lives in the shared
`core-modules/sdk-client/dtos/`. `FrontComponentDTO` and the
`frontComponent` resolver no longer carry checksums (decoupled from the
front-component row).
- `sdk-client` controller: instance-wide `metadata[/:checksum]` route
(no workspace-cache or application lookup, serves the memoized installed
module) + application-scoped `:applicationId/:moduleName[/:checksum]`
route (serves `core` from the per-application archive, `metadata` kept
for back-compat). Cacheability compares the URL checksum against the
**known** checksum — persisted `sdkClientCoreChecksum` for `core`,
memoized package hash for `metadata` — instead of hashing the served
bytes on every request: `immutable` on match, `no-store` otherwise (bare
URL or stale fingerprint), plus `nosniff`. A persisted checksum out of
sync with the archive only downgrades to `no-store` until the next
regeneration.

## Front (twenty-front)

- New metadata query `GetApplicationSdkClientChecksums`, keyed by
`applicationId`; removed the `sdkClientChecksums` selection from
`FindOneFrontComponent`.
- `getSdkClientUrls` builds the two module URLs independently:
`/sdk-client/{applicationId}/core/{checksum}` and the **instance-wide**
`/sdk-client/metadata/{checksum}` (no application segment → one shared
browser cache entry per release across all applications). Each falls
back to its bare URL when its checksum is absent — since `core` is
nullable, a never-generated app still gets a content-addressed metadata
URL and only `core` falls back. The checksum type is sourced from the
codegen `SdkClientChecksums` type rather than a hand-maintained
duplicate.
- `FrontComponentRenderer` is split into a gating outer component (runs
`FindOneFrontComponent`, renders nothing while loading) and a content
component that receives a guaranteed-non-null `frontComponent`.
Following project conventions, the side effects live in dedicated effect
components: `FrontComponentLoadErrorSnackBarEffect` (query error →
snackbar) and `FrontComponentApplicationTokenPairEffect` (mirrors the
query-derived token pair into component state unconditionally, `null`
included, so revoked credentials can never be retained or refreshed).
The content component fetches checksums via the application-keyed query
and **gates the mount of SDK-using components on that query**, so the
very first module fetch is always the content-addressed (`immutable`)
URL instead of the bare `no-store` one. Non-SDK components skip the
query and are never blocked.
- **Live invalidation without reload:** SDK regeneration updates the
application row, and the server broadcasts an `application` metadata
event carrying the new core checksum.
`useOnApplicationSdkClientChecksumsUpdated` /
`useUpdateSdkClientChecksumsApolloCache` patch the application-keyed
checksum query cache (core only; the instance-wide metadata is
preserved), so every mounted component of that application picks up the
new URL at once. This replaces the previous frontComponent-derived field
and closes the earlier "known gap" (a mounted component staying on a
session-old checksum until a full reload). The cache-patching callback
is memoized (`useCallback`) so the window listener is registered once
per application, and the listener is **skipped entirely** for non-SDK
components (`useListenToMetadataOperationBrowserEvent` gained a `skip`
option) — they register no listener and never refetch a query they don't
consume.

## Renderer (twenty-front-component-renderer)

- SDK sources are fetched through a dedicated plain authenticated fetch,
`fetchJavaScriptModuleSourceText` (Bearer header, `credentials:
'omit'`), instead of the Cache Storage `fetchComponentSource` path;
`fetchSdkClientSources` uses it. Execution stays exclusively in the
opaque-origin worker via blob URLs; the host only fetches and forwards
source strings (no hashing host-side). Staleness self-resolves through
the checksum: new checksum → new URL → cache miss.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22981?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. -->
2026-07-21 15:05:00 +00:00
Félix Malfait 3ad3e8bd1a feat: kanban, calendar and group-by table layouts for dashboard view widgets (#22963)
## Context

Dashboard view widgets previously only rendered flat tables. This PR
ships the full feature: **Table with group-by**, **Kanban**, and
**Calendar** layouts for dashboard view widgets — server API + frontend,
end-to-end. (Originally staged as a 4-PR stack — #22966, #22967, #22968
— consolidated here per review.)

## Server / API

- **View typing.** Adds `KANBAN_WIDGET` and `CALENDAR_WIDGET` to
`ViewType` (following the `TABLE_WIDGET` precedent) so widget-backing
views keep their layout in `view.type` while staying excluded from
record-index pickers. Shared `getViewLayoutFromViewType()` maps widget
types to their base layout; `isWidgetViewType()` centralizes the
exclusions that were previously hardcoded per-site.
- **Migrations.** Two fast instance commands (**2.23**): `ALTER TYPE
core.view_type_enum ADD VALUE` for both values, and a widened
`CHK_VIEW_CALENDAR_INTEGRITY` constraint covering `CALENDAR_WIDGET`
(entity `@Check` updated for fresh installs).
- **Validation.** `FlatViewValidatorService` keys kanban/calendar
validation on the mapped layout, so widget views get the same invariants
as index views (kanban needs a groupable group-by field; calendar needs
a date field + layout). Calendar widget views default to month; a
non-month (DAY/WEEK) layout is rejected at the API level **unless** the
`IS_CALENDAR_WEEK_VIEW_ENABLED` feature flag is enabled for the
workspace — the same flag that gates day/week on index calendars.
- **API.** `upsertViewWidget` (LAYOUTS permission) accepts a nested
`view` settings input (`type`, `mainGroupByFieldMetadataId`,
`shouldHideEmptyGroups`, kanban aggregate/column-width, calendar
layout/fields). Routes through the standard update path, so `viewGroups`
auto-generate from SELECT options exactly like index views. Only widget
view types accepted; only `RECORD_TABLE` widgets can change view
settings.
- **AI tools.** `create-complete-dashboard` + `create_view` now
use/allow the `*_WIDGET` types (previously they created plain `TABLE`
views that leak into index pickers).

## Frontend

**Settings panel.** The **Source** (object) row comes first, since which
layouts are available depends on it. The **Layout** row below is a
working dropdown (Table / Kanban / Calendar); layouts the source object
can't support are **disabled with a hint** ("Needs a Select field" /
"Needs a Date field") rather than hidden. Group-by row (select fields;
searchable) with a **Hide empty groups** toggle while grouped; **Date
field** row replaces Group by while Calendar is active, and — when the
`IS_CALENDAR_WEEK_VIEW_ENABLED` flag is on — a **Calendar view** row
(Day / Week / Month) appears beside it; **Limit** row hidden while
grouped (only the flat virtualized loader enforces it). Kanban keeps its
group-by locked (no `None` option).

**Instant edit-mode preview.** Draft snapshots carry `viewGroups`;
picking a group-by synthesizes them client-side
(`buildDraftViewGroupsForFieldMetadataItem`, mirroring the server&#39;s
generation), so grouped tables/boards preview immediately before
dashboard save. On save, `upsertViewWidget` responses hand back the
server-generated groups, which replace the client-generated ones in the
persisted snapshot.

**Renderers.** `RecordTableWidgetRendererContent` branches on the
backing view&#39;s layout: `RecordBoardWidget` (wraps the standard
`RecordBoardContainer`) and `RecordCalendarWidget` (mounts the existing
`RecordCalendar`, which renders month / day / week) inside the same
per-widget provider sandbox the table uses.

**Read-only semantics.** Two flags with distinct scopes, each documented
on its state:
- `isRecordBoardViewSettingsReadOnlyComponentState` — locks the board
chrome that edits view settings (add group, column reorder/resize/menu,
aggregates); **card drag still updates records** under object
permissions.
- `isRecordCalendarReadOnlyComponentState` — widget calendars are
read-only by default (no drag, no add-new, no in-calendar layout
switch); cards open the side panel. The one exception, behind
`IS_CALENDAR_WEEK_VIEW_ENABLED`: a **live (non edit-mode) day/week**
widget calendar allows drag-to-reschedule and record creation under
object permissions. Month calendars and edit-mode previews stay
read-only.

**Calendar state componentization.** The calendar module&#39;s three
settings move from global atoms to component states keyed on
`RecordCalendarComponentInstanceContext` (same pattern as record-board),
so several calendar widgets and an index-page calendar can coexist
without leaking state. All readers resolve the ambient instance;
calendar unit tests updated.

**Multi-instance fixes that also fix index pages:** record drag states
were written against a different instance than every reader resolves
(now use the ambient instance); the board sticky-header DOM id is
namespaced per board; dragged board cards portal to `document.body`
while dragging so react-grid-layout&#39;s transforms can&#39;t offset
the clone from the pointer.

## Scope (v1)

- Widget calendars are month-only and read-only by default. With
`IS_CALENDAR_WEEK_VIEW_ENABLED` enabled, day/week layouts become
selectable (UI + API) and live day/week widget calendars support
drag-to-reschedule and record creation under object permissions.
- Widget group-by offers SELECT fields only (server auto-generates
groups from options; widgets have no per-record add-group flow).

## Tests

- Integration: `upsert-view-widget-view-settings.integration-spec.ts` (9
tests — group auto-creation, invalid type/field rejections, non-month
calendar widget rejected while the week/day flag is off and accepted
once it&#39;s enabled, combined settings+fields call); pre-existing
`upsert-view-widget` suite (20) green.
- Front: new suites for draft view-group generation and snapshot
clone/build utils; calendar suites componentized; full `twenty-front`
jest, typecheck, oxlint green; `twenty-server` typecheck + lint green.
- Browser-verified end-to-end (real dev server + seeded workspace):
configure → live edit-mode preview → save → reload for all three
layouts; measured drag with pointer inside the card; index-page calendar
re-verified (with the week/day flag enabled).

https://claude.ai/code/session_01E5N87kwwZWhDtEQaP72cMf
2026-07-21 15:41:08 +02:00
martmull 8d84a0b9f3 feat(app): allow non-admin developers to claim and list marketplace apps (#22621)
## Context

Follow-up to #22609. Lets a non-admin developer claim ownership of a
public Twenty app they published to npm, then request a marketplace
listing that a server admin reviews. Marketplace state is per-instance
for now.

## Claiming

- Developer tab gets a **Claim an application** section: look up an
unclaimed npm app by package name or universal identifier.
- Ownership is proven with GitHub OAuth against the package's npm
provenance (trusted publishing): the connected account must own the
GitHub account or organization the package was published from.
- Errors from the GitHub callback come back as a code and are shown
inline with a link to the relevant documentation.
- The old one-click claim stays admin-only.
- A **Sync catalog** button triggers a catalog refresh instead of
waiting for the hourly cron.
- Gated behind the `IS_APP_CLAIMING_ENABLED` feature flag.

## Listing requests

- Catalog-synced apps are created **unlisted**; a data migration unlists
previously auto-listed unclaimed npm apps (owned or vetted rows are left
untouched).
- Owners request a listing from the Distribution tab (logo + description
required); a server admin approves or rejects it from a **Listing
requests** section in the Admin Panel.

## Screenshots

<img width="1512" height="829" alt="image"
src="https://github.com/user-attachments/assets/788d4362-97c4-4e42-810c-ef1f11517bec"/>
<img width="1512" height="829" alt="image"
src="https://github.com/user-attachments/assets/d6246190-c82a-4f64-87be-3bb668527645"/>
<img width="1512" height="828" alt="image"
src="https://github.com/user-attachments/assets/21a8dad4-610b-4d1f-8948-b9acab40d373"/>
<img width="1512" height="829" alt="image"
src="https://github.com/user-attachments/assets/58246130-41f7-451e-ae7f-57bd21d04bb6"/>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-07-20 15:43:40 +00:00
martmull baa84bb2e0 Add auto-upgrade-in-apps (#23001)
We need to auto upgrade application, lets add this column in application
entity, and add an admin button to autoupgrade all applications to
latest app registrration version manually

<img width="1131" height="372" alt="image"
src="https://github.com/user-attachments/assets/4e755abc-38ad-4895-a2e8-d55ee1948ac2"
/>

<img width="906" height="533" alt="image"
src="https://github.com/user-attachments/assets/ce002057-0341-4581-bf9a-66ac2bd84a9b"
/>
2026-07-20 16:12:44 +02:00
Charles Bochet 2e671342f5 [2/2] Write CREATED at activation, clean stale onboarding workspaces (#22915)
## Context

Follow-up to #22904 (merged), which introduced the `CREATED` activation
status (schema provisioned, onboarding incomplete — no billing
subscription), its enum migration, and the read path. This PR turns the
status on and closes the zombie-workspace leak (~60–110
subscription-less ACTIVE workspaces per day since v2 onboarding, 935+
total).

 **Deploy gating satisfied**: #22904's slow enum migration shipped with
the release deployed to prod on 2026-07-17, so writing `CREATED` is now
safe. Rebased on main (clean, no conflicts) — main's #22943/#22955
guarded-transition rework already handles `CREATED` correctly: the
webhook suspend switch only suspends `ACTIVE` workspaces, and
`reactivateWorkspace` promotes `CREATED`→`ACTIVE`.

## What this PR does

1. **Write path** — `activateWorkspace` sets `CREATED` instead of
`ACTIVE` when the workspace has no billing subscription.
`hasWorkspaceAnySubscription` returns true when billing is disabled, so
self-hosted workspaces keep going straight to `ACTIVE` — no behavior
change outside cloud.
2. **Cleanup** — `CREATED` joins `PENDING_CREATION`/`ONGOING_CREATION`
in the existing onboarding cleaning flow (cron +
`workspace:clean:onboarding` with `--dry-run`): workspaces older than
the same seven-day threshold are soft-deleted, then hard-deleted on a
later run. **No suspension step and no emails** — an abandoned
onboarding is treated as never having completed, exactly like a
workspace stuck in creation. A workspace that subscribes before cleanup
exits the flow (`CREATED`→`ACTIVE` synchronously via checkout).
3. **Backfill** — slow instance command moving `ACTIVE` workspaces with
no `billingSubscription` row, created since v2 onboarding shipped
(2026-07-01), to `CREATED`. Gated on `IS_BILLING_ENABLED` so self-hosted
instances are untouched.
4. **Resolves #22904's text-cast TODO on the billing activation update**
— this PR only deploys after the enum migration, so the
`CREATED`→`ACTIVE` promotion is a plain status-scoped update again. The
upgrade-path filters (`activationStatusIn`) keep the `::text` cast:
upgrade tooling has to run against databases coming from pre-2.22
versions, so its TODO now points at the real removal trigger (dropping
pre-2.22 upgrade support).

## Ops note before deploying

The backfilled zombies are all older than seven days, so the first cron
run after the backfill **soft-deletes them and the next run destroys
them (schema and data), with no user-facing communication**. The
backfill also catches any post-July-1 cloud workspace that is ACTIVE
without a subscription — including intentionally comped/demo/internal
ones if any were created since then (verified locally: the seeded demo
workspaces matched). **Run the backfill's SELECT as a dry-run against
prod and review the list before deploying.**

## CI note

~~`cross-version-upgrade` (and its `ci-server-status-check` aggregate)
is red due to a pre-existing regression on main — `Field metadata
"coreWorkflowVersionId" is missing in object metadata workflowVersion`
on the seed workspaces.~~ Resolved: the rebase picks up main's
#22944/#22961 which fixed that regression.

## Verification

Server-side (billing-enabled local instance, Stripe test mode) and
through the full onboarding UI in both billing modes:

- **Billing enabled, UI**: signup → workspace creation →
**`activationStatus: CREATED`** in DB mid-onboarding → profile/invite
steps work on the CREATED workspace → plan-required page → no-card trial
→ app loads, workspace **`ACTIVE`** with a `trialing` subscription
(exercises #22904's synchronous promotion).
- **Billing disabled, UI**: signup → workspace creation → **`ACTIVE`
directly**, no plan step anywhere, app loads — self-hosted behavior
unchanged.
- **Cleanup**: a `CREATED` workspace backdated 8 days is listed by
`workspace:clean:onboarding --dry-run`; the real run soft-deletes it
silently (no suspension, no email) and the next run hard-deletes it
(workspace row and schema gone).
- **Backfill**: synthetic `ACTIVE` no-sub workspaces — created
2026-07-05 flips to `CREATED`, created 2026-06-15 stays `ACTIVE`,
subscribed workspaces stay `ACTIVE`; billing-disabled short-circuit
returns without touching anything.
- Lint + typecheck green.
2026-07-17 11:26:46 +00:00
Charles Bochet 8e03921372 Add CREATED workspace activation status (read path + enum migration) (#22904)
## Context

Since v2 onboarding (#22303), workspaces are activated **before** the
billing plan step (now the last onboarding step). Users abandoning at
the plan step leave ACTIVE workspaces with a Stripe customer but no
subscription (~60–110/day on cloud, 935+ so far), and no cleanup
mechanism ever touches them: billing webhooks never fire (no
subscription), the suspended-workspaces cron only handles SUSPENDED, the
onboarding cron only handles PENDING_CREATION/ONGOING_CREATION.

Target lifecycle (across two PRs): `PENDING_CREATION → ONGOING_CREATION
→ CREATED → ACTIVE → SUSPENDED → deleted`.

**`CREATED`** = the workspace schema is provisioned but onboarding is
not complete — no billing subscription yet. It is **not** considered
active:

| Concern | CREATED behavior |
|---|---|
| Sign-in / invited teammates joining | allowed (invite-team step
precedes the plan step) |
| Member + metadata loading (app shell) | allowed (user must finish
onboarding) |
| Permissions | real permission checks (no PENDING-style bypass) |
| Version upgrades / workspace migrations | **included** (schema must
not drift) |
| Messaging/calendar/workflow/etc. crons | **excluded** — no background
processing until a plan is chosen |
| PLAN_REQUIRED onboarding lock | unchanged (still derived from
subscription existence) |

## What this PR does (read path only)

The enum addition ships as a **slow** instance command, which can run
after deploy — so nothing in this PR ever **writes** `CREATED`. The
write path (setting it at activation, the cleanup sweep, the backfill of
the existing zombie cohort) is a follow-up PR that ships once this
migration has run everywhere.

- **twenty-shared**: `CREATED` enum value;
`PROVISIONED_WORKSPACE_ACTIVATION_STATUSES` + `isWorkspaceProvisioned`
("schema exists": CREATED | ACTIVE | SUSPENDED), replacing
`isWorkspaceActiveOrSuspended` — all call sites (server member loading,
access-token workspace-member lookup, front metadata-store gates) meant
"has schema/members".
- **Slow instance command** (2.22.0): swaps
`core.workspace_activationStatus_enum` using the
rename→recreate→alter-column idiom. The CHECK constraints on
`core.workspace` embed casts to the enum type and would break the swap —
the command captures them from `pg_constraint`, drops them, swaps the
type, and restores them.
- **Pre-migration-safe queries**: Postgres rejects `IN ('CREATED', ...)`
when the enum value does not exist yet — even for reads, and the
instance-command runner itself queries provisioned workspaces before
migrating (a fresh database could never initialize). All
provisioned-status filters go through a new `activationStatusIn` util
comparing on `"activationStatus"::text`, valid before and after the
migration.
- **Upgrade path**: workspace iterator, command runner, upgrade-status
and workspace-version services iterate CREATED workspaces. Since they
now cover more than ACTIVE/SUSPENDED, the stale names were renamed to
`ProvisionedWorkspaceCommandRunner`, `hasProvisionedWorkspaces`,
`getProvisionedWorkspaceIds`, `loadProvisionedWorkspaces` (the
mechanical import rename in old version-command dirs is why this PR
carries the `ci:allow-previous-version-upgrade-mutation` label).
- **Sign-in**: `throwIfWorkspaceIsNotReadyForSignInUp` accepts CREATED
so invited members can join during onboarding (join authorization itself
is unchanged — enforced upstream in `checkAccessForSignIn`);
`activateWorkspace` idempotent-retry accepts CREATED as a terminal
state.
- **Transitions out of CREATED** (only write ACTIVE — safe to ship now,
dead until the write path lands): the Stripe webhook reactivation branch
also promotes CREATED, and `syncSubscriptionToDatabase` promotes
synchronously; both gated on
`WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES` (Active/Trialing —
extracted from `shouldReactivateWorkspace`, behavior-preserving) so an
`incomplete` subscription created by the payment-intent flow before
payment never promotes the workspace.
- Deliberately untouched: all background crons, permission guards, JWT
strategy, PLAN_REQUIRED logic, admin panel (renders the raw status
string).

## Follow-up PR (after this migration has run)
1. `activateWorkspace` sets `hasWorkspaceAnySubscription ? ACTIVE :
CREATED` (billing disabled → always ACTIVE, self-hosted unchanged).
2. Cleanup: suspend CREATED workspaces older than N days (config var),
handing them to the existing suspended pipeline (warn → soft-delete →
destroy).
3. Backfill: cloud-only slow command moving ACTIVE workspaces with no
billingSubscription row (created since Jul 1) to CREATED.

## Verification
- Migration exercised against a real database via the command class: up
→ down → up; `enum_range` and `pg_get_constraintdef` checked after each
step (constraints restored against the new type, `DEFAULT 'INACTIVE'`
preserved).
- Pre-migration safety exercised for real: with the migration rolled
back (enum without CREATED), `run-instance-commands` — the exact
fresh-database CI path that failed before the `::text` fix — completes
cleanly.
- End-to-end with a workspace manually set to CREATED and the branch
server+front running: sign-in issues tokens, `currentUser` loads
workspaceMember(s), the full app loads with no console errors; GraphQL
returns `activationStatus: CREATED`.
- Workspace creation ran end-to-end locally in **both billing modes** on
this branch:
- billing disabled: signup → workspace creation → ACTIVE immediately →
onboarding completes with no plan step → app loads (unchanged behavior);
- billing enabled (Stripe test mode): signup creates the Stripe customer
eagerly → activation ends ACTIVE → subscription-less workspace is pinned
to the plan-required page → no-card trial checkout creates a `trialing`
subscription via `createDirectSubscription`/`syncSubscriptionToDatabase`
→ app loads.
- `twenty-shared` unit tests, server specs on touched services,
`lint:diff-with-main` and `typecheck` for shared/server/front all green;
full CI green.
2026-07-15 17:03:17 +02:00
Weiko 25bd2897a3 Add weekly layout to record calendar (#22819)
## Summary

- Add a week layout to record calendar views and persist the selected
layout.
- Render `DATE` calendars as an all-day week and `DATE_TIME` calendars
as an hourly week.
- Add an optional end date field across calendar configuration,
metadata, persistence, and complete-view upserts.
- Use configured end values for ranged and multi-day events, with a
one-hour fallback when a `DATE_TIME` end is absent or invalid.
- Keep calendar cards consistent with the existing compact view,
including checkbox selection and whole-card record opening.
- Gate the weekly layout and end-date behavior behind the public Labs
`IS_CALENDAR_WEEK_VIEW_ENABLED` workspace feature flag.

## Week interactions

- Show overlapping timed events side by side and cap the visible records
at two per day.
- Display start and end times on timed cards, enforce a readable
30-minute minimum height, and keep today’s text contrast stronger.
- Drag timed events between days and times with 30-minute snapping while
preserving their duration, including zero-duration events.
- Show a create button when hovering a 30-minute slot; keyboard users
can focus a day, move the slot with the arrow keys, and reach the same
contextual action.
- Initialize new records with the selected slot time and a compatible
writable end value one hour later.
- Show the workspace time zone and current-time indicator in timed
weeks; date-only weeks keep the all-day section without an hourly grid.

## Configuration and data loading

- Only allow end fields that match the start field type, and prevent
selecting the same field for both boundaries.
- Load records whose ranges overlap the visible period so month and week
layouts display the same relevant records.
- Resolve and persist calendar end fields when updating existing views
through `upsert_complete_view`.
- Fall back to Month and ignore the configured end field while the flag
is disabled, without overwriting either persisted setting, so
re-enabling restores the previous configuration.
- Expose the flag in Labs and keep it default-off for workspaces without
a stored value; enable it in the development seeder.

<img width="1285" height="808" alt="Screenshot 2026-07-15 at 15 50 17"
src="https://github.com/user-attachments/assets/b7e3f7f1-ca77-492f-8cce-cca186ebca0b"
/>
2026-07-15 16:30:18 +02:00
martmull 94192a2164 Resolve application registration logo and gallery image urls at query time (#22827)
## Context

Application registration logo and gallery image urls were baked into the
stored manifest and display columns at write time, with each source flow
doing it differently: npm catalog sync baked CDN urls, local dev sync
baked `public-assets` urls, and tarball uploads left raw manifest paths
that never displayed in the UI. The entity also carried a `logoUrl`
getter computed field.

This moves url generation to query time, the same way the workspace logo
works.

## What changed

**Read side**
- `ApplicationRegistrationAssetUrlService` builds display urls when
queried: stored files are served by fileId, absolute urls pass through
untouched, and not-yet-rehosted npm assets fall back to the registry CDN
from `sourcePackage@latestAvailableVersion`.
- The `logoUrl` getter on `ApplicationRegistrationEntity` is replaced by
`logoUrl` and `galleryImages` `@ResolveField`s on the metadata resolver,
the admin panel resolver, and a new resolver for
`ApplicationRegistrationSummary` (used by
`Application.applicationRegistration`).
- The marketplace detail/card DTOs and the public OAuth authorize DTO
(`findApplicationRegistrationByClientId`) go through the same url
builder.
- New public route `GET /file/application-registration/:id` streams
registration server files (these are instance-global marketplace assets,
also shown on the public OAuth authorize page).
`ServerFileStorageService.readServerFileById` now returns the mime type
alongside the stream.

**Write side**
- New `logoFileId` column on `applicationRegistration` (2.21 fast
instance command, constraint names match TypeORM naming), complementing
the fileIds already stored in the `galleryImages` jsonb.
- `ApplicationRegistrationAssetService` copies the manifest logo and
gallery images into instance-global server file storage, so all three
sources behave the same:
- **TARBALL**: from the uploaded package (previously only gallery images
were stored, never the logo).
- **LOCAL**: dev sync reads the already-uploaded public assets from
workspace storage (the CLI uploads files before syncing).
- **NPM**: catalog sync downloads the assets from the registry CDN.
Downloads are skipped when the package version is unchanged and the
files are already stored; failed or pending downloads fall back to CDN
urls at query time.
- Write-time url rewriting is removed
(`ManifestAssetUrlResolverService`, `resolveManifestAssetUrls`);
manifests now keep raw asset paths. Existing rows with baked absolute
urls keep working through the absolute-url passthrough, so no backfill
is needed.
- `updateFromManifest` and `upsertFromCatalog` preserve stored gallery
fileIds for unchanged paths, so installs and the hourly catalog sync no
longer clobber them.

## How it was verified

Against a local Postgres/Redis with the server running:
- Fresh database init runs the new instance command; column and FK/UQ
constraint names match TypeORM's generated names, and the CI
pending-migration check produces no diff.
- `findManyApplicationRegistrations { logoUrl galleryImages }` returns
fileId-served urls for a TARBALL registration (absolute urls passed
through), and null/[] for a LOCAL registration without assets.
- Ran `marketplace:catalog-sync` against the real npm registry: 14
packages synced, logos and gallery images rehosted from unpkg with
fileIds set; a second run re-downloaded nothing (version-unchanged
skip); `findMarketplaceAppDetail` for `twenty-linear` returns
fileId-served urls for the logo and all four gallery images.
- `GET /file/application-registration/:id` serves stored files with the
right content type (png and svg verified), 404s on unknown ids, and the
token-guarded generic `/file/:folder/:id` route still returns 403
without a token.
- Unit tests for the url builder and the assets-stored check; server
unit test suites for the application module pass; typecheck and lint
clean.
2026-07-13 17:09:34 +02:00
Paul Rastoin de75be16e2 harden(server): backfill and enforce workspace.databaseSchema invariant with a check constraint (#22855)
## What & why

`core.workspace.databaseSchema` is meant to be set for every workspace
past the creation phase. It only started being written at creation time
in 2.x (dual-write since 2026-03-28, direct write since 2026-04-10);
older workspaces relied on the `1-21 backfill-datasource-to-workspace`
instance command, which never effectively ran on some instances. On
affected rows the column could be left `NULL`.

A null value on a post-creation workspace is a real integrity problem —
several paths trust the column:

- **REST API**: `hydrateRestRequest` throws `No data sources found` for
authenticated requests.
- **GraphQL API**: `getOrComputeSchemaSDL` returns `null`, so
`WorkspaceSchemaFactory` hands back an empty schema.
- **GraphQL introspection** (direct execution) returns `null`.

This PR makes the invariant impossible to silently violate, and repairs
any instance still lagging.

### On the original "No data source, skipping" logs

This investigation started from `BackfillActorSourceEnumValuesCommand`
logging `No data source for workspace <id>, skipping` at high volume.
**That symptom is not explained by this change, and this PR is not a fix
for it.** Findings:

- The workspace iterator only processes `ACTIVE` + `SUSPENDED`
workspaces, and on the affected instance all of those already have
`databaseSchema` set (only `PENDING_CREATION` rows are null, and those
are never iterated).
- `getGlobalWorkspaceDataSource()` never resolves to `undefined` (it
returns a value or throws), so a defined-schema workspace should never
hit the skip branch.
- The upgrade-aware repository proxy was investigated as a possible
cause (it can short-circuit `findOne` to `null` for entities marked
unavailable during an upgrade) and **exonerated**: `WorkspaceEntity` and
its `databaseSchema` column carry no
`@WasIntroducedInUpgrade`/`@WasRemovedInUpgrade` decorators, so
`resolveEntityShapeAtUpgradeCursor` always reports the entity available
and the column visible at every cursor.

In other words, current code should emit zero such skips for that
instance's data, so the root cause of the observed logs remains
undetermined and is tracked separately. See
twentyhq/core-team-issues#2666.

## Changes

- **Check constraint `workspace_requires_database_schema`** (the core of
this PR): enforces `databaseSchema IS NOT NULL` for any workspace past
creation (`activationStatus NOT IN ('PENDING_CREATION',
'ONGOING_CREATION')`). Declared on `WorkspaceEntity` and applied in the
slow instance command's `up()`. Safe against the creation flow:
`databaseSchema` is written in `WorkspaceManagerService.init` (right
after schema creation) long before a workspace becomes `ACTIVE`.
- **Defensive backfill** (`2-21` slow instance command): repopulates
`databaseSchema` where it is `NULL`/empty, deriving the schema name
deterministically from the workspace id (`getWorkspaceSchemaName`) and
only setting it for workspaces whose schema actually exists in
`information_schema.schemata` (so `PENDING_CREATION` rows without a
provisioned schema are left untouched, and stay exempt via the
constraint). No-op on instances already backfilled.
- `runDataMigration` runs before `up()`, so the backfill repairs legacy
rows before the constraint is enforced. Keeping both in the same slow
command (rather than a standalone fast command) guarantees the
constraint is never added ahead of the repair.
- `checkSchemaExists` gets an explicit `: Promise<boolean>` return type.

## Notes

- Backfill + constraint live in a **slow** instance command, so they
only apply on upgrades run with `--include-slow`.
- The constraint is added **`NOT VALID`**: the backfill repairs every
workspace whose Postgres schema exists, but some legacy active/suspended
workspaces (e.g. carried over from very old versions, as reproduced by
the cross-version upgrade from v1.22) have a null `databaseSchema` with
no schema to point at and are unrepairable. `NOT VALID` enforces the
invariant on all future inserts/updates without failing the upgrade on
that pre-existing corruption.
- No production request path was changed — the iterator and
`checkSchemaExists` keep trusting the (now backfilled + constrained)
column.

## Test plan

- [ ] Run `database:migrate:prod --include-slow` on an instance with
null `databaseSchema` rows; verify rows whose schema exists get
backfilled and `PENDING_CREATION` rows are left null.
- [ ] Verify the `workspace_requires_database_schema` constraint exists
on `core.workspace` and rejects nulling `databaseSchema` on an active
workspace.
- [ ] Verify a fresh workspace creation still succeeds (constraint does
not fight the `PENDING_CREATION` → `ACTIVE` transition).
2026-07-13 13:12:43 +00:00
martmull 23cae2040a Improve application asset management (#22564)
App manifests could point the logo and screenshots at either external
URLs or public folder paths, and that was handled inconsistently across
install, sync and the marketplace.

This makes assets always bundled files:

- Manifests now use `logo` and `galleryImages` (a `string[]` of public
folder paths) instead of `logoUrl` and `screenshots`. The old fields
still work but are deprecated. Gallery order comes from the array index.
Normalization (deprecated-field migration, and warning about + ignoring
external URLs) happens in `defineApplication`, so the warnings surface
at define time.
- Logo is stored as a File record (`logoFileId`).
- The registration gallery is configured via a `settings` jsonb column
on `applicationRegistration` (`{ galleryImages: string[] }`) — populated
from the manifest, read by the marketplace detail (falling back to the
legacy `screenshots` column, then the manifest). No dedicated gallery
table.
- The marketplace detail DTO and front now use `galleryImages`.

Verified against a local Postgres: the fast instance commands run with
no pending-migration diff, the schema is correct, and the server boots.
Typecheck, lint, codegen and the application unit tests pass.

Not included yet: rehosting assets into storage for npm catalog and
tarball registrations, versioned cache busting on the serving route, and
a backfill for existing installs.

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22564?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>
2026-07-10 09:18:52 +00:00
Thomas Trompette b327ab09a7 feat(workflow): add universalIdentifier + applicationId to core workflowVersion (#22747)
## workflowVersion core: syncable columns

Adds `universalIdentifier` + `applicationId` (nullable) to
`core.workflowVersion`, plus the FK to `core.application` and the
`(workspaceId, universalIdentifier)` unique index, via a 2.20
add-columns fast command gated with `@WasIntroducedInUpgrade`.

Nullable for now: the already-merged Phase A backfill (#22663) inserts
version rows without these columns, so `applicationId` can't be NOT NULL
yet. Flipping to NOT NULL + `extends SyncableEntity` comes once they're
populated (backfill + dual-write follow-ups).

Schema captured and verified via `migrate:generate` (zero drift).

Independent of the core-workflow PR, but both add 2.20 upgrade commands,
so this one (ts `…480`) must merge **after** the core-workflow PR (ts
`…479`), or it gets re-timestamped on rebase.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22747?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. -->
2026-07-10 10:26:52 +02:00
Thomas Trompette 6210389221 feat(workflow): add core workflow entity (syncable) + create-table (#22746)
## Core `workflow` entity (syncable)

Part of the app-workflows work. Adds a core `WorkflowEntity extends
SyncableEntity` (`name`, `lastPublishedVersionId`, plus
`universalIdentifier`/`applicationId`/workspace from the base class) and
its 2.20 create-table fast command, gated with
`@WasIntroducedInUpgrade`.

Schema was captured and verified via `migrate:generate` (zero drift,
FK/index hashes correct), then run against a live DB.

Backfill (populate from workspace `workflow` records) and the dual-write
listener land in follow-ups.

Independent of the version-syncable-columns PR, but note: both add 2.20
upgrade commands, so this one (ts `…479`) must merge **before** the
version PR (ts `…480`) to satisfy the append-only guard.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22746?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. -->
2026-07-09 18:30:45 +02:00
Paul Rastoin 60fd322b49 Centralize system field side effects + search field metadata (#22594)
## Introduction

Closes twentyhq/core-team-issues#2635 and twentyhq/core-team-issues#2642
and twentyhq/core-team-issues#2589

Object system fields (`searchVector` + its GIN index +
`searchFieldMetadata`, the reserved system fields, default relations)
were provisioned through several scattered, path-specific code paths. As
a result the **app-manifest sync path** authored objects with an
empty/`NULL` `searchVector` and **zero `searchFieldMetadata`**, so
app-owned objects shipped a broken generated search column (see #22657).
The generation logic also lived partly in imperative services rather
than in the metadata side-effect engine, and relied on non-deterministic
(`v4`) universal identifiers that `twenty apply` could not converge,
destroying manually backfilled rows.

This PR centralizes every object-creation system side effect into the
**metadata side-effect engine**, extends the engine to keep search
metadata consistent on field delete and object relabel, makes the
standard app's search identifiers deterministic, and ships upgrade
commands to reconcile existing workspaces.

## What changed

### Side effects moved into the metadata side-effect engine

New dedicated, self-contained handlers — so every write path (API and
app manifest) gets identical results, and side effects never trigger
other side effects.

**Object create / delete** (`handlers/object-metadata`)

* **`objectSystemFieldsOnCreate`** — generates the 7 reserved system
fields (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`,
`updatedBy`, `position`).
* **`objectSearchVectorOnCreate`** — provisions the full-text search
surface as one unit: the `searchVector` `TS_VECTOR` field, its backing
GIN index, and the `searchFieldMetadata` row (for searchable objects
whose label identifier is a searchable field) that keeps `searchVector`
populated instead of `NULL`.
* **`objectSystemSideEffectsOnDelete`** — tears the above down on object
deletion.

**Search-metadata consistency on relabel / field delete** (new — these
are what close the manifest-path gaps)

* **`objectSearchVectorOnUpdate`** (`handlers/object-metadata`) — when a
searchable object is relabeled onto a new searchable field, provisions
the `searchFieldMetadata` row that indexes it. Relabeling is
**additive**: existing rows (e.g. the provisioned `name` row) are
preserved, so the previous label identifier stays searchable. Mirrors
the API update path so a manifest re-sync that changes the label
identifier reaches search parity. No-ops for junction objects (`id`
label identifier) and non-searchable field types.
* **`fieldSearchFieldMetadataOnDelete`** (`handlers/field-metadata`) —
when a field is deleted, cascade-deletes every `searchFieldMetadata` row
that indexes it. `searchFieldMetadata` is excluded from manifest
deletion inference, so this explicit cascade is what covers **both the
API and manifest paths** (the object-scoped DB cascade only fires on
object deletion). Uses the `searchFieldMetadataUniversalIdentifiers`
aggregator on the flat field for an O(k) lookup instead of scanning all
rows.

The **default `name` field and default relations are now caller-provided
default fields** (SDK autocomplete on the manifest path, input
transpiler on the API path) rather than system side effects — removing
duplicate name generation, the imperative
`build-default-*-for-custom-object` utilities, and the ad-hoc
system-field integrity validator.

### Deterministic identifiers for the standard app

The twenty-standard search GIN index and `searchFieldMetadata` now
derive deterministic universal identifiers
(`getIndexUniversalIdentifier` / `getSearchFieldUniversalIdentifier`)
instead of `v4`, so `twenty apply` converges instead of recreating.

### Upgrade commands (`2-20`) to reconcile existing workspaces

**Instance commands** (run once per instance; ordered fast → slow →
workspace):

1. **`AddIsSystemSideEffectToSearchFieldMetadata`** (fast) — adds the
`isSystemSideEffect` column to `core.searchFieldMetadata`. Defaults to
`true`, which also correctly backfills every existing row since
`searchFieldMetadata` is always system-derived (never user-authored).
2. **`BackfillNameFieldIsSystemSideEffect`** (slow) — re-flags existing
`name` fields from `isSystemSideEffect: true` → `false`, since the
default `name` field is now a caller-provided default like any other
user-owned field (it was provisioned as `true` in 2.15 → 2.19). This is
a pure data backfill, so the bulk `UPDATE` lives in `runDataMigration()`
rather than `up()` — keeping it out of the fast schema transaction
avoids holding an `ACCESS EXCLUSIVE` lock that could stall reads during
the deploy. Slow instance commands still run before every workspace
command of the version, so the fresh value is in place before the
search-reconcile workspace commands recompute the `fieldMetadata`
flat-entity cache. Scoping by name alone is safe (no engine-owned field
is named `name`); `down()` is best-effort (pre-2.15 `false` rows are
indistinguishable from flipped ones).

**Workspace commands** (idempotent, dry-run supported):

1. **`reconcile-search-vector-gin-index-universal-identifier`** —
re-owns every searchVector GIN index UID to its deterministic value (all
applications), then backfills the missing GIN index for installed-app
objects.
2. **`reconcile-search-field-metadata`** — re-owns every
`searchFieldMetadata` UID (all applications), then backfills the missing
rows for installed-app searchable objects.
3. **`rebuild-installed-app-search-vectors`** — rebuilds the
`searchVector` column of every installed-app `TS_VECTOR` field, once the
index and rows exist.

Design notes:

* **Re-own is global** (twenty-standard, workspace-custom, installed) —
a UID convergence keyed on each row's own application.
* **Backfill is installed-app only** — standard/custom objects already
have these rows via the manifest funnel.
* Re-own runs **before** backfill and is transaction-guarded; a failure
aborts that workspace to avoid a unique-identifier collision.

## Tests

* Integration: app manifest sync now asserts system fields + searchable
objects (searchVector, GIN index, searchFieldMetadata) are created; a
new relabel suite drives three manifest syncs and asserts records stay
searchable through the old + new label identifiers and lose
searchability when a field is removed; removed the obsolete
system-fields-integrity suite/snapshots.
* Unit: per-handler side-effect specs (including the new
`objectSearchVectorOnUpdate` and `fieldSearchFieldMetadataOnDelete`
handlers), and per-util specs for the re-own / backfill operation
builders and the GIN-index classifier.

## Upgrade / migration notes

* Existing workspaces converge on the next upgrade run via the `2-20`
instance + workspace commands (idempotent, dry-run supported).
* Backfill and rebuild go through the workspace-migration runner
(automatic cache invalidation); the re-own step invalidates only the
affected flat-entity maps directly.
* The cross-version upgrade CI now flushes the cache before running the
upgrade, so the new version recomputes every flat-entity map from the
database instead of reading blobs the old version serialized in an older
shape.

## Follow-up

* `object-metadata.service.ts` still carries a `TODO: remove once
default view fields move to the metadata side effect engine` — default
view fields are the next candidate to move into the engine.
* A single manifest sync cannot yet both create a field and relabel the
object onto it, because `objectMetadata.update` is ordered before
`fieldMetadata.create` in the migration runner. Tracked in
twentyhq/core-team-issues#2655; to be fixed in a follow-up.
2026-07-09 16:59:54 +02:00
martmull 0ea0c23556 refactor(app-marketplace): rename featured to vetted (#22674)
## What

Renames the application-registration "featured" flag to "vetted" across
the backend, frontend, GraphQL schema/DTOs, and the marketplace UI.

"Vetted" better describes what the flag actually does today: it marks an
app as reviewed and approved by the Twenty team (a trust signal), rather
than "featured" which reads as spotlighting/promotion. The admin toggle
description was already "Mark this app as reviewed and approved".

## How

- Renamed `isFeatured` -> `isVetted` on the `ApplicationRegistration`
entity, DTOs (`MarketplaceApp`, `MarketplaceAppDetail`,
`UpdateApplicationRegistrationPayload`), services, GraphQL fragments,
and the settings/admin UI (labels: "Featured" -> "Vetted", "Featured
only" -> "Vetted only", etc.).
- Renamed the `MARKETPLACE_FEATURED_APPLICATIONS` constant/file to
`MARKETPLACE_VETTED_APPLICATIONS`.
- Regenerated GraphQL client artifacts (`generated-metadata`,
`generated-admin`, `twenty-client-sdk`).

### Database

The `isFeatured` column is renamed in place to `isVetted` via a single
2.20 fast instance command (`ALTER TABLE ... RENAME COLUMN`). No new
column, no data-copy backfill.

- Since all 2.19 commands (including the existing `isFeatured` backfill)
complete before any 2.20 command runs, the rename carries over the
values that backfill set.
- The entity uses `@WasRenamedInUpgrade` so the upgrade-aware layer
queries the old column name until the rename step runs during an
upgrade.

## Testing

- `nx typecheck` and `nx lint:diff-with-main` pass for twenty-server and
twenty-front.
- Ran `database:reset` on a fresh dev DB: the 2.19 `isFeatured` backfill
runs first, then the 2.20 rename; the column ends up as `isVetted` (and
`isFeatured` no longer exists), values preserved.
- Booted the server: the `@WasRenamedInUpgrade` decorator validates
against the upgrade sequence, and GraphQL introspection confirms all
four types expose `isVetted` and none expose `isFeatured`.
- Ran the three `graphql:generate` configs and the SDK metadata client
generator so the committed generated files match the generator output
(field ordering included).

## Notes

- The `api-breaking-changes` check flags the removal of the `isFeatured`
GraphQL field — that is expected and inherent to this rename.
- Translation catalogs (`locales/`) are intentionally not touched here
since they are managed via Crowdin; new English strings render via
Lingui's default-message fallback until translated.
2026-07-08 16:41:58 +00:00
Thomas Trompette 48730df0d2 feat(workflow): scaffold core workflowVersion entity + trigger cache (phase 0) (#21674)
## What

**Phase 0 (scaffold)** of migrating `workflowVersion` data to **core**.
Gated by `IS_WORKFLOW_VERSION_IN_CORE_ENABLED` with **no behavior
change** — nothing reads or writes the new core entity yet.

## Plan

`workflowVersion` becomes a thin **workspace shell** over a core entity
(the `dashboard`/`pageLayout` pattern), so navigation, the metadata
relations, and the record UI keep working while the heavy data
(`triggers`, `steps`) lives in core. Trigger dispatch will derive from
active core versions via a per-workspace cache, letting us **eliminate**
the denormalized `workflowAutomatedTrigger` object. `workflow` and
`workflowRun` stay as workspace objects.

Phases: **0 — scaffold (this PR)** → A — backfill + dual-write → B —
switch reads to core → C — drop the workspace `trigger`/`steps` columns
+ the `workflowAutomatedTrigger` object.

## Included
- **Core `WorkflowVersionEntity`** (`extends WorkspaceRelatedEntity`) —
stores version data, with triggers as an **array** (`triggers:
WorkflowTrigger[]`), a long-due shape change. Storage only: dispatch
reads the primary trigger, so behavior stays single-trigger for now.
- **Fast create-table instance command** for `core."workflowVersion"`
(v2.19.0).
- **`IS_WORKFLOW_VERSION_IN_CORE_ENABLED`** feature flag.
- **Per-workspace automated-trigger cache provider** deriving
CRON/DATABASE_EVENT dispatch from the active version's trigger —
groundwork for removing `workflowAutomatedTrigger`.

## Notes
- `WorkspaceRelatedEntity`, **not** `SyncableEntity`: this is user
runtime data (like `connectedAccount`/`apiKey`/`file`), not
application-manifest metadata.
- No frontend behavior; the generated `FeatureFlagKey` enums are updated
to include the new flag.
2026-07-08 12:50:03 +00:00
Etienne 4b9f393167 fix: add workspace command to backfill missing AGENT source enum values (#22593)
**What**
Adds a 2.19 workspace upgrade command that backfills missing
FieldActorSource enum values (AGENT) into the Postgres enums backing
every ACTOR composite field (createdBy, updatedBy) across all existing
workspaces.

Each ACTOR field stores its source sub-field as a Postgres enum scoped
to its own table and schema (e.g.
workspace_abc.company_createdBySource_enum). Workspaces created before
these enum values were introduced are missing them, which causes runtime
errors when those sources are used.

**How**
buildActorSourceEnumBackfillTargets collects all ACTOR fields from the
workspace metadata cache and maps each enum sub-property to its
(tableName, columnName, enumName, expectedValues) tuple.
Before iterating over all targets, the command performs a single fast
pg_catalog.pg_enum lookup on company.createdBySource as a representative
sentinel. If AGENT is already present there, the workspace is skipped
entirely (idempotency fast-path).
For each remaining target, ALTER TYPE … ADD VALUE IF NOT EXISTS is
issued per missing value via
WorkspaceSchemaEnumManagerService.addEnumValue, making the command fully
idempotent and safe to re-run.

Fixes
https://discord.com/channels/1130383047699738754/1522507190949118003

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22593?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. -->
2026-07-08 12:05:15 +00:00
martmull b733a79821 feat(server): support server-scoped files via nullable workspaceId on file table (#22587)
Part of the app settings architecture cleanup
(twentyhq/core-team-issues#2456) — PR 1 of the server-level documents
plan, reworked after the revert of #22560 (#22579). Same capability,
different shape: **no new entity** — server-level documents live in the
existing `file` table with a nullable `workspaceId`.

## Problem

All file storage is workspace-scoped (`FileEntity.workspaceId NOT NULL`,
`{workspaceId}/{app}/…` storage keys). Server-level data like
application-registration manifests and tarballs for ownerless catalog
registrations has no first-class home, forcing raw-driver bypasses
(`DefaultAiCatalogService`, prototype #22556).

## Changes (core storage layer only — no HTTP serving, no GraphQL
exposure)

**`FileEntity` gains server scope** (mirrors `KeyValuePairEntity`, which
already supports both instance-level and per-workspace rows):
- `workspaceId` uuid becomes **nullable** — NULL means server-scoped;
the entity no longer extends `WorkspaceRelatedEntity` and declares its
columns directly
- `applicationRegistrationId` nullable FK (`onDelete: CASCADE`) —
registration-owned documents follow their registration
- ownership checks: `workspaceId IS NOT NULL OR
applicationRegistrationId IS NOT NULL` and `workspaceId IS NULL OR
applicationRegistrationId IS NULL` — every row has exactly one owner
- `IDX_FILE_APPLICATION_REGISTRATION_ID_PATH_UNIQUE` UNIQUE
(`applicationRegistrationId`, `path`) — mirrors the workspace
unique-constraint pattern; workspace rows are exempt via their NULL
`applicationRegistrationId`

**New `ServerFileStorageService`** (`file-storage/services/`, exported
from the global `FileStorageModule`; `FileStorageService` moved
alongside it):
- storage keys
`server/{fileFolder}/{applicationRegistrationId}/{resourcePath}` — the
registration segment is injected by the service itself, so paths cannot
collide across registrations; scope-validation util mirroring
`validateStoragePathIsWithinWorkspaceOrThrow`; new `ServerFileFolder`
enum in twenty-shared
- `writeServerFile` (upsert on (`applicationRegistrationId`, `path`) +
driver write; throws on failure), `readServerFile`/`readServerFileById`
(missing row or bytes surfaces `FILE_NOT_FOUND`),
`checkServerFileExists`, `deleteServerFile`/`deleteByServerFileId`
(bytes best-effort, row authoritative),
`deleteByApplicationRegistrationId`
- rows are accessed through a plain repository pinned to `workspaceId:
IsNull()` on every query; workspace-file code paths still go through
`WorkspaceScopedRepository`, which never sees NULL rows

**Null-safety ripples** (workspaceId is now `string | null`):
- `WorkspaceScopedEntity` bound widened to `workspaceId: string | null`
(the wrapper always filters with a concrete id)
- `list-and-delete-orphaned-workspace-entities` now skips `workspaceId
IS NULL` rows — previously `NOT EXISTS` would have flagged server rows
as orphans and deleted them
- `PendingFileCleanupService` sweeps only `workspaceId IS NOT NULL`
rows; `application-package-fetcher` pins its tarball lookup to workspace
rows (tarball migration to server scope is a follow-up PR)

**Migration**: `allow-server-scoped-file` ships as a **2-20 fast
instance command** (2.20.0 is current since #22639; re-slotted from 2-19
per review). Command runs are tracked by name, so instances that already
executed the 2-20 `standardOverrides` drop command still pick this one
up. Its realistic timestamp sorts before that drop command's fabricated
`1825000000000`, which the
`ci:allow-upgrade-command-timestamp-exception` label covers.

## Next PRs in the plan

- PR 2: HTTP serving + token type for server files
- PR 3: application-registration manifests stored as versioned server
files (rework of draft #22556)
- PR 4 (optional): registration tarballs migrate to server scope

## Verification

- New spec `server-file-storage.service.spec.ts` (traversal table,
upsert conflict semantics, row-before-bytes reads, best-effort byte
deletion, registration cascade) + scope-validation util spec; affected
suites all green
- Typecheck (server + shared), `lint:diff-with-main`, full `oxfmt
--check src/` on both packages clean
- Fresh `database:reset` on the re-slotted branch: the 2-20 command
executes, generator then reports **no schema drift**; both ownership
checks and the composite unique verified live (dual-owner insert and
duplicate registration+path both rejected)
2026-07-08 11:56:24 +02:00
martmull 435073e9c5 Display featured applications in marketplace (#22635)
## After
<img width="1060" height="589" alt="image"
src="https://github.com/user-attachments/assets/74dfadcf-8698-4404-81c6-b309cc4cbf79"
/>
<img width="732" alt="image"
src="https://github.com/user-attachments/assets/0e1a3644-04bc-4208-aa77-3842d9db9cc8"
/>
<img width="797" alt="image"
src="https://github.com/user-attachments/assets/0456ecce-607a-4705-8a89-c77029bfb6ac"
/>

- Remove IS_MARKETPLACE_SETTING_TAB_VISIBLE feature flag
- add vetted toggle in admin app tab
- added people data labs, last contact and call recorder to default
vetted applications

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22635?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: prastoin <paul@twenty.com>
2026-07-07 17:05:54 +02:00
Paul Rastoin bd8bf89653 Revert "feat(messaging): message campaign delivery stats + views" (#22452) (#22627)
Revert "feat(messaging): message campaign delivery stats + views
(#22452)"

This reverts commit 2e1117d442.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22627?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. -->
2026-07-07 14:17:13 +02:00
neo773 2e1117d442 feat(messaging): message campaign delivery stats + views (#22452)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22452?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. -->
2026-07-07 04:47:52 +02:00
martmull 2327ae7122 Revert "feat(server): add instance-level file storage layer" (#22579)
Reverts twentyhq/twenty#22560

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22579?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. -->
2026-07-06 11:47:45 +00:00
martmull 0baf213fa4 feat(server): add instance-level file storage layer (#22560)
Part of the app settings architecture cleanup
(twentyhq/core-team-issues#2456) — PR 1 of the instance-level documents
plan. Today all file storage is workspace-scoped
(`FileEntity.workspaceId NOT NULL`, `{workspaceId}/{app}/…` storage
keys, workspace-anchored tokens); instance-level data like
application-registration manifests and tarballs for ownerless catalog
registrations has no first-class home, forcing raw-driver bypasses
(`DefaultAiCatalogService`, prototype #22556).

## Changes (core storage layer only — no HTTP serving, no GraphQL
exposure)

**New `instanceFile` table** (`InstanceFileEntity`) — deliberately
separate from the workspace-scoped `file` table so nothing about the
existing system changes:
- `id`, `path` (unique, `{fileFolder}/{relativePath}` mirroring
FileEntity's convention), `size`, `mimeType`, timestamps
- nullable `applicationRegistrationId` FK (`onDelete: CASCADE`) —
registration-owned documents follow their registration
- no `workspaceId`, no `applicationId`; plain repository (added to the
`prefer-workspace-scoped-repository` lint rule's global-table
exemptions, as the rule's own message directs)

**New `InstanceFileStorageService`** (exported from the global
`FileStorageModule`):
- storage keys under a literal `instance/{fileFolder}/…` prefix —
collision-free with workspace prefixes (UUIDs); scope-validation util
mirroring `validateStoragePathIsWithinWorkspaceOrThrow`
- `writeInstanceFile` (upsert row on `path` conflict + driver write;
throws on failure — no swallowing),
`readInstanceFile`/`readInstanceFileById` (missing file surfaces
`FILE_NOT_FOUND` like `FileStorageService.readFile`),
`checkInstanceFileExists`, `deleteInstanceFile`/`deleteByInstanceFileId`
(bytes best-effort, row authoritative),
`deleteByApplicationRegistrationId` (lifecycle hook for
registration-owned files)
- same driver path as `FileStorageService` (`FileStorageDriverFactory` →
`ValidatedStorageDriver`)

**Migration**: fast instance command `add-instance-file-table` (2.19,
generator-produced; post-command `database:migrate:generate` reports no
pending changes).

## Next PRs in the plan

- PR 2: HTTP serving + token type for instance files (new route + guard;
workspace file endpoints untouched)
- PR 3: application-registration manifests stored as versioned instance
files (supersedes draft #22556)
- PR 4 (optional): registration tarballs migrate to instance scope,
removing the cross-workspace `FileEntity` read in
`application-package-fetcher` and the `ownerWorkspaceId` requirement on
`uploadTarball`

## Verification

- New specs: scope-validation util (traversal cases) + service (upsert
conflict, missing-file error, best-effort byte deletion, registration
cascade) — 16/16; `npx jest "application"` still 31 suites / 160 green
- Typecheck, `lint:diff-with-main`, full `oxfmt --check src/` (6421
files) and full type-aware oxlint clean
- Fast command executed against the local DB — table, unique index, and
CASCADE FK verified via psql; generator then reports no schema drift
- Server boots with the new provider; `generate-metadata-client
--skip-nx-cache` zero diff (no GraphQL change)

---
_Generated by [Claude
Code](https://claude.ai/code/session_011sST4rPLU1Koi2oVGi84ei)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22560?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. -->
2026-07-06 12:23:42 +02:00
martmull d6b6962604 feat(files): content-verify direct uploads and pin pending files to octet-stream (#22533)
## Context

Follow-up to #22449 (direct-to-storage upload endpoints). In that flow
`createFileUpload` inserts a `PENDING` file record before any bytes
exist, and until now it guessed the mime type from the **filename
extension** — an untrusted, client-controlled value. This PR makes a
pending file opaque and only trusts a mime type that was verified
against the actual stored bytes.

## What this does

**1. A pending file is always `application/octet-stream`.**
`createFileUpload` records the pending file — and signs the presigned
PUT — as `application/octet-stream`. The extension is still kept on the
stored object name so the content can be checked against it later.

**2. Content verification at completion.**
`completeFileUpload`, after the existing size check, reads a **bounded
prefix** of the stored object (`readReadablePrefix`, capped at 64 KiB —
a large object is never buffered in full) and runs the existing
`extractFileInfoOrThrow` util to detect the real mime type from the
content. It:
- writes the detected type alongside `status = UPLOADED`, and
- rejects a file whose bytes don't match its declared extension (the
record stays `PENDING`, so it can never be served or attached, and is
reaped by the pending-file cleanup cron).

Serving already overrides `Content-Type` from the DB record, so storing
the object as octet-stream is fine.

**3. A database constraint as backstop.**
`CHK_FILE_PENDING_MIME_OCTET_STREAM` — `"status" != 'PENDING' OR
"mimeType" = 'application/octet-stream'` — added to `FileEntity` and
applied by a fast instance command (`2-19`). It is added `NOT VALID` on
purpose: an instance freshly upgraded past #22449 may still hold
`PENDING` rows whose mime came from the old extension-guess path, and
`NOT VALID` enforces the invariant on every new/updated row without
failing on that legacy backlog (those rows get overwritten to
octet-stream when completed — `status` flips to `UPLOADED`, so the check
passes — or are reaped while pending).

## Tests

- `read-readable-prefix.spec.ts` — prefix reader: short source, early
stop on a large source (asserts it tears the stream down without
draining it), error propagation, empty stream.
- `file-upload.service.spec.ts` — create records octet-stream; complete
sniffs and sets the detected type, overrides a spoofed extension with
the real content type, and rejects content that can't be matched to the
declared extension.
- `direct-file-upload.integration-spec.ts` — end-to-end case rejecting a
`.png` upload whose bytes are plain text.

## Verification

`typecheck` green, `lint:diff-with-main` clean, unit suites pass (17
tests). No GraphQL schema change, so no codegen drift.

## Scope

Server-only, part of the incremental direct-upload rollout being split
into small PRs. Independent of the reaper-cron PR (#22531).

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

---
_Generated by [Claude
Code](https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22533?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. -->
2026-07-06 08:12:30 +00:00
martmull 1a85b88d38 feat(files): direct-to-storage upload endpoints with pending file lifecycle (#22449)
<img width="1484" height="404" alt="image"
src="https://github.com/user-attachments/assets/b2d363bf-d9e1-49fb-9811-8cc98041aa79"
/>


## Context

Uploading large files currently OOMs the server: every upload resolver
buffers the whole file in memory (`streamToBuffer`) before writing it to
storage. This PR is the first of a series introducing direct
client-to-storage uploads. It adds the server-side endpoints and driver
support only — it is non-breaking and nothing consumes the new flow yet.
Follow-up PRs will migrate the frontend upload paths, add a
stale-pending-file cleanup cron, and cap the legacy buffered resolvers.

## What it does

**New upload flow (initiate → PUT → confirm):**

- `createFileUpload(filename, size, fileFolder, fieldMetadataId?)`
validates the request (folder allowlist: `FilesField`/`Workflow`, max
size, extension-derived mime type), creates the file record in a new
`PENDING` status, and returns an upload target:
- **S3 with presign enabled** → a presigned PUT URL with
`Content-Type`/`Content-Length` pinned in the signature, so the client
uploads straight to the bucket;
- **local storage, or S3 without presign** → a token-authenticated
streaming endpoint on the server (`PUT /file-upload/:id?token=…`, new
`FILE_UPLOAD` JWT type) that pipes the request body to the storage
driver with constant memory usage and a declared-size cap.
- `completeFileUpload(fileId)` verifies the bytes actually landed in
storage (HEAD + size match against the declared size) and flips the
record to `UPLOADED`. Idempotent.

**Pending lifecycle safety:**

- New `status` column on `core.file` (`PENDING`/`UPLOADED`, default
`UPLOADED` so all existing rows and the legacy upload path are
unaffected) + fast instance command.
- Files are refused by the serving endpoints and by FILES-field sync
while `PENDING`.

**Driver support (both drivers):**

- `getPresignedUploadUrl` (S3: presigned PUT; local: `null` →
server-endpoint fallback)
- `writeFileStream` (local: `fs` pipeline with the existing
symlink/containment hardening, partial-file cleanup on error; S3:
`@aws-sdk/lib-storage` `Upload` for bounded-memory streaming)
- `getFileMetadata` (HEAD/stat for confirm-time verification)

## Tests

- `file-upload.service.spec.ts`: initiate validation (folder allowlist,
size), presigned vs fallback target, confirm verification (missing
object, size mismatch, happy path, idempotency)
- `local.driver.spec.ts`: `writeFileStream` (content, symlink rejection,
partial-file cleanup on stream error), `getFileMetadata`
- `s3.driver.spec.ts`: `getPresignedUploadUrl` (disabled → null, PUT
command with signed content-type/content-length)
- `direct-file-upload.integration-spec.ts`: full end-to-end flow against
the local driver (initiate → PUT → complete → download), plus error
paths (complete without upload, oversized PUT → 413, invalid token →
403, unsupported folder, size above max)

## Notes for reviewers

- The upload-size ceiling for direct uploads is
`settings.storage.maxDirectUploadFileSize` (1GB), separate from the 10MB
`maxFileSize` used for pictures.
- Since content can't be sniffed before it reaches storage, the mime
type is derived from the file extension (with the existing
`TWENTY_MIME_POLICY` override) and unknown extensions fall back to
`application/octet-stream`; the serving path already forces
`Content-Disposition: attachment` for anything not on the inline-safe
allowlist.
- Self-hosters using S3 presign will need a bucket CORS policy allowing
`PUT` from the frontend origin (config variable description updated).

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

---
_Generated by [Claude
Code](https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22449?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. -->
2026-07-03 15:11:53 +02:00
martmull 0992d0b748 feat(server): promote registration display fields to first-class columns (#22513)
Part of the application settings architecture work:
https://github.com/twentyhq/core-team-issues/issues/2456 — follow-up to
#22453, delivering the promised removal of the temporary manifest load.

Display data (description, author, category, websiteUrl,
aboutDescription, termsUrl, emailSupport, issueReportUrl, screenshots)
only existed inside the `manifest` jsonb, forcing hot paths to load it.
This PR:

- Promotes those 9 fields to first-class columns on
`applicationRegistration`, populated at every ingestion point
(`updateFromManifest`, both `upsertFromCatalog` branches) — fast command
creates the columns at deploy, slow command backfills them from the
manifest.
- `findManyListedCatalogCards()` (marketplace list) now selects only
scalar columns — the manifest jsonb is no longer loaded there.
- `findPublicByClientId()` (OAuth consent page) now selects `id, name,
logo, websiteUrl, oAuthScopes` — no manifest.
- The narrow select used by
`findMany`/`findAll`/`findOneById`/`findOneByIdGlobal` includes the new
columns.
- GraphQL surface unchanged (no new fields); the marketplace detail
endpoint still reads the manifest and is slimmed in the next PR.

Verified: migration applied via the real runner (both commands recorded
completed), backfill SQL exercised against live rows (full + minimal
manifests), migration generator reports no pending schema changes,
typecheck, lint, unit suites (application-registration + marketplace
15/15).

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

https://claude.ai/code/session_011sST4rPLU1Koi2oVGi84ei

---
_Generated by [Claude
Code](https://claude.ai/code/session_011sST4rPLU1Koi2oVGi84ei)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22513?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. -->
2026-07-03 14:47:24 +02:00
martmull af1b89c788 feat(server): import application logo into file storage at install (#22437)
Installed apps stored the logo as the manifest's relative path but never
imported the file, so the public-assets URL 404'd and logos went missing
in the UI for npm/tarball sources. Import the logo (best-effort — a
declared but unshipped logo is skipped, not fatal) and record it as a
first-class logoFileId on the application so it can be served reliably.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22437?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. -->
2026-07-03 11:47:58 +02:00
martmull 20f2e33702 feat(server): first-class logo on application registration + narrowed settings queries (#22453)
Part of the application settings architecture work:
https://github.com/twentyhq/core-team-issues/issues/2456

Application-registration list queries loaded the entire `manifest` jsonb
(potentially 100KB+/row) on every settings/marketplace list request
because display data (logo, description, author, category) only exists
inside it. This PR:

- Adds a first-class nullable `logo` column on
`applicationRegistration`, populated at every ingestion point
(`updateFromManifest`, `upsertFromCatalog`) and backfilled from
`manifest->application->>logoUrl` via a slow instance command
(self-sufficient backfill since `runDataMigration` runs before `up`).
- Backs the `logoUrl` GraphQL getter with the column (manifest fallback
for un-backfilled rows) — **GraphQL surface unchanged**.
- Narrows `findMany` / `findAll` / `findOneById` / `findOneByIdGlobal`
to an explicit scalar select that excludes `manifest` and
`oAuthClientSecretHash` (every caller audited — none needs them; OAuth
verification paths are untouched).
- Replaces `findManyListed()` with `findManyListedCatalogCards()`: a
projection query that extracts the four display strings from the
manifest in SQL (with explicit soft-delete filtering) instead of
hydrating full entities, feeding `findManyMarketplaceApps`.

Verified: typecheck, lint:diff-with-main, unit suites
(application-registration 5/5, marketplace 10/10, instance-command
31/31), migration applied via the real runner, and the migration
generator reports no pending schema changes.

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

https://claude.ai/code/session_011sST4rPLU1Koi2oVGi84ei

---
_Generated by [Claude
Code](https://claude.ai/code/session_011sST4rPLU1Koi2oVGi84ei)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22453?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. -->
2026-07-03 09:43:57 +00:00
martmull 25fe66565c feat(applications): add type and options to application variables (#22157)
## Before
<img width="1452" height="709" alt="image"
src="https://github.com/user-attachments/assets/cd384ffa-cbe6-49d5-a807-ca8d580f55a9"
/>

<img width="1074" height="452" alt="image"
src="https://github.com/user-attachments/assets/720d38db-3495-4032-8831-17d24ec6a7e7"
/>

## After

<img width="1421" height="865" alt="image"
src="https://github.com/user-attachments/assets/2275c996-c895-4800-8324-2aa2ddfddd43"
/>

<img width="1348" height="870" alt="image"
src="https://github.com/user-attachments/assets/3e1a891d-6db0-4cbd-870a-2a5bbde4929d"
/>


## Summary

Adds typed application variables with optional select **options**. This
is the other half of #22059, split out from the custom-settings-tab
removal.

## Changes

- **Shared types**: `ApplicationVariable` / `ServerVariables` gain an
optional `type` (a `FieldMetadataType` subset — `TEXT`, `BOOLEAN`,
`NUMBER`, `DATE`, `SELECT`, `MULTI_SELECT`, `RAW_JSON`, `RICH_TEXT`,
`ARRAY`, …) and select `options`. New
`serializeApplicationVariableValue` /
`deserializeApplicationVariableValue` helpers convert typed values
to/from the encrypted string storage.
- **Server**: `type`/`options` columns on `applicationVariable` and
`applicationRegistrationVariable` (entities + DTOs), a fast `2-17`
instance command, manifest processing via the serialization helpers, and
a `QueryDeepPartialEntity` cast where the manifest JSON column is
persisted.
- **Frontend**: a polymorphic `SettingsApplicationVariableInput` that
renders the native `Form*` field component for each type (boolean,
number, date/date-time, select, multi-select, array, raw JSON, rich
text, text); fragment/query updates to fetch `type`/`options`.
- **SDK**: `defineApplication` validates that `SELECT`/`MULTI_SELECT`
variables declare non-empty `options` at build time (since `options` is
kept structurally optional for TypeORM/SDK compatibility).

Variables default to `TEXT` when no type is given, so existing manifests
are unaffected.

## Notes

The generated GraphQL artifacts (`type`/`options` on the variable types)
are regenerated by codegen; that change accompanies this PR.

https://claude.ai/code/session_013Z7UB35V2mvUozh55QHG23

---
_Generated by [Claude
Code](https://claude.ai/code/session_013Z7UB35V2mvUozh55QHG23)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22157?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. -->
2026-07-03 10:52:22 +02:00
Félix Malfait 024a9b4d94 fix(upgrade): re-slot all 2-19 upgrade commands to their real merge epochs and guard timestamps in CI (#22498)
# Fix broken 2-19 upgrade command sequence (dev incident:
`lastStreamError` / `workspaceDiscoverability`)

## Incident

The dev environment (tracking main) throws:
- `Property "lastStreamError" was not found in "AgentChatThreadEntity"`
- `Cannot return null for non-nullable field
Workspace.workspaceDiscoverability`

## Root cause

The 2-19 upgrade commands were committed with **fabricated future
timestamps** (year-2027 epochs like `1820000000000`). The upgrade cursor
(`upgrade-aware-entity-metadata.adapter.ts`) tracks a **single**
most-recent applied step: it looks up the latest
`core."upgradeMigration"` row's name in the sequence (sorted by
timestamp within kind) and hides every `@WasIntroducedInUpgrade` column
at or past that index.

Two ways this breaks, and both were live on main:
1. **Cursor regression**: a command merged *later* with a *smaller*
timestamp (e.g. `pendingQuestion` at `1811…` after `metadata-overrides`
at `1820…` had run) sorts *before* already-applied steps. When migrate
runs, the "latest" row now points earlier in the sequence, re-hiding
columns that were already applied.
2. **Migrate not running at all** (Felix's hypothesis): if the deploy
pipeline skipped `database:migrate:prod`, none of the 2-19 rows exist
and every 2-19-gated column is hidden.

Both hypotheses have the same fix path; the discriminating query is in
the verification section below.

## Fix

**Real timestamps** (per maintainer direction — no more fabricated
epochs):

| Command | Old (fabricated) | New (real merge epoch) | Introduced by |
|---|---|---|---|
| workspace: backfill-workspace-custom-application-registration |
`1820000000000` | `1782853718000` | #22378 |
| fast: add-metadata-overrides-column | `1820000100000` |
`1782986475000` | #22417 |
| slow: backfill-metadata-overrides | `1820000110000` | `1782986476000`
(+1s to order after its fast pair) | #22417 |
| fast: add-last-stream-error-to-agent-chat-thread | `1821000000000` |
`1782996657000` | #22434 |
| fast: add-pending-question-to-agent-chat-thread | `1811000000000` |
`1782999138000` | #22346 |
| fast: add-workspace-discoverability-to-workspace | `1820000001000` |
`1783004140000` | #22423 |

Each value is the committer epoch of the squash-merge commit that
introduced the command on main (verified via `git log --diff-filter=A`).
Sorted by real time, the fast sequence is strictly increasing, so the
cursor can no longer regress.

**Idempotency**: renaming a command changes its step name, so every one
of these re-runs on any instance that already applied it under the old
name (dev cluster, edge self-hosters — 2.19 is unreleased, so tagged
releases are unaffected). All six are now safe to re-run:
- `lastStreamError`, `pendingQuestion`, `metadata-overrides` fast: `ADD
COLUMN IF NOT EXISTS` (already were)
- `metadata-overrides` slow backfill: `WHERE … IS NULL` guard (already
was)
- workspace command: skips when `applicationRegistrationId` is already
set (already did)
- `workspaceDiscoverability`: **made idempotent in this PR** — `CREATE
TYPE` wrapped in a `duplicate_object` handler, `ADD COLUMN IF NOT
EXISTS`

**CI guard** (replaces the append-only check added earlier on this
branch):
- Timestamps must be **real**: within `[now − 60 days, now + 2 days]`.
This is the check that would have prevented the original sin — 2027
epochs can never pass.
- Still **append-only** within the version directory, but computed from
`git diff --name-status --find-renames` so renamed/copied files are
checked too (cubic's P2), and files the PR deletes/renames away no
longer count toward the existing max (otherwise a re-slotting PR like
this one could never pass its own guard).
- Covers `workspace-command-<ts>-` filenames, not just
`instance-command-fast|slow-<ts>-`; skips `.spec.ts` files.
- Failure message documents the escape path (re-slot the fabricated
blocker to its real epoch + make it idempotent) and a bypass label
`ci:allow-upgrade-command-timestamp-exception` for deliberate
exceptions.

## Deploy sequencing (important)

After this merges and deploys, `database:migrate:prod` **must run**
before the API pods are relied on: the old step names no longer exist in
the sequence, so until the renamed commands run once, the cursor
resolves to 0 and *every* gated column is hidden. The commands are
idempotent, so the re-run is harmless. Running API/worker pods only
compute the cursor at boot — restart them after migrate.

## Verification / diagnosis on dev

```sql
SELECT name, status, "createdAt"
FROM core."upgradeMigration"
WHERE "workspaceId" IS NULL
ORDER BY "createdAt" DESC
LIMIT 15;
```
- Latest rows named `…_182xxxxxxxxxx` (fabricated) and completed →
migrate ran, cursor regressed (hypothesis 1).
- No 2-19 rows at all → migrate never ran for 2-19 (hypothesis 2).
- After the fix: latest row should be
`2.19.0_AddWorkspaceDiscoverabilityToWorkspaceFastInstanceCommand_1783004140000`,
status `completed`.

https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38
2026-07-02 23:38:38 +02:00
Félix Malfait a28887bba6 feat(server): workspace opt-out of root-domain directory listing (#22423)
## What

Lets a workspace opt out of being surfaced in the multi-workspace
root-domain (app.twenty.com) picker via **email-domain discovery**.

Adds `isDirectoryListingEnabled` (default `true`) on the workspace. When
`false`, the workspace is filtered out of the approved-access-domain
branch of `findAvailableWorkspacesByEmail`, so a user whose email domain
matches an approved access domain no longer sees the workspace in the
sign-up picker.

## Scope of the opt-out (deliberately narrow)

The filter is applied **only** to the approved-access-domain discovery
source:

- **Members** (`availableWorkspacesForSignIn`) — never filtered; they
keep access.
- **Explicit invitations** — never filtered; the intent is one-to-one.
- **Approved-access-domain discovery** — the only "listing" source,
gated by the flag.

A hidden workspace stays fully reachable by members and invited users
via the direct workspace subdomain; it just isn't advertised in the
global picker.

> Open question for review: do we also want a stronger mode that hides
the workspace from the root-domain picker even for existing members
(forcing them to use the subdomain directly)? That would additionally
filter the member/invitation sources and is a larger behavior change —
not included here.

## Changes

**Backend**
- `workspace.entity.ts` — new `isDirectoryListingEnabled` column
(`@Field`, default `true`).
- `user-workspace.service.ts` — filter the approved-access-domain branch
on the flag.
- `update-workspace-input.ts` — expose the field on `updateWorkspace`.
- `workspace.service.ts` — `PermissionFlagType.SECURITY` (same as the
other discovery/security toggles).
- Fast instance command adding the column (default `true`, so no
existing workspace is hidden).

**Frontend**
- Settings > Security: a **"List in workspace directory"** toggle (shown
only in multi-workspace mode) that flips the flag via `updateWorkspace`,
mirroring the existing `isInternalMessagesImportEnabled` toggle.
- Threaded the field through the current-user fragment,
`CurrentWorkspace` type, and mock data.
- Regenerated the metadata + client-sdk GraphQL types
(`generated-metadata`, `twenty-client-sdk/.../generated`) — generated
against a server booted from this branch.

## Verification

- `tsgo` typecheck: 0 errors. `oxlint`: 0/0. `oxfmt`: clean.
- Codegen diff verified to contain **only** the new field (no unrelated
drift).
- Tests not run locally; CI covers unit/integration + the
codegen/migration freshness checks.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-07-02 16:55:40 +02:00
Félix Malfait 4aaf171d63 feat(ai): add ask_questions interactive clarifying-question tool (#22346)
## What & why

Adds an `ask_questions` tool that lets the in-app **Ask AI** assistant
**pause a turn to ask the user one or more multiple-choice questions**
(per the [Figma
design](https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=105959-117153))
and resume once answered — instead of guessing on
ambiguous/consequential decisions.

The tool is **harness-only**: an interactive question UI is meaningless
without a user to answer it, so it must be absent from MCP and from
head-less workflow agents.

## Design — true tool-result resume (not a synthetic user message)

The user's answer is a **structured tool result bound to the
`toolCallId`**, and the **same agent turn resumes** — exactly how
Anthropic (`tool_result` by `tool_use_id`) and OpenAI
(`function_call_output`) model human-in-the-loop.

The naive form of this (leave the tool call in `input-available` to mean
"pending") is **impossible** here: `finalizeDanglingToolParts` rewrites
`input-available` → `output-error` ("Tool execution was interrupted") on
both the persist path (`addMessage`) and the model-reload path
(`chat-execution.service.ts`). That util is a load-bearing safety net,
so weakening it is the wrong move.

Instead:

- `ask_questions` is an **inline, chat-only tool with an `execute` that
returns a `status: 'pending'` result immediately**, so the tool part is
always `output-available` and **immune to `finalizeDanglingToolParts`**.
`stopWhen(hasToolCall('ask_questions'))` halts the turn right after the
call (the model never sees the placeholder).
- A nullable **`thread.pendingQuestionMessageId`** marker records that a
turn is awaiting an answer.
- The new **`answerAgentChatQuestion`** mutation atomically *claims* the
question (clears the marker, marks the thread streaming), **writes the
answer onto the same tool part** (`status: 'answered'`), and
**re-enqueues the turn via the existing `existingTurnId` plumbing**
(`isResume` bypasses the per-turn dedup guard). On resume
`finalizeDanglingToolParts` leaves the `output-available` part untouched
and `convertToModelMessages` emits `assistant(tool_use)` +
`tool_result(answers)`, so the model continues.

This achieves the platform-aligned semantics **without** weakening the
finalize safety net or inventing a fragile new part state.

### Meets the two requirements

- **Survives refresh, scoped per-thread** — the pending state is a
normal persisted `output-available` part + the thread marker; the
frontend card is derived per-thread from the loaded messages, so it
re-appears on reload and only on its own thread.
- **Takes priority over the queue** — a unified `isBlocked =
activeStreamId || pendingQuestionMessageId` gate is applied in both
`sendChatMessage` (new messages queue) and `flushNextQueuedMessage` (the
drain). The queue cannot unpile until the question is answered and the
resumed turn completes.

### Harness-only by construction

`ask_questions` is added **only** to the chat's inline `activeTools`
(like `learn_tools`/`execute_tool`/`load_skills`). It never enters the
tool registry/catalog, so it is invisible to MCP and to workflow agents
— no `MCP_EXCLUDED_TOOL_NAMES` entry needed.

## UX

While a question is pending, the **composer is replaced by the question
card** (matching the Figma): question title + pager (`1/2`), numbered
option rows (`IconSquareNumber*`) with per-option info-icon descriptions
and a "Recommended" badge, and the normal composer as the free-text
fallback ("Type anything to do differently."). The transcript shows a
compact "Asking questions…" status line that becomes an answered
summary.

## Changes

**twenty-shared**
- `ai/types/AskQuestionsToolTypes.ts` —
`AskQuestionItem/Option/Answer/Result`, `ASK_QUESTIONS_TOOL_NAME`.

**twenty-server**
- `ai-chat/tools/ask-questions.tool.ts` — inline tool factory
(pending-result `execute`, zod schema, 1–4 questions × 2–4 options).
- `chat-execution.service.ts` — add to `activeTools` +
`preloadedToolNames`; `hasToolCall` in `stopWhen`.
- `chat-system-prompts.const.ts` — when-to-use guidance.
- `entities/agent-chat-thread.entity.ts` — `pendingQuestionMessageId`
column.
- `stream-agent-chat.job.ts` — set the marker on a question pause;
bypass the dedup guard on resume; suppress the no-text warning for
question pauses.
- `agent-chat-streaming.service.ts` — gate `flushNextQueuedMessage`;
`enqueueResumeStream`.
- `agent-chat.resolver.ts` — gate `sendChatMessage`;
`answerAgentChatQuestion` mutation.
- `agent-chat.service.ts` — `resolvePendingQuestion` (atomic claim +
write answer).
- `dtos/agent-chat-question-answer.input.ts`, `ai.exception.ts`
(`QUESTION_NOT_PENDING`), `utils/find-pending-question-part.util.ts`.

**twenty-front**
- `components/AiChatQuestionCard.tsx` — the interactive card (matches
Figma tokens) + `__stories__/AiChatQuestionCard.stories.tsx`.
- `components/AiChatEditorSection.tsx` — swap the composer for the card
while pending.
- `components/AiChatQuestionStatusRenderer.tsx` + branch in
`AiChatAssistantMessageRenderer.tsx`.
- `states/selectors/agentChatPendingQuestionComponentSelector.ts`,
`types/AgentChatPendingQuestion.ts`.
- `hooks/useSubmitQuestionAnswer.ts` + `utils/markQuestionAnswered.ts`
(optimistic) + `graphql/mutations/answerAgentChatQuestion.ts`.

A design doc lives at
`packages/twenty-server/docs/ASK_USER_QUESTION_TOOL_PLAN.md`.

## Migration

Adds a nullable `pendingQuestionMessageId` (uuid) column to
`core.agentChatThread`. Needs a generated **fast instance command**
(`database:migrate:generate --name addThreadPendingQuestion --type
fast`) — see "Verification status".

## Tests

- Server: `ask-questions.tool.spec.ts` (pending echo + schema bounds),
`find-pending-question-part.util.spec.ts`.
- Front: `markQuestionAnswered.test.ts`, plus the Storybook story.

## Verification status (please read)

This branch was authored in an environment where the monorepo `yarn
install` repeatedly failed on transient TLS resets from the package
registry, so I could **not** locally run the mechanical gates. The logic
was reviewed by hand and the `ai@6.0.97` exports used (`hasToolCall`,
`stepCountIs`, `generateId`) were confirmed against the package's type
defs. Still **TODO** (will rely on CI / a follow-up once deps install):

- [ ] `nx run twenty-shared:generateBarrels` (the `ai/index.ts` export
was added by hand; regen to reconcile)
- [ ] `nx run twenty-front:graphql:generate` (new mutation + input type)
- [ ] generate the fast instance command (migration) for the new column
- [ ] `typecheck` + `lint:diff-with-main` (front + server) — expect
minor import-ordering autofixes
- [ ] run the unit tests

**Screenshots:** reproducing the live flow needs an AI provider API key
(to get the model to actually call `ask_questions`), which isn't
available here. The card can be screenshotted from its **Storybook
story** (`AiChatQuestionCard.stories.tsx`) with no API key — I'll add
that image once deps install, or a reviewer can run `nx storybook
twenty-front`.

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

https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB

---
_Generated by [Claude
Code](https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22346?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. -->
2026-07-02 15:32:18 +02:00
Félix Malfait d709467902 feat(ai): surface AI chat stream failures through one typed error channel (#22434)
## Context

Investigating a report where the AI chat showed only a `...` spinner
while the network response clearly contained `No AI models are
available`. Root cause: terminal stream failures reach the client on
**two mismatched channels**.

| Representation | Persisted (survives reload) | Rendered by client |
|---|---|---|
| AI-SDK `error` chunk (inside `stream-chunk`) |  RPUSH'd to Redis | 
dropped by `readUIMessageStream` (no message part, no error state) |
| typed `stream-error` event |  never persisted |  sets the error atom
|

Live, the `stream-error` event renders. But on reload,
`chatStreamCatchupChunks` replays only the persisted **error chunk** —
which the reducer discards — and the streaming indicator never clears.

## Change

Collapse to a single typed error contract:

- **Suppress the opaque `error` chunk** in the stream job; every failure
is surfaced through the typed `stream-error` event. Errors are mapped
via `mapErrorToStreamError` so an `AiException` keeps its
`AiExceptionCode` (e.g. `API_KEY_NOT_CONFIGURED` → the existing "AI not
configured" banner) instead of leaking a raw string.
- **Persist the terminal error** next to the accumulated chunks and
expose it as an explicit `error { code message }` field on
`ChatStreamCatchupChunks`, so a client catching up after a reload
recovers it — no dependency on the AI SDK's internal chunk shape.
- **Reset per-thread stream state at job start**, so a failed turn's
leftover chunks/error never replay on the next stream.
- **Client replays the catchup error** as a terminal `stream-error`
event, which clears the streaming indicator and renders the error (fixes
the infinite spinner on a stream that ended in error).

## Notes

- `ChatStreamError` is a new metadata GraphQL type; generated types
(twenty-front metadata + client-sdk) were hand-updated to keep the tree
consistent and will be reconciled by CI's `graphql:generate` check if
anything differs.
- Server unit test added for the error mapping. No schema/DB migration.

## Test plan

- [ ] With no AI provider configured, send a chat message → error
renders immediately (not a spinner).
- [ ] Reload the thread → the error still renders (recovered from
catchup), indicator not spinning.
- [ ] Configure a provider and send again → normal streaming; no stale
error from the previous failed turn.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22434?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. -->
2026-07-02 14:50:57 +02:00
Félix Malfait 38fbff465f chore(server): ship the 2.20 standardOverrides drop as a dormant command (#22448)
Follow-up to #22417, per [this
thread](https://github.com/twentyhq/twenty/pull/22417#discussion_r3512187719):
migrate the `2-20/README.md` placeholder into a real command using the
`TWENTY_NEXT_VERSIONS` mechanism.

### What

- Add `DropMetadataStandardOverridesColumnFastInstanceCommand`,
registered against `2.20.0`. It boots (`2.20.0` is in
`TWENTY_ALL_VERSIONS`) but stays **dormant** — the upgrade sequence only
runs `TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS` (previous + current), so
it never executes during the 2.19 deploy and activates automatically
when `nx version:bump` promotes 2.20 to current.
- Name constant + unit test (SQL parity, registration against `2.20.0`,
name-constant parity).
- Register it in `instance-commands.constant.ts`.
- Update the `standardOverrides` `@deprecated` comments on object/field
metadata to point at the shipped command.
- Delete `2-20/README.md`.
- Document the "ship a command for a future version" flow in
`docs/UPGRADE_COMMANDS.md` and `.cursor/rules/server-migrations.mdc`
(the mechanism was previously undocumented).

### Note / correction to the README's plan

The old README implied both the command **and** `@WasRemovedInUpgrade`
could be added at 2.20 time. Only the command can ship now: the
decorator's validator runs against the active sequence, so referencing a
still-dormant 2.20 step fails boot with `unknown-step-name`. So the
entity keeps its `WasRemovedInUpgrade<T>` type wrapper for now; the
decorator gets wired (one line, via the name constant) once 2.20 is
current — same deferred-drop shape as `isUIReadOnly`.

### Verification

Could not run `jest`/`typecheck`/`lint` in this environment: `yarn
install` is blocked by egress policy on a git-based transitive dep
(`github.com/electron/node-gyp.git`). Verified by review against the
sibling 2-19 add-column and 2-12 drop commands. **Please let CI run
before merge.**

https://claude.ai/code/session_01KMArJvdEmsX3eAmJLbS1b6

---
_Generated by [Claude
Code](https://claude.ai/code/session_01KMArJvdEmsX3eAmJLbS1b6)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22448?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. -->
2026-07-02 13:18:20 +02:00
Félix Malfait 5a4ebca226 refactor(server): unify the two metadata override mechanisms into one (#22417)
## Unify the two metadata override mechanisms into one

Twenty had **two** override mechanisms:

- **`standardOverrides`** — a bespoke JSONB column on
`objectMetadata`/`fieldMetadata` with typed DTOs and a per-locale
`translations` map, resolved by two i18n-aware resolvers.
- **`OverridableEntity.overrides`** — a flat, registry-driven JSONB blob
on view / view-field / view-field-group / command-menu-item /
page-layout-tab / page-layout-widget, resolved by a plain spread.

This PR collapses them into **one** concept: a single `overrides` blob,
one registry-driven overridable set, one i18n-aware read path, and one
write path (`computeMetadataOverridesBlob`, extracted in #22404).

Object/field **stay on `SyncableEntity`** (not reparented to
`OverridableEntity`) so their `isActive` default stays **FALSE** — this
sidesteps the `isActive` default conflict entirely.

### GraphQL breaking change (accepted)

The `standardOverrides` field is **removed** with no deprecation alias —
`overrides` (a `JSON` scalar) is exposed instead on `Object` and
`Field`. Product confirmed negligible external usage; the front-end has
no hand-written consumer (only generated types), which are regenerated
here.

### Commit structure (reviewable commit-by-commit)

1. **Unified resolver + parity harness** —
`resolveEffectiveEntityProperty` is a strict superset of the three
legacy resolvers; a corpus parity spec compares it against a *frozen
reference* of the old logic across every locale, `isStandardApp` branch
and override shape.
2. **Registry-driven** — object/field presentation props tagged
`isOverridable` + `translatable`; the overridable/translatable sets are
derived from the registry (a test asserts they equal the legacy
hardcoded lists).
3. **Rename + swap + delete** — `standardOverrides` → `overrides` across
entities, DTOs, flat/universal types, producers, the ~12
resolve/write/create/sync call sites, mocks and specs; the reconciler's
two compare entries collapse to one; the three legacy resolvers, both
DTOs and the hardcoded constants/types are deleted.
4. **Migration (zero-downtime, two-phase)** — split across two releases
so a rolling deploy never drops a column a previous-release pod still
`SELECT`s:
   - **2.19 fast** — add the `overrides` column (schema only).
- **2.19 slow** — backfill `overrides` from `standardOverrides` in
`runDataMigration` (kept out of the schema transaction so the bulk write
doesn't hold the ACCESS EXCLUSIVE lock; skipped on fresh installs, which
have no data to copy).
- **2.20 fast** — drop the legacy `standardOverrides` column (gated by
`TWENTY_NEXT_VERSIONS`, so it stays dormant until the instance reaches
2.20).
5. **Front/client-SDK regen** — regenerated metadata GraphQL types.
6. **Integration specs + i18n** — updated the standard object/field
update integration specs + snapshots, and the reworded validator message
catalog entry.

### Rolling-deploy safety

`standardOverrides` is retained through 2.19 and only dropped in 2.20,
mirroring the codebase's deferred-drop convention
(`isUIReadOnly`/`isCustom`). During the 2.19 rollout both columns exist,
so old and new pods coexist without "column does not exist" errors. The
backfill lives in a slow `runDataMigration` (per the
`no-data-mutation-in-fast-instance-command` rule) so it doesn't stall
reads.

### `isActive` guard

The migration never reads or writes `isActive`; the backfill asserts the
active-row count is unchanged and aborts otherwise. Verified on a real
DB: apply + revert preserves the blob **and** the nested `translations`
map, with `isActive` counts identical before/after.

### Verification (local)

- `nx typecheck twenty-server` + `nx typecheck twenty-front` — green
- `nx lint:diff-with-main twenty-server` (oxlint `--type-aware` + oxfmt)
— green
- `nx test twenty-server` — green (unit + parity + registry + migration
tests)
- `nx run twenty-server:test:integration:with-db-reset` — green
- `database:reset` applies the 2.19 phases and leaves **both** columns
present (2.20 drop stays dormant); backfill + revert round-trip verified
on a real DB
- Metadata integration suites (standard object/field update, application
sync) pass end-to-end against the two-column schema
- Metadata GraphQL types regenerated against a booted server; zero
`standardOverrides` references remain in application code (only the
migration commands + the legacy schema baseline)

---------

Co-authored-by: prastoin <paul@twenty.com>
2026-07-02 12:01:15 +02:00
Paul Rastoin b0d7516951 Deprecate asExpression from field metadata search_vector (#22287)
## Summary

Fully deprecates the cached `asExpression` / `generatedType` settings on
`TS_VECTOR` (searchVector) fields. Previously the generated-column
expression was stored in `FieldMetadataSettings` and kept in sync via
imperative recompute side-effects. It is now **derived at DDL time**
from the `searchFieldMetadata` rows that describe which fields feed the
search vector, making `searchFieldMetadata` the single source of truth
and removing a whole class of cache-drift bugs.

This is delivered across the milestones tracked in #2587 and coordinates
with the frontend migration (#1428).

## Why

- The searchVector expression lived in two places (stored
`settings.asExpression` + the actual generated column), kept consistent
by bespoke side-effects (`recompute-search-vector-on-field-rename`,
label-identifier recompute, etc.).
- The frontend reconstructed the searchable-fields list by
**regex-parsing** the stored `asExpression`.
- Both are brittle. Deriving the expression from `searchFieldMetadata`
rows at build/run time removes the cache and the parsing.

## What changed

### Server - data model & derivation
- Introduce the `tsVectorFieldMetadata` relation on
`searchFieldMetadata` (`tsVectorFieldMetadataId` / universal identifier)
linking each searchable-field row to its target `TS_VECTOR` field.
- New runtime derivation
`deriveSearchVectorAsExpressionForTsVectorField`
(`flat-search-field-metadata/utils/...`) used by the create-object and
update-field handlers to generate the column expression from
`searchFieldMetadata` rows.
- Remove `asExpression` / `generatedType` from stored settings:
`FieldMetadataSettings.TS_VECTOR` is now `null`; the column builder
(`generate-column-definitions.util.ts`) hardcodes `generatedType:
'STORED'` and requires the derived expression.
- Delete the imperative recompute side-effects and the
`compute-search-vector-universal-settings-from-object-manifest` path;
drop the `settings` block from all 28 standard
`compute-*-standard-flat-field-metadata` utils.

### Server - migration runner
- New `rebuildSearchVector` marker on `update-field` actions: the
orchestrator synthesizes targeted column rebuilds
(`compute-search-vector-rebuild-target-universal-identifiers.util.ts` +
the deprioritize aggregator) only when a searchFieldMetadata change or
indexed-field rename actually requires it - instead of rebuilding on
every settings touch.
- Deferrable FKs + in-flight ID resolution so a `searchFieldMetadata`
row and its `TS_VECTOR` field can be created in the same transaction
(deterministic UUIDs).

### Frontend (contract change, #1428)
- New `SearchFieldMetadataDTO` + dataloader exposing
`searchFieldMetadataList` on object metadata.
- `SettingsObjectSearchSection` now reads
`objectMetadataItem.searchFieldMetadatas` instead of parsing
`asExpression`; new `SearchFieldMetadataItem` type, fragment, and
mapping updates.

### Upgrade commands (2.18)
-
`2-18-instance-command-fast-...-add-ts-vector-field-metadata-id-to-search-field-metadata`
-
`2-18-instance-command-fast-...-make-search-field-metadata-fks-deferrable`
-
`2-18-instance-command-slow-...-backfill-ts-vector-field-metadata-id-on-search-field-metadata`

(These were relocated from 2.16 to 2.18 and re-timestamped into an
ordered block - add column -> make FK deferrable -> backfill data -
since 2.16/2.17 are released.)

### Tests
- Updated search-vector side-effect integration specs to assert behavior
(search works) rather than the now-removed `asExpression`; removed the
obsolete expression-validation specs; refreshed the application-sync
snapshot (`universalSettings: null`).

## Upgrade / compatibility notes
- Existing workspaces keep their stored `settings` until a later
cleanup; nothing reads it anymore. The new derivation drives all DDL
going forward.
- Schema changes are gated behind the 2.18 instance commands above.

## Known follow-up (separate PR)
https://github.com/twentyhq/core-team-issues/issues/2620
- The column rebuild (`DROP`/`ADD` of the `searchVector` STORED column)
cascade-drops its GIN index and does not recreate it - a pre-existing
regression on `main` inherited here. A follow-up PR will fix the rebuild
handler to recreate the GIN index and add a 2.18 workspace command to
recompute every search vector + strip the deprecated settings.
(Planned.)

## Test plan
- [ ] `npx nx typecheck twenty-server` / `twenty-front`
- [ ] `npx nx lint:diff-with-main twenty-server` / `twenty-front`
- [ ] Server integration: create/update/delete field, rename indexed
field, update object - search returns expected records
- [ ] Run the 2.18 instance commands on a seeded DB; verify
`tsVectorFieldMetadataId` backfilled and FKs deferrable
- [ ] Frontend: object Search settings tab lists the correct searchable
fields (no `asExpression` parsing)

close https://github.com/twentyhq/core-team-issues/issues/2587

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22287?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. -->
2026-06-30 09:16:01 +00:00
Félix Malfait 41c10b9ee7 feat(server): resolve app-owned metadata translations at runtime (#22235)
## Summary

First of a **4-PR stack** that lets apps built with `twenty-sdk`
translate their metadata, resolved at runtime. The standard Twenty app
is modelled as "an app like any other" — `NULL
applicationRegistrationId` ⟺ the standard app, no special-casing.

This PR adds the server foundation and wires runtime resolution for
**object** and **field** metadata:

- New `applicationTranslation` core table + entity (nullable
`applicationRegistrationId`, `locale`, `messages` jsonb), one row per
(app, locale) to avoid multi-MB rows.
- `ApplicationTranslationCacheService` (process-local, 30s TTL) +
`ApplicationTranslationSyncService` (upsert + soft-delete from a
manifest).
- Shared `translateStandardLabel` util: application catalog → i18n
bundle → source value.
- Object/field resolvers + dataloaders prefetch and apply the per-app
catalog. The new `applicationCatalog` param is **optional**, so standard
behaviour is byte-unchanged.
- Fast instance command to create the table.

## Stack
**PR 1/4**, targets `main`. Followed by: (2) twenty-sdk extract/compile
→ `manifest.translations`, (3) resolution across the remaining metadata
resolvers, (4) the per-locale standard-override editor.

## Tests
Unit: `translateStandardLabel`, `resolveObjectMetadataStandardOverride`
(including the application-catalog path).

## Verification note
The remote dev environment for this branch could not complete `yarn
install` (no package-registry egress), so typecheck/lint/tests were not
run locally — **CI is the source of truth** for this stack. Changes
follow existing patterns.

https://claude.ai/code/session_01NiE7o3cd3zCLZarVkJa6UA

---
_Generated by [Claude
Code](https://claude.ai/code/session_01NiE7o3cd3zCLZarVkJa6UA)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22235?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. -->
2026-06-28 07:36:21 +02:00