Commit Graph

2606 Commits

Author SHA1 Message Date
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
neo773 9f5d17d1f0 Add receipt metrics and logs to connected account sync webhooks (#22853)
Webhook deliveries from Google and Microsoft were invisible at the app
level: successful notifications produced no logs and no metrics, so
webhook-triggered syncs could not be told apart from cron polling.

Add two counters, connected-account-sync-webhook/received/messaging and
/received/calendar, mirroring the sync-job metric umbrellas, and log a
line whenever a notification triggers a sync. Unmatched subscriptions
keep their existing warn logs.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22853?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-13 14:28:21 +02:00
Paul Rastoin 1b168ac1f7 fix(server): gate workspaceDiscoverability behind upgrade decorator (#22818)
## Context

Needs to be patched on 2.20, will craft a 2.19 equivalent with fallback
asap ( be it won't be merged unlike this one )

Fixes #22662. Follow-up to #22423, which introduced
`workspaceDiscoverability`.

A clean 2.18.x to 2.19 upgrade breaks on login with:

```
QueryFailedError: column workspaceDiscoverability does not exist
```

`workspaceDiscoverability` was added to `WorkspaceEntity` (in #22423) as
a plain, always-selected, non-nullable column, so any workspace query
(including the auth-path `findAvailableWorkspacesByEmail` lookup) fails
as soon as the ORM selects it, before the `2.19` upgrade command that
creates the column has run. Because the Docker entrypoint is fail-open,
the API starts even if the upgrade is delayed, and users hit this on
their first login.

## Changes

- Add `@WasIntroducedInUpgrade` to `workspaceDiscoverability`,
referencing the existing `2.19.0` fast instance command that creates the
column. The upgrade-aware ORM then skips the column until the command
has actually added it, keeping login working during the upgrade.
- Keep the GraphQL `@Field` non-nullable and add a
`workspaceDiscoverability` `@ResolveField` that falls back to
`WorkspaceDiscoverability.PUBLIC` while the column is hidden, so the
resolver never returns `null` for the non-nullable field during the
upgrade window.

This mirrors the existing pattern already applied to `FileEntity.status`
and `FileEntity.applicationRegistrationId`, and the resolver-default
pattern already used for `fastModel` / `smartModel` / `logo`.

## Cherry-pick

This fix needs to be cherry-picked onto both the **2.19** and **2.20**
release branches, since affected instances are upgrading into those
versions.

## Test

- `validate-upgrade-aware-entity-decorators` and
`resolve-entity-shape-at-upgrade-cursor` unit tests pass (the referenced
upgrade command name resolves correctly).
- `upgrade-aware-repository.proxy` and
`upgrade-aware-entity-metadata.adapter` specs pass.
- `typecheck` and lint pass for `twenty-server` and `twenty-front`.
- Regenerating the GraphQL schemas produces no diff (the field stays
non-nullable).
2026-07-13 12:20:28 +00:00
martmull 7381038452 Paginate admin panel app registrations list (#22734)
## Context

The `findAllApplicationRegistrations` query on the admin panel Apps page
(`/settings/admin-panel#apps`) loaded every application registration at
once, with search and filtering done client-side.

## Changes

**Server**
- `findAllApplicationRegistrations` now takes `limit` / `offset` /
`searchTerm` / `isPreInstalledOnly` args and returns a
`PaginatedApplicationRegistrations` object (`registrations`,
`totalCount`, `hasMore`), following the same pattern as `getQueueJobs`.
- `ApplicationRegistrationService.findAll` uses `findAndCount` with
`take`/`skip`, and moves the search (name, source package, universal
identifier via `ILIKE`) and the pre-installed filter into the SQL query,
mirroring how `getInstalledWorkspacesGlobal` filters installed
workspaces.

**Frontend**
- `SettingsAdminApps` passes the page, the debounced search term (300ms,
like the installed workspaces table), and the pre-installed toggle as
query variables instead of filtering client-side.
- Adds a Previous / Next pagination footer (25 per page) matching the
queue jobs table, shown only when there is more than one page.
- The "unconfigured first" ordering is kept within each page
(`isConfigured` is a dataloader-resolved field, so it can't be sorted in
SQL).

## Notes
- Regenerated `generated-admin/graphql.ts` follows in a subsequent
commit.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22734?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: Weiko <corentin@twenty.com>
2026-07-13 14:07:11 +02:00
github-actions[bot] 983f03adbe i18n - translations (#22833)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-10 20:36:28 +02:00
neo773 b0dc637dbd Throw proper error on duplicate emailing domain (#22790)
Adding an emailing domain that already exists blew up with a raw
QueryFailedError and the client just saw a generic "An error occurred".
The unique index on domain is global, so the workspace-scoped existence
check never caught rows owned by another workspace.

Now the check is unscoped and throws an EmailingDomainException mapped
to CONFLICT with a proper user-facing message, in both the
createEmailingDomain mutation and the email group channel flow. Also
dropped the hardcoded catch-all snackbar on the new channel page so
server messages actually reach the user.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22790?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 20:28:34 +02:00
twenty-pr[bot] ab9e6f30b8 chore: bump version to 2.21.0 (#22820)
## Summary

- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version
- Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same
version

## Checklist

- [ ] Verify version constants are correct
- [ ] Verify npm package versions match

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

Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
2026-07-10 15:46:26 +02:00
Weiko 666ceb41d5 Fix SDK plan on non installed apps (#22805)
# Context

`yarn twenty plan` fails when the app has never been installed in the
target workspace:

```
Sync failed with error: Application "f5ce204f-..." is not installed in workspace "10f39a9d-...". Install it first.
Hint: run `yarn twenty dev --once` to register the app in this workspace, then retry.
```

This forces developers to apply before they can plan, which defeats the
purpose of `plan`. Planning a not-yet-installed app is well defined: the
from-state is empty, so the plan is simply "create everything".

## Why it failed

The dry-run sync required the application row to exist in two places:

1. `ApplicationSyncService.synchronizeFromManifest` threw
`APP_NOT_INSTALLED` when the app row was missing, because the dry-run
needs an owner `FlatApplication` to anchor the from → to metadata diff.
2.
`WorkspaceMigrationFlatEntityMapsService.computeAllInvolvedApplicationIds`
threw when the owner app id was absent from `flatApplicationMaps`, even
though the only hard dependency of a build is the twenty standard
application.

`apply` never hit this because it registers the app row as a side effect
before syncing (and even swallows this exact error on its pre-apply
plan).

# What this PR does

Keeps `plan` strictly read-only, no registration or app row is created:

- **`application-sync.service.ts`**: on dry-run, resolve the owner to
the installed application when it exists (unchanged behavior), otherwise
build a virtual, non-persisted `FlatApplication` from the manifest. Its
freshly generated id matches no existing metadata, so the from-state
slice resolves to empty and every manifest entity shows up as a create.
- **`workspace-migration-flat-entity-maps.service.ts`**: relax the guard
so only the twenty standard application is required. A missing owner app
just contributes an empty from-slice instead of throwing. Installed apps
take the exact same path as before (`applicationId` defined → identical
behavior).
2026-07-10 15:11:46 +02:00
Thomas Trompette c1b62334b7 fix(workflow): scope one-active-per-workflow index to workspace (#22795)
## Problem

The Phase 0 core index \`IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW\`
is on \`(workflowId) WHERE status='ACTIVE'\`, with **no
\`workspaceId\`**. But \`core.workflowVersion\` is a shared multi-tenant
table, so this enforces "one active version per workflowId **globally
across all workspaces**" instead of per workspace.

The version backfill fails on staging with:
\`\`\`
duplicate key value violates unique constraint
"IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW"
Detail: Key ("workflowId")=(8b213cac-...) already exists.
\`\`\`
across several different workspaces that share the same workflowId
(seeded/cloned data): workspace A's active version claims the
workflowId, and every other workspace's insert collides. Every other
index on this table includes \`workspaceId\`; this one dropped it when
copied from the per-tenant workspace entity.

## Fix

Index becomes \`(workspaceId, workflowId) WHERE status='ACTIVE'\` — one
active version per workflow **per workspace**, matching the table's
multi-tenant design and the intended invariant. New 2-20 fast instance
command drops and recreates the index (Phase 0's command is
merged/append-only).

## Test

Reset + reproduce the exact scenario against the fixed index:
- two workspaces with the same workflowId, both ACTIVE → **insert
succeeds** (previously collided)
- a second ACTIVE version for the same workflow within one workspace →
**still blocked** (invariant preserved)

Zero \`migrate:generate\` drift, typecheck + lint clean. After this
deploys, re-run \`upgrade:2-20:backfill-workflow-version-to-core\`.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22795?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 12:48:33 +00:00
Abdul Rahman d23511ea5e remove nestjs-query from user and workspace resolvers (#22766)
## What

Migrates the `user` and `workspace` core modules off
`@ptc-org/nestjs-query`. Both used `NestjsQueryGraphQLModule` only as
scaffolding — all CRUD was disabled and the real GraphQL API is already
served by the hand-written `UserResolver` / `WorkspaceResolver`.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22766?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: Weiko <corentin@twenty.com>
2026-07-10 16:59:58 +05:30
Abdul Rahman 4ab36a630b remove nestjs-query from app-token and drop unused createOneAppToken mutation (#22765)
## What

Migrates `app-token` off `@ptc-org/nestjs-query` and removes the
auto-generated `createOneAppToken` mutation, which was unused dead API
surface.

## Why

The `createOneAppToken` mutation was reachable only from the schema — no
frontend query, SDK caller, or test used it. It was also non-functional
(its input couldn't set the token `value`), a leftover from
nestjs-query's default `create.one` being left enabled. Real app tokens
(refresh, password-reset, email-verification, invitation, OAuth,
enterprise) are all created directly via the repository in ~14 services,
none of which touched this mutation.

## Notes

- ⚠️ This removes `AppToken`, `createOneAppToken`,
`CreateAppTokenInput`, and `CreateOneAppTokenInput` from the `/metadata`
schema, so the **api-breaking-changes check will flag it**


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22765?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: Weiko <corentin@twenty.com>
2026-07-10 16:14:13 +05:30
Paul Rastoin 9e20e2222a Fix front-component serving on Safari, kill stale presigned caching, and cache built bundles client-side (#22672)
## Context

Built front-component bundles are served via `GET
/rest/front-components/:id/:cacheKey`. On S3-backed storage (Twenty
Cloud) the endpoint used to 302-redirect the worker's authenticated
fetch to a presigned S3 URL. That redirect caused two bugs, and fixing
it removed the caching the redirect was accidentally providing — so this
PR also adds a proper client-side cache.

Closes twentyhq/core-team-issues#2653.

### Bug 1 — Safari 403 (Authorization header forwarded across redirect)

The renderer worker fetches the bundle with `Authorization: Bearer`. The
controller answered with a 302 to a presigned S3 URL. Per the Fetch
spec, browsers must strip `Authorization` on a cross-origin redirect.
Chrome/Firefox do, but Safari/WebKit forwards it, so S3 receives both a
query-string signature and an `Authorization` header and rejects with
`InvalidArgument: Only one auth mechanism allowed`. Result: front
components never load in Safari on S3-backed storage.

### Bug 2 — 302 cached publicly (browser-independent)

The redirect branch set no `Cache-Control`, so a CDN could cache it far
beyond the presigned URL's TTL (`STORAGE_S3_PRESIGNED_URL_EXPIRES_IN`,
900s). Consequences: any client re-served the cached 302 after 15 min
hits an expired signature (403, also affects Chrome), and the cached
redirect containing a live presigned URL is served to unauthenticated
requests (short-lived auth bypass).

### Regression this introduces — warm-load caching lost

Marking the handoff `no-store` (Bug 2 fix) is correct, but it means the
built bundle is no longer cached anywhere on the S3 path. The browser
HTTP cache cannot compensate: the presigned URL that actually returns
the bytes carries a fresh `X-Amz-Date`/`X-Amz-Signature` on every
request, so each download is a brand-new cache key and never hits. Net
effect without mitigation: every worker mount re-downloads the full
bundle.

## What changed

- **Front components return a 200 JSON body instead of a 302.** The
controller now responds `200 { url }` with `Cache-Control: private,
no-store`. The worker parses the JSON and issues a separate header-less
`fetch(url)` to S3. No redirect means the `Authorization` header is
never forwarded, making it browser-independent, and the handoff carrying
the presigned URL is never cached. The stream path (local storage) is
unchanged.
- **Client-side bundle cache in the renderer (restores warm loads).**
`fetchComponentSource` wraps the fetch chain in a `CacheStorage` layer
keyed by the **content-addressed** `/front-components/:id/:checksum.js`
URL. A hit returns the stored bundle and skips **both** the `no-store`
handoff to Twenty and the S3 download — restoring cross-session warm
loads without ever persisting a presigned credential. Because
`CacheStorage` is writable by any same-origin code (including the
untrusted component code this cache feeds), cached content is verified
against the sha-256 checksum embedded in the URL on every read, and
evicted on mismatch. Caching degrades to a plain fetch where
`CacheStorage` or WebCrypto is unavailable.
- **sha-256 checksums for built front components.** The SDK build and
workspace prefill now fingerprint built front-component bundles with
sha-256 (WebCrypto has no md5), enabling the integrity check above.
Other file folders keep md5. Legacy md5-fingerprinted URLs (32-hex)
simply bypass the cache — already-synced components keep working and
start benefiting from caching on their next build/sync.
- **WebKit e2e coverage.** Added a `webkit` project to the postcard
example's Playwright config mirroring `chrome` (shared setup +
storageState), plus iframe/worker diagnostics logging so front-component
failures surface in the test log. `TZ` is pinned to `Europe/Paris`
because WebKit on Linux ignores Playwright's `timezoneId` emulation and
rejects the runner's legacy `CET` alias, which crashed the record page
before the component could render.

### Why we hand off to S3 instead of streaming through Twenty

On S3-backed storage we deliberately **do not** proxy/stream the bundle
bytes through the API. The controller returns the presigned URL and the
worker fetches the content directly from S3, for two reasons:

- **Server CPU/bandwidth.** Streaming every bundle on every cold load
would put the API server on the hot path for all front-component
content. Handing off to S3 keeps that load off the server.
- **Domain isolation.** Front-component content is fetched from the
object-storage domain (e.g. `s3.domain.com`), a different origin than
the API and the front app. Serving untrusted/app-authored bundle content
from a separate domain than `twenty.com` keeps it off the app's origin.

The stream path is kept only as the local-storage fallback (no
S3/presign available), where these concerns don't apply.

## Examples

### The JSON handoff (S3 path)

```http
GET /rest/front-components/d3b07384-.../a1b2c3d4.js HTTP/1.1
Host: twenty.com
Authorization: Bearer <worker-token>
```

```http
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, no-store

{"url":"https://s3.domain.com/bucket/.../checkout-widget.mjs?X-Amz-Date=20260709T091500Z&X-Amz-Expires=900&...&X-Amz-Signature=AAAA1111..."}
```

The worker then fetches that presigned URL **without** headers (the
Safari fix) and gets the bundle bytes.

### Why the browser HTTP cache can't reuse it

| | Load 1 (09:15) | Load 2 (09:30) | Same key? |
|---|---|---|---|
| Twenty handoff URL | `.../a1b2c3d4.js` | `.../a1b2c3d4.js` |  but
response is `no-store` |
| Presigned `X-Amz-Signature` | `AAAA1111...` | `ZZZZ9999...` |  |
| Effective S3 URL (the HTTP cache key) |
`...&X-Amz-Signature=AAAA1111...` | `...&X-Amz-Signature=ZZZZ9999...` |
 new key → miss |

### What the CacheStorage layer stores

```
key   = https://twenty.com/rest/front-components/d3b07384-.../a1b2c3d4.js   (stable, chosen by us)
value = <bundle JS bytes>                                                   (NOT the presigned URL)
```

Keying by the stable logical URL (not the volatile URL the bytes arrived
from) is the one thing the native HTTP cache can't express. The
presigned URL is used once and discarded.

### Invalidation

No TTL and no explicit delete — invalidation is by key change. A rebuild
changes the checksum → changes the URL → guaranteed miss on the new key.
The old entry is orphaned and reclaimed by normal browser eviction
(quota/LRU; Safari ITP after 7 idle days). Global invalidation lever:
bump the cache name suffix (`front-component-source-v1`).

## Deploy note — front/server release window

Old frontend bundles (already-open tabs) hitting the new server receive
the JSON handoff where they expect raw JS and fail to render until the
tab is reloaded. The other direction is safe: the new worker against an
old server follows the 302 transparently (the content-type check falls
through to `response.text()`). Accepted as a short deploy-window
trade-off.

## Follow-ups (not in this PR)

- The client-side cache is a bridge for the `no-store` presigned
handoff. If built components are later served from a stable, non-signed,
public-by-URL path (they are already content-addressed by checksum, so
`immutable` is safe), the browser + CDN cache natively and this custom
layer can be removed.
- `GET /file/:fileFolder/:id` presigned 302s still carry no
`Cache-Control`. An explicit policy there (bounded `private, max-age`
below the presigned TTL) was prototyped in this PR and deliberately
dropped to keep the scope on front components — the file path
authenticates via a query-param token (part of any cache key), so its
exposure differs and deserves its own PR.

## Non-goals

Per the issue, file serving keeps its query-param token + 302 model.
Native browser loads (`<img>`, downloads) cannot do a two-step fetch and
already work on Safari. The public-asset redirect is left untouched
since its caching is intentional.

## Test plan

- Renderer: `fetchComponentSource.spec.ts` covers cache miss + write,
verified cache hit (no network), poisoned-entry eviction,
checksum-mismatch (never cached), non-fingerprinted and legacy-md5 URL
bypass, and the no-`CacheStorage` / no-WebCrypto fallbacks.
`fetchComponentSourceFromNetwork.spec.ts` covers the direct JS response,
the JSON handoff follow-through (header-less presigned fetch), and error
mapping.
- e2e: the postcard front-component spec now runs on both Chromium and
WebKit against prod-parity storage (S3 + Lambda).
- `oxlint` + `oxfmt` clean; typecheck passes on changed packages.

### Reproduction proof — Safari was always broken (e2e probe)

We ran the prod-parity postcard e2e suite (S3 storage + Lambda) with
WebKit against **`main` without this fix**, via a throwaway probe PR:
twentyhq/twenty#22717.

Result — [ci-privileged run
29015624468](https://github.com/twentyhq/ci-privileged/actions/runs/29015624468):

```
1 failed
  [webkit] › card-front-component.spec.ts:61 › renders the postcard name and status badge in the record preview
2 passed (1.4m)
```

`[webkit]` times out waiting for `getByTestId('postcard-card')` to
become visible (*element(s) not found*) while the Chromium run of the
same spec passes. This confirms the front component **never rendered in
Safari** on S3-backed storage prior to this PR — it is a genuine,
browser-specific bug, not a flake. The fix in this PR is expected to
turn that same `[webkit]` assertion green.

Note: running the WebKit tests in CI requires the WebKit browser binary
and its system dependencies in the e2e job (now installed via `npx
playwright install --with-deps chromium webkit`).
2026-07-10 10:12:58 +00:00
martmull 1c8b8970fd Allow CLI dev mode on catalog-synced apps without mutating the shared registration (#22756) 2026-07-10 12:10:00 +02:00
github-actions[bot] f8d3555fe7 i18n - translations (#22779)
Created by Github action

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-10 12:05:07 +02:00
github-actions[bot] 09496a0f98 i18n - translations (#22745)
Created by Github action

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-07-10 11:56:49 +02:00
neo773 f547da4ee9 Gate connected-account webhook subscriptions behind config (#22761)
Now gated behind IS_CONNECTED_ACCOUNT_WEBHOOK_SUBSCRIPTION_ENABLED
(default false).

Merge + deploy twentyhq/twenty-infra#780 first.

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

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-07-10 15:03:53 +05:30
Abdul Rahman a360f9bdec migrate 10 modules off NestjsQueryTypeOrmModule wiring (#22763)
## Summary
Continues the incremental removal of `@ptc-org/nestjs-query`. Migrates
ten modules from `NestjsQueryTypeOrmModule.forFeature` to the standard
`TypeOrmModule.forFeature`. These modules only used `nestjs-query` for
repository registration — none register `NestjsQueryGraphQLModule` /
auto-generated
resolvers — so this is a pure module-wiring swap with no behavior or
schema change.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22763?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: Weiko <corentin@twenty.com>
2026-07-10 09:21:52 +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
Raphaël Bosi 9c405384f3 Only propose configured apps during onboarding install step (#22712)
## What

- Onboarding "Install your first apps" now proposes only apps that are
actually installable: it intersects the onboarding list with
`findManyMarketplaceApps`, which the backend already filters to listed +
configured apps (all required server variables set).
- If none are available, the step auto-skips. If the marketplace query
fails, it shows an intentional fallback (heading + Skip) instead of
silently skipping or rendering an empty install card.
- `findManyMarketplaceApps` now accepts `universalIdentifiers`, so
onboarding fetches and configuration-checks only its own apps instead of
the entire catalog.

## Why

Previously the step rendered all hardcoded apps regardless of
configuration, only borrowing logos from the marketplace, so a user
could be offered an app the admin never configured. This centralizes
onboarding availability on the marketplace's existing logic and keeps
the query bounded as the marketplace grows.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22712?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:12:11 +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
Raphaël Bosi f06ba08b16 Fix onboarding locale reverting to English after signup (#22675)
During onboarding the UI reverted to English after email verification.
The chosen locale was never stored on the user, so the workspace member
seeded from it inherited `en`, and after workspace activation
`loadCurrentUser` reactivated `en` and flipped the whole UI to English
regardless of the browser language.

Two changes:

**Backend (the actual fix):** the `signUp` resolver received
`signUpInput.locale` but only used it for the verification email,
creating the user without it (so it defaulted to `en`). It now passes
the locale into `signUpWithoutWorkspace`, and the SSO create-a-workspace
path forwards `locale` as well. The user, and the workspace member
created from it at activation, now keep the chosen locale, so the
post-activation reactivation no longer forces English.

**Frontend:** carry the active locale across the workspace subdomain
redirect (in `useBuildSearchParamsFromUrlSyncedStates`) so the freshly
loaded subdomain renders `/verify` in the right language before the
current user loads, instead of briefly falling back to the browser
default. Minor, only affects the case where the chosen locale differs
from the browser language.
2026-07-09 14:08:09 +00:00
Félix Malfait 6897fff632 Rebuild email composer recipient fields as a structured chip input with person resolution and autocomplete (#22668)
# Why

The To/Cc/Bcc fields reused `FormMultiTextFieldInput`, the workflow
Tiptap tag editor, with recipients stored as a comma-separated string.
That caused every reported issue: duplicates were allowed, the field was
locked to one 32px line with a hidden horizontal scrollbar, chips did
nothing on click, `First Last <email>` could not even be typed (space
committed a tag) and was rejected by the backend when pasted, chips
could not be edited, invalid addresses only failed server-side after
pressing Send, and there was no autocomplete at all.

## The model

A recipient is `{ address, displayName? }`. Person and workspace member
are never stored in composer state; they are resolved live from the
address at render time, mirroring how `MatchParticipantService` links
`messageParticipant.handle` to `personId`/`workspaceMemberId` on the
receive side. Entities appear at the edges (autocomplete in, chip
display out); state, dedupe, validation, and send operate on addresses
only. The send path is unchanged: `SendEmailInput.to/cc/bcc` stay
comma-separated bare addresses.

# What changed

New module `activities/emails/recipients/` (the workflow editor is
untouched; its other consumers are unaffected):

- **`EmailRecipientsFieldInput`**: wrapping chip rows (up to ~3 lines,
then scroll), commit on Enter/Tab/comma/semicolon/blur, space commits
only when the buffer is already a valid email, paste parses RFC 5322
lists (names, quoted commas, semicolons, newlines), case-insensitive
dedupe with a flash on the existing chip, invalid addresses become red
chips that disable Send, double-click or keyboard editing in place with
Escape revert, Backspace select-then-delete, arrow-key chip navigation,
Ctrl/Cmd+Enter commits a pending buffer or sends when the buffer is
empty.
- **Person resolution**: chips resolve against People
(`emails.primaryEmail`, case-insensitive) and workspace members,
rendering avatar + name when known and degrading to a plain address chip
otherwise.
- **Chip menu**: person/member header, Copy email, Edit, Remove, and Add
as person for unknown addresses (creates the Person; the chip upgrades
in place).
- **Autocomplete**: blends context people (company you are composing
from, or the company behind a person/opportunity), ranked people search,
workspace members with a Team member badge, and a literal "Use this
email" row ranked first when the typed buffer is a valid address.
Suggestions exclude addresses already present in any field. Enter picks
the highlighted or top row.
- **Prefill**: replies and drafts preserve participant display names
(`getEmailDraftPrefillFromMessage`, `useReplyContext`).
- `useEmailComposerState` holds `EmailRecipient[]` per field and blocks
send on invalid recipients; the recipient-limit warning is surfaced
again in the composer.
- The Send Email engine command passes the record context so context
suggestions work from the record page action.
- `EmailsFilter` was missing from the shared `LeafFilter` union, so
nothing could filter on `emails.primaryEmail`; added (additive).
- New dependency `addressparser@1.0.1` in twenty-front, the same package
and version the server already uses to parse inbound mail headers, so
both sides parse identically. Tiny, dependency-free, browser-safe.

# Decisions and tradeoffs

- Person resolution matches on `emails.primaryEmail` only,
case-insensitively via per-address `ilike` filters (no `%` wildcards,
`%_\` escaped). `additionalEmails` is a JSONB array and not cleanly
filterable through the GraphQL filter API today; the server-side matcher
checks additional emails too, so a chip may show as a plain address even
though the send still links to the person via participant matching.
- Chip flash-on-duplicate replays its CSS animation by remounting the
chip subtree (nonce in the React key), chosen over animation-restart
hacks; the remount is invisible.
- Keyboard chip selection keeps DOM focus on the input and tracks a
virtual `selectedChipIndex` (`aria-activedescendant`) instead of roving
focus across chips: one focus point, no focus juggling, standard
combobox listbox pattern.
- `flushSync` (precedent: `Dropdown.tsx`) focuses and places the caret
after entering chip-edit mode; the alternative was a useEffect on
editing state.
- Suggestion rows `preventDefault` on mousedown so picking a suggestion
never blurs the input (blur would first commit the half-typed buffer as
a junk chip).
- Cmd/Ctrl+Enter inside a recipient field: with a non-empty buffer it
commits the buffer only; with an empty buffer it sends via an `onSubmit`
prop wired to `handleSend`. Not commit+send in one stroke: `handleSend`
holds a same-render closure over composer state, so sending in the same
event would read the pre-commit recipients. E2E also showed the side
panel's own ctrl+Enter hotkey never fires while any form field is
focused (focus-stack scoping, applies to the old composer too), which is
why the field triggers the submit itself.
- Enter with suggestions open picks the highlighted (or top) suggestion,
Gmail-style. When the typed buffer is itself a valid email, the literal
row is ranked first so Enter keeps meaning "add what I typed".
- Suggestions are disabled while editing a chip (the edit buffer holds
`Name <email>` text, a poor search query).
- Dedupe blocks within a field; across fields typed duplicates are
allowed (sometimes intentional), but suggestions exclude addresses
already present in any of To/Cc/Bcc.
- Chip menu actions never navigate: navigating the side panel (or main
view) unmounts the composer and silently destroys the draft, since
composer state is component-local with no draft persistence. "Add as
person" creates the record and shows a snackbar while the chip upgrades
in place; the person header row is informational. "Open person"
navigation should come back once drafts survive navigation.
- The reply composer gets no context record: its widget target record is
the message thread, not a person/company, and replies already prefill
participants.
- If two people share a primary email, the last fetched match wins for
chip display (no ambiguity UI).
- "Add as person" splits the display name on the first space for
firstName/lastName, the same heuristic the contact-creation manager uses
server-side.

# Deferred

- Display names on the wire (`Name <email>` in outbound headers): needs
`SendEmailInput` / `EmailComposerService.validateEmails` changes
server-side.
- Drag chips between To/Cc/Bcc; collapse-on-blur to one line with a "+N
others" summary.
- Frequency/recency ranking of suggestions from `messageParticipant`
aggregates.
- "Open person" from the chip menu, pending draft persistence across
navigation.

# Verification

Unit tests cover the parser, formatter round-trip, merge/dedupe, and the
field state machine (commit, dedupe flash, edit, cancel, keyboard
selection). Typecheck, lint, and the email module suites pass, plus the
shared and side-panel suites.

Every flow was also driven end to end with Playwright against seeded
data: prefill resolution, context and typed suggestions, keyboard
navigation and picks, dedupe flash, RFC 5322 paste, invalid chips gating
Send, wrapping, in-place editing, chip menus, clipboard copy, Add as
person with live chip upgrade, Cc/Bcc exclusions, and the Ctrl+Enter
send path (the mutation reached the server; it failed only on the seeded
account's missing refresh token, expected outside a real provider
connection).

Screenshots of each verified behavior:
https://claude.ai/code/artifact/1743f05d-422e-43d0-bbea-a34a0470c180

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22668?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 13:11:09 +02:00
github-actions[bot] a0cf4cc9e1 i18n - translations (#22714)
Created by Github action

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-09 11:46:19 +02:00
Etienne a51c37dae5 feat(ai) - add light AI chat turn instrumentation (metrics + Sentry correlation) (#22692)
## Summary

Adds minimal server-side observability for AI chat turns: lifecycle
counters to measure success/failure rates, Sentry scope tags to
correlate API and worker traces, and per-LLM-call telemetry metadata for
turn/stream correlation.

- Add turn lifecycle metrics: `ai-chat/turn-started`,
`ai-chat/turn-completed`, `ai-chat/turn-failed` (with `failure_phase`
and `error_code` attributes)
- Emit counters at key points: job start, clean completion, execution
failures, enqueue failures, interrupted streams, and empty completions
- Tag Sentry scope with `streamId`, `turnId`, `threadId`, and
`workspaceId` at API entry points (`sendChatMessage`,
`retryChatMessage`, `answerAgentChatQuestion`) and worker entry
(`StreamAgentChatJob`)
- Enrich LLM `experimental_telemetry` metadata with
stream/turn/thread/workspace IDs
- Return `turnId` from streaming service methods so resolvers can tag
the scope
- Remove granular tool-learned/skill-loaded metrics in favor of the
turn-level counters

## Test plan

- [ ] Send a chat message and verify `ai-chat/turn-started` and
`ai-chat/turn-completed` increment
- [ ] Trigger a stream failure (e.g. interrupted/dead stream) and verify
`ai-chat/turn-failed` with correct `failure_phase`
- [ ] Retry a failed turn and confirm a new `turn-started` is emitted
for the retry attempt
- [ ] Answer an `ask_questions` prompt and confirm Sentry tags include
`streamId` and `turnId`
- [ ] Check Sentry spans for LLM calls include `streamId`, `turnId`,
`threadId`, `workspaceId` in telemetry metadata
- [ ] Run unit tests:
- `npx jest
packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/jobs/__tests__/stream-agent-chat.job.spec.ts`
- `npx jest
packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/agent-chat-streaming.service.claim.spec.ts`
- `npx jest
packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/agent-chat-streaming.service.retry.spec.ts`

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22692?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 08:24:27 +00:00
Etienne f2d0a1b90a fix(billing): only reactivate suspended workspaces when subscription is in good standing (#22687)
## Context

When a subscription's trial ends without a valid payment method, Stripe
emits
`customer.subscription.updated` (`active → past_due`) within seconds of
`trial_end`.
Our webhook handler correctly suspends the workspace for this event,
because it lands
inside the 24h "trial just ended" window checked by
`shouldSuspendWorkspace`.

However, the workspace does not *stay* suspended if an other
subscription update event arrived.

## Problem

Reactivation was gated only on the negation of the suspend heuristic:

```ts
} else if (workspace.activationStatus === WorkspaceActivationStatus.SUSPENDED) {
  await this.workspaceService.reactivateWorkspace(workspaceId);
}

<!-- This is an auto-generated description by cubic. -->
<a href="https://cubic.dev/pr/twentyhq/twenty/pull/22687?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 09:38:30 +02:00
Félix Malfait 0755855768 fix(billing) - skip paying upgrade invoices already settled at finalization (#22705)
## Problem

Upgrading resource credits fails with `Invoice is already paid` whenever
the one-off upgrade invoice resolves to a $0 amount due. That is the
case for internal workspaces on the `Twenty Internal - 100% FREE`
coupon, and for customers whose credit balance covers the price
difference.

Reproduced on an internal workspace (5 to 20 credits upgrade):

1. `createImmediateUpgradeInvoice` creates the invoice for the $20 diff
and finalizes it with `auto_advance: true`
2. The 100% coupon brings the amount due to $0, and Stripe settles
zero-due invoices at finalization ("Invoice was finalised and
automatically marked as paid because the amount due was US$0.00")
3. The explicit `stripe.invoices.pay()` that follows is rejected with a
400 `invalid_request_error`: "Invoice is already paid"
4. The error propagates, so the mutation aborts before
`runSubscriptionUpdate`: the Stripe subscription item stays on the old 5
credits price while the upgrade invoice already exists
5. Every retry creates a new invoice item + invoice and fails the same
way, leaving stray $0 invoices on the customer

## History

Third pass on this code path:

- #21097 treated it as a race with `auto_advance` and switched
finalization to `auto_advance: false`. Zero-due invoices are settled at
finalization regardless of that flag, so the failure remained.
- #21450 restored `auto_advance: true` and swallowed the error when
`error.code === 'invoice_already_paid'`. Stripe does not send that code
for this failure (it is not in its documented error codes; the response
only carries the message), so the guard never matched and the error was
always rethrown.

## Fix

Rely on invoice status instead of error codes:

- `finalizeInvoice` returns the finalized invoice; when it comes back
`paid` (the zero-due case), skip `pay` entirely
- if `pay` still fails (the genuine auto_advance race from #21097),
re-retrieve the invoice and only rethrow when it is actually unpaid

## Tests

Unit tests for `createImmediateUpgradeInvoice`:

- open invoice after finalization gets paid
- invoice settled at finalization skips `pay`
- `pay` failure with a meanwhile-paid invoice is swallowed
- `pay` failure with an unpaid invoice is rethrown

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22705?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 09:09:58 +02:00
neo773 3a5545c753 chore: remove IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED flag (#22680)
Messaging/calendar webhook subscriptions are now always on; drop the
feature flag gate and its enum/public-flag registration.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22680?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 23:35:35 +02:00
Thomas Trompette ca90a9358f feat(workflow): backfill workspace workflowVersion into core (phase A) (#22663)
## workflowVersion -> core, Phase A

Follows #21674 (Phase 0, merged). Base: `main`.

Populates core `workflowVersion` and keeps it in sync with the workspace
object, so a later phase can switch reads to core. Reads stay on the
workspace object in this PR.

### 1. Backfill (upgrade command)
`BackfillWorkflowVersionToCoreCommand`, a
`@RegisteredWorkspaceCommand('2.20.0', ...)`. Per workspace, reads all
workspace `workflowVersion` records and upserts them into core,
preserving ids (idempotent), dry-run aware.

### 2. Dual-write (always on, not flag-gated)
`WorkflowVersionCoreDualWriteListener` hooks
`@OnDatabaseBatchEvent('workflowVersion', CREATED/UPDATED/DELETED)`
(same mechanism as the existing workflow-version status listener) and
mirrors every mutation into core. Sync failures are logged, never break
the user's write; drift is repaired by re-running the backfill command.

Dual-write is deliberately not behind a flag: reading from core (next
phase) is only safe if core has been continuously in sync since the
backfill. An always-on mirror makes "core is fresh" an invariant, so the
read switch becomes a plain flag flip. The cost is one extra upsert on
infrequent workflowVersion writes.

`IS_WORKFLOW_VERSION_IN_CORE_ENABLED` is reserved for the read switch
(Phase B): dispatch from the `workflowAutomatedTriggerMaps` cache,
runner and builder reading trigger/steps from core. Until then it gates
nothing.

Both the backfill and the listener go through a single
`WorkflowVersionCoreSyncService` (`upsertToCore`/`deleteFromCore`): the
workspace-to-core mapping (`trigger` -> `triggers[]`, plus `steps`,
`status`, `workflowId`) and `workflowAutomatedTriggerMaps` invalidation
live in one place.

### Rollout plan (following phases)
- **B, read switch (flag per workspace):** reads move to core; writes
keep flowing workspace -> listener -> core. Rollback = flip the flag
back, workspace never stopped being source of truth.
- **C, contract (code change):** write paths write trigger/steps to core
directly; workspace `workflowVersion` stays as a thin shell
(nav/relations/search) but drops the trigger/steps columns; listener and
flag removed.

### Not in this PR
- Reconciliation tooling beyond re-running the backfill.
- The read switch (Phase B).
2026-07-08 18:45:57 +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
Paul Rastoin 163c96c2e5 Validate range version app dev sync (#22625)
# Introduction
Also now validating the workspace version when running a sync manifest

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22625?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 16:37:42 +00:00
neo773 674de0056b feat(messaging): message campaign delivery stats + views (#22661)
Re-land of #22452 (reverted in #22627). Rebuilt on fresh main with
upgrade commands isolated to 2-20 only; no other version's commands
touched.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22661?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 15:55:20 +00:00
martmull 9423af7f67 feat(server): add public marketplace resolver for vetted app catalog (#22647)
## What

Adds a public GraphQL resolver so unauthenticated clients (the public
website) can read the listed/vetted marketplace catalog without a
workspace token.

- `MarketplacePublicResolver` (metadata schema) exposes two public
queries guarded by `PublicEndpointGuard` + `NoPermissionGuard`:
  - `publicMarketplaceApps`
  - `publicMarketplaceAppDetail(universalIdentifier)`
  
Both delegate to the existing `MarketplaceQueryService` (no new logic,
no new REST routing). The existing workspace-guarded
`findManyMarketplaceApps` / `findMarketplaceAppDetail` queries are
untouched.
- Adds a shared `ApplicationCategory` type in `twenty-shared` (known
values plus `string` for backward compatibility) used to type
`ApplicationManifest.category`. A warning is logged server-side when an
app declares a category outside the known set.

## Why

This is the backend half of the public apps marketplace on the website.
Splitting it out so the server-side catalog exposure can be reviewed
independently from the website UI.

## Follow-up

The website PR (the `/apps` marketplace UI) consumes
`publicMarketplaceApps` and should merge after this one.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22647?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: martmull <martin@twenty.com>
2026-07-08 15:43:17 +00:00
Pratik Mahajan 72d728ea8e fix(messaging): add sender name to IMAP/SMTP From headers (#22603)
Manual IMAP/SMTP outbound emails were being composed with a
bare email address in the From header, so recipients did not
see the sender's display name.
Gmail already built a proper sender header from Google profile
data, but manually configured IMAP/SMTP accounts had no
equivalent path and fell back to the raw address only.

Fix this by storing the optional sender display name in the
IMAP/SMTP/CALDAV connection parameters, exposing it through
the settings flow and metadata API, and reusing a shared
From-header formatter when composing outbound messages.
The formatter now builds a properly encoded sender header when
a name is available and falls back to the bare email address
when it is not, keeping the behavior safe for blank or missing names.
Gmail keeps using its existing Google-derived display name source;
this change only brings manual accounts up to the same header
formatting standard and removes duplicated formatting logic between
outbound drivers.

After this change, manual SMTP sends and IMAP draft creation include
the configured sender name in the From header, while blank names are
normalized away instead of producing malformed headers.
Existing manual account names are preserved when updates omit the
field, and edited accounts can still explicitly clear it through the
settings flow.

Add focused utility coverage for the shared From-header formatter.

Fixes: #22608

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22603?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: neo773 <neo773@protonmail.com>
2026-07-08 20:29:25 +05:30
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
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
twenty-pr[bot] 3b1a0ef3e6 chore: bump version to 2.20.0 (#22639)
## Summary

- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version
- Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same
version

## Checklist

- [ ] Verify version constants are correct
- [ ] Verify npm package versions match

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

Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
2026-07-07 17:40:56 +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
Raphaël Bosi 2c0e0b2eac Create billing customer at signup so onboarding rewards are credited (#22633)
## Problem

Onboarding credit rewards (install apps, import contacts, invite team)
were silently dropped. They credit the workspace balance via
`billingCustomer.increment(...)`, but no `billingCustomer` row exists
until the plan step (it's created lazily when the first subscription is
set up, which is after those steps). So the increment affected 0 rows
and the credit was lost. A user installing 3 apps saw only the trial
grant, not the expected +1.5 credits.

## Fix

Create the Stripe customer + `billingCustomer` row eagerly at signup via
a new `BillingCreditService.ensureBillingCustomer`, called from
`signUpOnNewWorkspace` after the workspace transaction commits. It is
idempotent, guarded by `IS_BILLING_ENABLED`, and non-blocking (failures
are logged, not thrown). The later subscription flow reuses this
customer (no duplicate Stripe customer), and trial eligibility is
unchanged since the customer has no subscriptions yet.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22633?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:51:53 +00: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
Etienne 54aa52d11c feat(index): support composite unique indexes in create-many upsert conflict resolution (#22604)
## Context

The `createMany` upsert path resolved conflicts by scanning individual
field
metadata and only treating a field as a conflict target when it was
flagged
`isUnique` (plus the primary `id`). This ignored **composite unique
indexes**
(multi-column unique constraints), so upserting against a multi-field
unique
key never matched an existing row and could either insert a duplicate or
fail.

## What changed

- **Conflict groups are now derived from unique indexes**, not from
per-field
`isUnique` flags. `getConflictingFields` reads the object's index
metadata
(`flatIndexMaps`) and builds one `ConflictingFieldGroup` per unique
index
  (the primary `id` remains its own group).
- Each index group correctly expands its fields into DB columns,
handling:
- **Composite field types** — expands to the sub-columns included in the
unique constraint (or a specific sub-field when the index targets one).
- **`MANY_TO_ONE` relation fields** — resolves to the join column name.
  - **Scalar fields** — used directly.
- `ConflictingFieldGroup.baseField: string` → **`baseFields:
string[]`**, since
  a composite index spans multiple fields.
- **Clearer multi-match error message**: conflicting values are now
grouped per
index (`baseFields (fullPath: value, ...)`, groups joined by `;`) so
it's
  obvious which unique key caused the ambiguity when a payload matches
  different rows across different indexes.
- `CommonCreateManyQueryRunnerService` now fetches `flatIndexMaps` via
  `WorkspaceManyOrAllFlatEntityMapsCacheService` and passes them into
`getConflictingFields`; the cache module is wired into
`CoreCommonApiModule`.

## Tests

- New integration suite
`composite-unique-index-upsert.integration-spec.ts`:
- single composite unique index — insert, update-on-match, and
insert-when-key-differs
- **two independent composite unique indexes** — happy path (single row
matches
both) and failure path (payload matches different rows across the two
indexes → `Multiple records found with the same unique field values` /
    `BAD_USER_INPUT`).
- Updated unit specs for `get-conflicting-fields`,
`get-matching-record-id`,
  `build-where-conditions`, and `categorize-records` to reflect the
  index-driven grouping and the `baseFields[]` shape.

## Test plan

- [ ] `npx nx run twenty-server:test:integration:with-db-reset --
composite-unique-index-upsert`
- [ ] `npx nx test twenty-server -- get-conflicting-fields
get-matching-record-id build-where-conditions categorize-records`
- [ ] Manual: upsert against a composite unique index updates the
matching row instead of inserting a duplicate.

fixes
https://github.com/twentyhq/twenty/issues/22580#issuecomment-4894266699

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22604?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 13:54:33 +02:00
Abdul Rahman 81cfcecdc7 chore(server): migrate 5 modules off NestjsQueryTypeOrmModule wiring (#22595)
## Summary
Continues the incremental removal of `@ptc-org/nestjs-query`. Migrates
five core modules from `NestjsQueryTypeOrmModule.forFeature` to the
standard `TypeOrmModule.forFeature`. These modules only used
`nestjs-query` for repository registration — their resolvers are
hand-written and registered as normal providers — so this is a pure
module-wiring swap with no behavior or schema change.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22595?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 12:39:59 +02:00
Abdul Rahman f90137b536 chore(server): remove nestjs-query from user-workspace module (#22591)
## Summary
Continues the incremental removal of `@ptc-org/nestjs-query`. Migrates
the `user-workspace` module to plain NestJS/TypeORM. This module
registered no resolvers, so `nestjs-query` was only acting as module
wiring and providing the service's inherited query methods — no public
API behavior depended on it.

## Changes
- `user-workspace.module.ts`: replaced
`NestjsQueryGraphQLModule.forFeature` with plain
`TypeOrmModule.forFeature`; kept all module imports and the service
provider unchanged.
- `user-workspace.service.ts`: dropped `extends TypeOrmQueryService` and
the `super()` call; added an explicit `findById` (the only inherited
method used externally, by `agent-actor-context.service.ts`).
- `user-workspace.entity.ts`: swapped `@IDField` for the standard
`@Field` on `id` (renders identically as `UUID!`).


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22591?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 12:37:31 +02:00
Paul Rastoin 628ab153a8 App installation workspace version check engines constraint (#22613)
## What

Makes the app-installation version gate **workspace-scoped**. App
installation now validates a manifest's `engines.twenty` requirement
against the version the **target workspace has actually finished
upgrading to**, instead of the instance/server's inferred version.

## Why

The server binary and a given workspace's migration state can diverge.
In a multi-workspace deployment the instance can already report version
`X` while an individual workspace still hasn't completed its
workspace-scoped upgrade commands for `X` (it's mid-upgrade or a
migration failed). Gating on the instance version let an app that
requires `X` install into a workspace whose schema/metadata is
effectively still at `X-1`, which can break the app. The requirement
should be checked against what the *workspace* has completed, not what
the server reports.

## How

- **`UpgradeStatusService.getWorkspaceCompletedVersion(workspaceId)`**
(new): resolves the last fully-completed upgrade version for a workspace
by reading its upgrade cursor and walking the upgrade sequence:
- Returns the cursor's version when the cursor sits on the **last step
of its version segment** and its status is `completed`.
- Otherwise walks backwards to the previous fully-completed version
segment.
- Returns `null` when the cursor is missing, not found in the sequence,
or otherwise uninterpretable.
- **`ApplicationVersionValidationService`**:
- Adds `validateWorkspaceCompatibility({ requiredServerVersion,
workspaceId })`.
- Extracts the shared semver logic into a private
`validateVersionAgainstRange({ version, requiredVersionRange, scope })`
and makes error messages scope-aware (workspace vs. instance).
`validateServerCompatibility` is preserved and now delegates to it.
  - New failure reason `INVALID_WORKSPACE_VERSION`.
- **`ApplicationInstallService`** now calls
`validateWorkspaceCompatibility` with the `workspaceId` instead of
`validateServerCompatibility`.
- **Exception plumbing**: new
`ApplicationExceptionCode.INVALID_WORKSPACE_VERSION`, surfaced as a
`UserInputError` (`BAD_USER_INPUT`) with a user-friendly message ("This
workspace's upgrade state could not be determined…"). The
tarball/registration path maps it onto the existing
`INVALID_SERVER_VERSION` registration code.

## Notes

- **Publishing (app registration) is intentionally not
workspace-gated.** The tarball/registration path
(`ApplicationTarballService`) still uses the instance-level
`validateServerCompatibility` check, not the new workspace-scoped one.
Publishing an app is not tied to any particular workspace's upgrade
state, so there is no workspace version to check at that point — the
workspace-completed-version gate only applies when installing an app
into a specific workspace.

## Testing

- Unit tests for `ApplicationVersionValidationService`
(`validateServerCompatibility` + new `validateWorkspaceCompatibility`)
covering: no requirement, invalid semver range, satisfied/unsatisfied
ranges, and the uninterpretable-cursor case.
- Unit tests for `UpgradeStatusService.getWorkspaceCompletedVersion`
against a three-segment mock upgrade sequence (multi-command version,
instance-only version, workspace-terminated version).
- New integration suite
`failing-app-installation-workspace-version.integration-spec.ts` (+
snapshots) exercising the real install flow: rejects installation when
the workspace hasn't completed the required version, and when the
workspace's upgrade cursor can't be interpreted. Adds a
`create-app-tarball.util.ts` test helper.
2026-07-07 10:05:06 +00:00
martmull 2a495c3477 feat(app): allow claiming ownership of unclaimed app registrations (#22609)
## After

<img width="653" height="703" alt="image"
src="https://github.com/user-attachments/assets/ebe800da-b00b-4239-99a9-e157f7bfd7a0"
/>
<img width="634" height="711" alt="image"
src="https://github.com/user-attachments/assets/00a048bf-36b1-489f-a080-1ed2d069e625"
/>


## Context

App registrations track their owner via `ownerWorkspaceId`. Curated /
catalog / CLI apps are seeded **unclaimed** (`ownerWorkspaceId: null`).
Until now there was no way to take ownership of an unclaimed app from
the UI — the only ownership action was **Transfer ownership**, which
requires the caller to already be the owner, so it can't act on a
null-owner app.

This PR adds a way to **claim** an unclaimed app registration, and makes
the owner always visible on the detail page.

## Behaviour

Admin panel → app registration detail → General tab:

- The **Owner** row is now always shown — an **Unclaimed** tag when
there's no owner workspace (previously the row was hidden).
- Danger zone buttons are ownership-aware:
- **Unclaimed** app → **Delete app** + **Claim ownership** (claims it
for the current workspace).
  - **Owned** app → **Delete app** + **Transfer ownership** (unchanged).

Transfer is hidden for unclaimed apps because transferring requires the
caller to already own the registration.

## Changes

**Backend**
- New `claimOwnership` service method: looks the registration up
globally, rejects it if it already has an owner, otherwise assigns
`ownerWorkspaceId` to the caller's workspace.
- New `claimApplicationRegistrationOwnership` mutation, guarded by
`WorkspaceAuthGuard` + `SettingsPermissionGuard(APPLICATIONS)` (same
guards as transfer).
- New `ClaimApplicationRegistrationOwnershipInput` DTO
(`applicationRegistrationId`).

**Frontend**
- **Claim ownership** button (shown only when the registration has no
owner workspace); opens a confirmation modal and calls the new mutation.
- **Transfer ownership** button now renders only for owned
registrations.
- The **Owner** row in the general info card is always displayed, with
an `Unclaimed` tag when there is no owner.

**Generated**
- Regenerated the checked-in GraphQL artifacts (`twenty-front` metadata,
`twenty-client-sdk` schema/types) against the live server so codegen
output matches.

## Verification
- `nx typecheck twenty-front` and `nx typecheck twenty-server` pass.
- `oxlint` + `oxfmt` pass on all changed source files.
- Codegen is idempotent — re-running the three `graphql:generate`
configs + `generate-metadata-client` produces no diff.
- Verified end-to-end on the running app against the seeded unclaimed
`Twenty CLI` registration (Owner shows `Unclaimed`; Danger zone shows
Delete + Claim ownership).

https://claude.ai/code/session_01U7rbxhBSUQRWBbdP5TmAgZ
2026-07-07 11:07:21 +02:00
Raphaël Bosi d3b79320b1 Remove book a call step from onboarding (#22597)
The book a call screen was shown as a dedicated onboarding step after
sending team invites. It is no longer part of the flow: the
`BOOK_ONBOARDING` status, its pending user var, the
`skipBookOnboardingStep` mutation and the `BookCallDecision` screen are
removed, and onboarding completes right after the plan step.

The `/book-call` Cal.com page remains, reachable only from the "Book a
Call" link on the upgrade screen, with a back link to `/plan-required`.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22597?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 10:34:20 +02:00
martmull ee28ae363f feat(files): use direct-to-storage upload for email and AI-chat attachments (#22610)
## Context

Follow-up to the direct-to-storage upload work (#22449 / #22531 / #22533
/ #22576). That migrated files-field, attachments and workflow uploads
off the buffered path. This PR does the same for the **last two
user-facing upload surfaces**: email attachments and AI-chat files.

## What this does

- Adds `EmailAttachment` and `AgentChat` to the server's
`DIRECT_UPLOAD_FILE_FOLDERS` allowlist. Both folders already resolve
through the workspace-custom-application path in
`resolveUploadLocation`, so no other server change is needed.
- Routes the two frontend hooks through the existing
`useDirectFileUpload` handshake (`createFileUpload` → `PUT` →
`completeFileUpload`):
- `useUploadEmailAttachment` → `FileFolder.EmailAttachment` (keeps its
existing `MAX_ATTACHMENT_SIZE` client check — email has a real send-size
limit).
  - `useAiChatFileUpload` → `FileFolder.AgentChat`.

Each hook keeps its public signature and return shape, so call sites are
unchanged. No schema change and no codegen needed — the
`CreateFileUpload`/`CompleteFileUpload` documents and the `FileFolder`
enum values already exist in `generated-metadata` from #22576.

## Why these are safe to migrate

Both server services (`file-ai-chat`, `file-email-attachment`) just
`writeFile` (store) and return a signed URL — no synchronous processing
of the bytes at upload time — so the store-and-reference direct-upload
flow fits exactly, same as files-field/workflow.

## Out of scope

`CorePicture` (avatars, member/workspace pictures, logos) stays on the
buffered path on purpose: small images that go through server-side image
handling and are served inline, where the 10 MB body limit is already
appropriate.

## Tests

Extends the `FileUploadService` unit spec with an `it.each` asserting
`createFileUpload` supports the `EmailAttachment` and `AgentChat`
folders.

## Verification

`typecheck` and `lint:diff-with-main` green on both `twenty-front` and
`twenty-server`. (The server jest suite couldn't run in my local sandbox
due to an unrelated config-import quirk present on a clean `main`
checkout too — CI runs it normally.)

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/22610?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 10:26:04 +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
Marie 8a4bcd1445 (Billing for self hosts) Tie enterprise key to server (#22464)
# Enterprise key: bind to a server, free dev instances, self-serve
transfer, shorter license

## Summary

Enterprise keys were being reused across multiple instances (e.g. one
prod + one dev, or several environments), which broke seat accounting
and made licensing ambiguous. This PR ties each enterprise key to a
**single server**, while giving customers a legitimate, self-serve way
to run a **free development instance** and to **move their key** when
they replace a server.

## Product behavior

### 1. Enterprise key is bound to one server
- The first server to validate an enterprise key **claims** it
(claim-on-first-use). From then on, that key is bound to that one server
(until unbound - see 3.).
- Any other instance that presents the **same key from a different
server is hard-rejected**: it does not receive a license, so enterprise
features stay off there.
- Each instance has a stable server identifier. If one isn't set, the
instance generates and persists one automatically on first validation
(in keyValuePair table), so existing customers generally don't need to
do anything (unless they have disabled config variables in db then they
should add it to .env).

### 2. Free development instance
- Every enterprise subscription gets **one free, non-billable
development instance** in addition to its production instance.
- An instance registers as development by declaring its instance type as
`development` (done by default when validating the enterprise key, then
can be toggled from UI or by updating value in keyValuePair table).
- The free dev slot is only granted while there is an **active
production instance** on the same subscription (so it's a perk for
paying customers, not a way to run for free).
- Only **one** dev instance can be active at a time per subscription,
and it is **not counted as a billable seat**.

### 3. Self-serve unbind / rebind (transfer)
- Admins can **release** the binding from the enterprise settings, which
frees the key so it can be **claimed by a new server**.
- This is the intended path when **sunsetting an instance and standing
up a new one** (migration, re-hosting, disaster recovery): release on
the old/dead box, then the new box claims it on its next validation.
- To prevent abuse, releases are **rate-limited (10 per rolling 30
days)**; hitting the limit shows a clear message.

### 4. Automatic release of dead servers
- If a bound server stops checking in for **14 days**, its binding is
considered stale and is **auto-released**, so a replacement can claim
the key without any manual step. This covers the case where the old
server is already gone and can't release itself.

### 5. Shorter license validity (30 → 7 days)
- The license (validity token) now expires after **7 days** instead of
30. The daily background refresh keeps healthy instances licensed
transparently.
- This limits the value of copying a license from one instance to
another, since a copied license now stops working within a week.

### 6. License issuance is rate-limited
- Issuing a new license is capped at **twice per 24h, independently for
production and for development**. This tolerates the normal daily
refresh (including small drift between runs) while blocking bursts of
license minting for cloned instances.
- Hitting this limit never revokes an existing, still-valid license —
the current one keeps working until it expires; the manual "refresh"
button just reports that the daily limit was reached.

## What changes for existing self-hosted customers

**If you run a single production instance with one enterprise key:**
nothing to do. On the next validation your instance reports its server
identifier, claims the binding, and keeps working.

**If you reuse one key across several instances (e.g. prod + dev, or
multiple environments):** only the **first** instance to validate keeps
its license. The others will **lose enterprise features**. To migrate:
- Keep your production instance as-is (it claims the binding).
- For a secondary/testing box, mark it as a **development instance**
(set the instance type to `development`) to use the free dev slot — no
extra cost.
- If you genuinely need multiple production instances, you'll need
**separate subscriptions/keys** for each.

**If you're replacing a server (decommissioning + rebuilding):**
- **Release** the binding from enterprise settings on the old instance,
then start the new one — it will claim the key automatically.
- If the old server is already gone, just wait for the **14-day
auto-release**, or contact support.

**Legacy instances that can't persist a server identifier
automatically:** set the server identifier explicitly in your
environment configuration (the instance logs a message telling you to do
so).

**Offline instances:** because licenses now last 7 days, an instance
that can't reach our licensing endpoint for more than a week will lose
enterprise features until it can check in again.

> A migration email will be sent to affected customers separately.

## Technical implementation (brief)

- Binding state lives in the **subscription's billing metadata** (bound
server id + last-seen timestamps for prod and dev, release timestamps,
and license-issuance timestamps). No new database is introduced on the
licensing side; the billing provider's subscription metadata is the
source of truth.
<img width="976" height="413" alt="metadata_3"
src="https://github.com/user-attachments/assets/ccc64822-e177-4223-a65a-4a4602aedf0e"
/>

- On each validation, a pure **binding resolver** takes the reported
server id + instance type + current metadata and returns `allowed` (with
the metadata to persist and whether the seat is billable) or `rejected`.
It handles claim-on-first-use, staleness/auto-release, the
dev-requires-active-prod rule, and the single-dev-slot rule.
- **Rate limits** (release + license issuance) use a shared
sliding-window helper stored as pruned timestamp lists in the same
metadata, so the metadata self-cleans and never grows unbounded. License
issuance uses **separate windows per instance type**.
- The self-hosted instance **generates and persists a server
identifier** if none is configured, and sends it (plus instance type) as
instance metadata on validation.
- A rejected binding returns a specific error code; the instance
**revokes its stored license** on that code. A license-issuance
rate-limit instead **throws a typed exception that surfaces to the
manual refresh** while leaving the existing license untouched; the daily
refresh job swallows it.
- License lifetime is a configurable duration (defaulted from 30 to **7
days**), clamped to the subscription's cancellation date when sooner.
2026-07-06 18:07:03 +02:00