Commit Graph

738 Commits

Author SHA1 Message Date
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
martmull 0706c7c1bc feat(front): upload files directly to storage for files-field, attachments and workflow (#22576)
## Context

Final step of the direct-to-storage upload work (follows #22449
endpoints, #22531 reaper, #22533 content-verify). The server can now
hand the client an upload URL so bytes go straight to storage instead of
being buffered through the Node process (the original OOM problem). This
PR switches the frontend to that flow for the three in-scope surfaces.

## What this does

Adds **`useDirectFileUpload`** — the shared hook that runs the
handshake:

1. `createFileUpload({ filename, size, fileFolder, fieldMetadataId? })`
→ `{ fileId, uploadUrl, contentType, expiresAt }`
2. `PUT` the raw file to `uploadUrl` with `Content-Type: contentType`
3. `completeFileUpload({ fileId })` → `FileWithSignedUrl` (`{ id, path,
size, createdAt, url }`)

Routes the three existing upload hooks through it, **keeping each hook's
public signature and return shape unchanged** so no call sites change:

| Hook | Folder |
|---|---|
| `useUploadFilesFieldFile` (FILES fields) | `FilesField` |
| `useUploadAttachmentFile` (attachments — the Attachment object's
`file` FILES field) | `FilesField` |
| `useUploadWorkflowFile` (workflow send-email attachments) | `Workflow`
|

Adds the `CreateFileUpload` / `CompleteFileUpload` gql documents and
regenerates `generated-metadata` types (+19 lines, scoped to the two new
operations).

## Out of scope

- AI-chat (`AgentChat`) and email-attachment (`EmailAttachment`) uploads
keep the legacy buffered mutations — those folders aren't in the
server's direct-upload allowlist (`[FilesField, Workflow]`).
- Workflow serverless-function code is saved via metadata mutations, not
the file path.

## Notes

- The legacy `uploadFilesFieldFile` / `uploadWorkflowFile` mutations
still exist server-side and remain used by the out-of-scope surfaces, so
this is non-breaking.
- Local storage routes the `PUT` to the token-authenticated streaming
endpoint (`SERVER_URL/file-upload/:id?token=…`); S3 uses a presigned
`PUT`. CORS is already enabled globally on the server and the token
rides in the query string (no cookies), so the browser upload works
cross-origin.

## Verification

`typecheck` and `lint:diff-with-main` green on `twenty-front`; codegen
ran against a live metadata schema so the generated file matches the
drift check. No existing tests/stories cover these hooks.

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/22576?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 15:28:57 +02:00
martmull 4e43a0fb4e refactor(server): regroup application resolvers by resource and unify install permission flag (#22532)
Part of the app settings architecture cleanup
(twentyhq/core-team-issues#2456) — implements the API-surface regroup
Charles asked for in #20825 ("in application resolvers we have
uninstall, upgrade, findMany, etc. and for some reason install is part
of the marketplace, and they are not protected by same guards").

## Changes

**Resolver regroup by resource** (GraphQL operation names and signatures
unchanged):
- `installApplication` + `installMarketplaceApp` (deprecation preserved)
move from the marketplace resolver into
`application-install.resolver.ts`, next to
`findManyApplications`/`findOneApplication`/`uninstallApplication`.
- `uninstallApplication` moves from the manifest resolver into
`application-install.resolver.ts`.
- `runWorkspaceMigration` is deleted outright (unused — no consumer
anywhere in the repo, front/SDK/e2e/docs); the now-empty manifest
resolver is deleted. Its `AllMetadataName` GraphQL enum registration
moves to `collection-hash.dto.ts` (its remaining consumer).
- `generateApplicationToken` moves from the development resolver into
`application-oauth.resolver.ts` next to `renewApplicationToken`, keeping
its effective guards (`WorkspaceAuthGuard` +
`SettingsPermissionGuard(APPLICATIONS)`) and its token-bucket throttle
verbatim.
- `upgradeApplication` stays in the upgrade resolver (moving it into the
install resolver would create a module cycle — the upgrade module
imports the install module).
- Marketplace resolver now only holds catalog concerns:
`findManyMarketplaceApps`, `findMarketplaceAppDetail`,
`syncMarketplaceCatalog`.

**Permission unification** (the only behavior change):
`installApplication`, `installMarketplaceApp` and `upgradeApplication`
move from `MARKETPLACE_APPS` to `APPLICATIONS`, matching uninstall and
the find queries. Front-end install/upgrade button gating updated
accordingly (`SettingsApplicationDetails` /
`SettingsAvailableApplicationDetails`).

**Module wiring**: `MarketplaceModule` no longer imports
`ApplicationInstallModule` (only the moved resolver needed it);
`ApplicationInstallModule` now imports `MarketplaceModule` — no cycle.
Exception filters follow the moved operations
(`ApplicationRegistrationExceptionFilter` on the install resolver;
`ApplicationExceptionFilter` on the oauth resolver, which also fixes
`renewApplicationToken`'s previously unmapped FORBIDDEN).

**Codegen**: `twenty-client-sdk` metadata client regenerated for the new
schema ordering (pure reordering — no field changes); all front
`graphql:generate` configurations produced zero diffs.

## Explicitly kept (per review discussion)

`installMarketplaceApp` (deprecated) and `generateApplicationToken` are
kept for SDK back-compat despite having no current consumers.
`runWorkspaceMigration` was also consumer-less but, unlike those two,
had no back-compat rationale (not a deprecated alias, not a token
primitive), so it is removed rather than relocated.

## Deferred follow-ups (guard inconsistencies found in the audit,
intentionally NOT changed here)

- `findApplicationRegistrationByUniversalIdentifier` uses
`NoPermissionGuard` and returns the full registration entity, bypassing
the `API_KEYS_AND_WEBHOOKS` gate that `findOneApplicationRegistration`
enforces on the same data (SDK CLI `ensure-app-registration` depends on
it today).
- `upgradeApplication` alone requires `UserAuthGuard` — an API key can
install but not upgrade.
- `uploadAppTarball` (`MARKETPLACE_APPS`) and
`transferApplicationRegistrationOwnership` (`APPLICATIONS`) are
flag-inconsistent with the rest of registration CRUD
(`API_KEYS_AND_WEBHOOKS`).
- `syncMarketplaceCatalog` triggers an instance-wide job but is gated
only by a per-workspace settings flag.

## Verification

- `npx nx typecheck twenty-server` / `twenty-front` ✓;
`lint:diff-with-main` clean for both
- `npx jest "application"` in twenty-server: 30 suites / 154 tests
passed
- Server boots with the new module graph (DI verified at runtime);
codegen run against the live server
- Repo-wide grep: no remaining imports of the deleted manifest resolver

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22532?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>

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-07-05 00:24:12 +02:00
martmull ea851f7d9c feat: expose marketplace app detail fields explicitly and deprecate manifest blob (#22526)
Part of the application settings architecture work:
https://github.com/twentyhq/core-team-issues/issues/2456 — follow-up to
#22513.

`MarketplaceAppDetail` returned the entire `manifest` jsonb (100KB+)
over GraphQL and the front dug display fields and roles out of it. This
PR:

- Adds explicit fields to `MarketplaceAppDetail`: `description, author,
category, logo, websiteUrl, aboutDescription, termsUrl, emailSupport,
issueReportUrl, screenshots, defaultRoleUniversalIdentifier`, sourced
from the registration columns introduced in #22513, and `roles:
[MarketplaceAppRole!]` (full permission shape — the permissions tab and
install modal render object/field permissions), sourced from the
manifest at detail time.
- Marks the `manifest` field `@deprecated` (kept functional — removal
would be a breaking change).
- Front: the shared `marketplaceAppDetailFragment` no longer selects
`manifest`; display and role reads are flattened across
`SettingsAvailableApplicationDetails`, `SettingsApplicationDetails`, and
the share-link buttons. The three consumers that genuinely need deep
manifest structure (content-tab counts/`manifestContent`, permissions
objects, `useApplicationManifest` page-layout/view reads) use a scoped
`FindMarketplaceAppManifest` query until the manifest demotion PR
removes that need.
- Codegen regenerated where the documents live: front metadata config +
twenty-client-sdk metadata client (data/admin configs verified
untouched).

Verified: server+front typecheck, lint (0 warnings), server marketplace
suite 10/10, front marketplace/applications suites 41/41, live schema
introspection confirms the new fields and the deprecation.

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22526?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-04 23:26:53 +02:00
martmull 1a85b88d38 feat(files): direct-to-storage upload endpoints with pending file lifecycle (#22449)
<img width="1484" height="404" alt="image"
src="https://github.com/user-attachments/assets/b2d363bf-d9e1-49fb-9811-8cc98041aa79"
/>


## Context

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

## What it does

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

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

**Pending lifecycle safety:**

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

**Driver support (both drivers):**

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

## Tests

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

## Notes for reviewers

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

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22449?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 15:11:53 +02:00
Félix Malfait 8b191d6fcc chore(server): remove the five dead FileFolder values and their legacy serving pipeline (#22516)
Follow-up cleanup after #22510: shrink `FileFolder` and
`fileFolderConfigs` to only folders that actually exist, so per-folder
policy entries are real decisions.

## What

**Remove the five dead enum values** — `ProfilePicture`,
`WorkspaceLogo`, `Attachment`, `PersonPicture`, `File`. They were
already marked replaced/removed in the enum, have no production write
path, and `FileByIdGuard`'s `SUPPORTED_FILE_FOLDERS` allowlist already
rejects them at the serving endpoint.

**Delete the legacy path-based serving pipeline that existed only for
them** — verified wired to no route:
- `FilePathGuard` — registered as a provider in `FileModule` but applied
to no controller
- `extractFileInfoFromRequest` (parsed the old
`/files/profile-picture/original/TOKEN/file.jpg` format) — only consumer
was `FilePathGuard`
- `checkFileFolder` — only consumer was `extractFileInfoFromRequest`
- `settings.storage.imageCropSizes` — keyed exclusively by the three
dead picture folders, zero consumers
- the crop-size helpers in `utils/image.ts` (`getCropSize`,
`ShortCropSize`, `CropSize`) — zero consumers outside the file;
`getImageBufferFromUrl` is kept
- `AllowedFolders` type — last consumer was `checkFileFolder`

**Test fixtures** referencing dead folders were moved to living ones;
the specs of deleted utils are deleted with them.

**Generated files** (`twenty-front/src/generated-metadata/graphql.ts`,
`twenty-client-sdk` schema) hand-updated to match the shrunk GraphQL
enum.

## Legacy data safety

Workspaces may still hold `File` rows whose `path` starts with a dead
prefix (e.g. `attachment/…`). These stay inert, exactly as today:

- Serving: `FileByIdGuard` rejects non-supported folders before any
config lookup, and file lookups filter by `path LIKE
'<current-folder>/%'`, so dead-prefix rows are unreachable.
- Every consumer that feeds stored paths into
`removeFileFolderFromFileEntityPath` (which throws on unknown prefixes)
is upstream-guarded by a current-folder filter or allowlist — audited
all seven call sites.
- Stored legacy member `avatarUrl` strings are parsed with
`extractFileIdFromUrl(url, FileFolder.CorePicture)` and already fall
back to `''` for old formats; unchanged.

## GraphQL note

`FileFolder` is exposed as a GraphQL enum (input of the dev-only
`uploadApplicationFile` mutation, which only accepts application-code
folders). Clients sending a removed value were already rejected at the
resolver allowlist; they now fail GraphQL enum validation instead. No
supported client sends them — the frontend only uses `CorePicture`.

Net: **+10 / −301** across 17 files.

https://claude.ai/code/session_01AKwhTxYFDhWhCZ4b7sf35W

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22516?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 13:13:14 +02:00
martmull af1b89c788 feat(server): import application logo into file storage at install (#22437)
Installed apps stored the logo as the manifest's relative path but never
imported the file, so the public-assets URL 404'd and logos went missing
in the UI for npm/tarball sources. Import the logo (best-effort — a
declared but unshipped logo is skipped, not fatal) and record it as a
first-class logoFileId on the application so it can be served reliably.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22437?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 11:47:58 +02:00
martmull 25fe66565c feat(applications): add type and options to application variables (#22157)
## Before
<img width="1452" height="709" alt="image"
src="https://github.com/user-attachments/assets/cd384ffa-cbe6-49d5-a807-ca8d580f55a9"
/>

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

## After

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

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


## Summary

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

## Changes

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

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

## Notes

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

https://claude.ai/code/session_013Z7UB35V2mvUozh55QHG23

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22157?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 10:52:22 +02:00
neo773 6f64be5751 Gate and meter email group: enterprise license (self-host) + credits (cloud) (#22390)
Email group (marketing email) was gated only by the
`IS_EMAIL_GROUP_ENABLED` feature flag with no server-side enforcement.
This adds real gating, split by deployment:

- **Self-hosted** (`IS_BILLING_ENABLED=false`): requires a valid
Enterprise plan.
- **Cloud** (billing enabled): metered by credits, mirroring the
existing AI credit system. Priced on AWS SES cost ($0.10/1,000 outbound)
× 3 margin = $0.30/1,000 (300 micro-credits/email). Pre-flight blocks
sends when out of credits; each email is charged after SES accepts it,
in the async send job — matching how AWS bills us (no refund on bounce).

Enforcement is applied at every email group resolver, and denials
surface as proper client errors through a dedicated GraphQL exception
filter.
2026-07-02 15:14:49 +00:00
Félix Malfait a28887bba6 feat(server): workspace opt-out of root-domain directory listing (#22423)
## What

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

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

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

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

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

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

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

## Changes

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

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

## Verification

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

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-07-02 16:55:40 +02:00
martmull 4f44131901 perf(front): query only rendered fields in application settings pages (#22454)
Part of the application settings architecture work:
https://github.com/twentyhq/core-team-issues/issues/2456

Application settings pages pulled far more data than they render:

- **`FindOneApplicationByUniversalIdentifier`** fetched the full
`ApplicationFields` fragment (all nested agents, objects,
logicFunctions, frontComponents, commandMenuItems) just so
`SettingsAvailableApplicationDetails` could check whether an app is
installed. Slimmed to `id, universalIdentifier, name, version` (its only
caller; every field usage audited).
- **`FindManyApplicationRegistrations`** (developer tab list) fetched
the 17-field registration fragment including `isConfigured`, which
triggers a per-row DataLoader resolve. The list renders only `id, name,
universalIdentifier, sourceType` — new lean
`ApplicationRegistrationListItem` fragment. The detail page and admin
list, which actually render `isConfigured`, keep the full fragment.
- **`SidePanelEditOwnerSection`** pulled the full `FindOneApplication`
payload to render a name — new `FindOneApplicationName` (`id, name`)
query.

Regenerated `generated-metadata/graphql.ts` (document-level changes
only, zero schema drift; data/admin outputs byte-identical).
Deliberately untouched: the installed-app detail page query (renders its
nested collections across tabs), the sub-detail pages that share its
cache entry, and the marketplace manifest usage (needs backend fields —
later PR).

Verified: typecheck, oxlint/oxfmt on touched files, jest (applications
19/19, navigation-menu-item 86/86).

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

https://claude.ai/code/session_011sST4rPLU1Koi2oVGi84ei

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22454?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 16:52:28 +02:00
Félix Malfait 4aaf171d63 feat(ai): add ask_questions interactive clarifying-question tool (#22346)
## What & why

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

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

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

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

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

Instead:

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

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

### Meets the two requirements

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

### Harness-only by construction

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

## UX

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

## Changes

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

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

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

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

## Migration

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

## Tests

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

## Verification status (please read)

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

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

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

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

https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22346?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 15:32:18 +02:00
Félix Malfait d709467902 feat(ai): surface AI chat stream failures through one typed error channel (#22434)
## Context

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

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

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

## Change

Collapse to a single typed error contract:

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

## Notes

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

## Test plan

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


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22434?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 14:50:57 +02:00
Félix Malfait 5a4ebca226 refactor(server): unify the two metadata override mechanisms into one (#22417)
## Unify the two metadata override mechanisms into one

Twenty had **two** override mechanisms:

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

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

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

### GraphQL breaking change (accepted)

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

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

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

### Rolling-deploy safety

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

### `isActive` guard

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

### Verification (local)

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

---------

Co-authored-by: prastoin <paul@twenty.com>
2026-07-02 12:01:15 +02:00
Abdul Rahman 3bbc08d41f refactor(schema): reorganize IndexField and related types (#22439)
## Summary

Querying `indexMetadatas { indexFieldMetadatas { ... } }` on the
`/metadata` GraphQL endpoint fails with a 500:

> Nest could not find IndexFieldMetadataDTOAuthorizer element (this
provider does not
> exist in the current context)

The `@CursorConnection('indexFieldMetadatas', ...)` decorator on
`IndexMetadataDTO` makes nestjs-query auto-generate a relation resolver
that injects an authorizer for `IndexFieldMetadataDTO`. That authorizer
is never provided, because the DTO was never registered as a resolver in
`IndexMetadataModule` — so the field has been broken since it was
introduced in #7162.

Since the working, DataLoader-backed `indexFieldMetadataList` field
already exposes the same data (and is what the frontend uses), this PR
removes the dead connection instead of wiring up the authorizer.

## Changes

- Remove `@CursorConnection('indexFieldMetadatas', ...)` from
`IndexMetadataDTO`
- Regenerate frontend metadata GraphQL types
(`twenty-front/src/generated-metadata`)
- Regenerate client SDK metadata schema/types
(`twenty-client-sdk/src/metadata/generated`)

## Notes

- Not a breaking change in practice: the removed field always threw, so
no consumer can have been relying on it. Callers now get a standard
GraphQL validation error suggesting `indexFieldMetadataList` instead of
an internal server error.
- Verified locally: the failing query now returns `Cannot query field
"indexFieldMetadatas" on type "Index". Did you mean
"indexFieldMetadataList"?` and `indexFieldMetadataList` continues to
work.

Fixes [sonarly issue #54098](https://sonarly.com/issue/54098?type=bug)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22439?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 11:32:39 +02:00
Thomas des Francs d8cc81cb91 Improve billing settings UI (#22377)
## Summary

- Refresh the settings billing subscription and credits cards with
clearer status, usage, and action states.
- Add the credit-package picker flow and route past-due/cancellation
actions to billing management instead of credit modals.
- Clean up related billing UI helpers and formatting.

## Screens

### regular
<img width="1007" height="781" alt="image"
src="https://github.com/user-attachments/assets/dc9c0c59-8cda-422a-a0d3-56292421a744"
/>

### downgrading
<img width="1049" height="818" alt="image"
src="https://github.com/user-attachments/assets/0dc3dea8-5b55-4cf2-bc92-997cf6e9bebc"
/>
<img width="1048" height="901" alt="image"
src="https://github.com/user-attachments/assets/fba805ad-f523-444b-93a0-2011b6b43443"
/>

### Trialing

without card
<img width="1008" height="903" alt="image"
src="https://github.com/user-attachments/assets/d5dd11b6-4c92-4102-ac22-ad1ad4e9fbfb"
/>

with card
<img width="1008" height="806" alt="image"
src="https://github.com/user-attachments/assets/0dbeb00e-890e-4448-94fe-0cc0ef411e8d"
/>


### Past due & Unpaid

<img width="1052" height="860" alt="Past due"
src="https://github.com/user-attachments/assets/25ba53ef-6e74-4b4c-bfe1-d6c74e165917"
/>
<img width="1138" height="860" alt="Unpaid"
src="https://github.com/user-attachments/assets/bb16fe65-84e4-40a7-8951-90b01030aded"
/>

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-07-01 17:02:54 +00:00
Raphaël Bosi 2e6077383b Add install your first apps onboarding V2 step (#22347)
https://github.com/user-attachments/assets/5326d48f-1842-4db1-bc7c-94852145c035


<img width="838" height="754" alt="CleanShot 2026-06-30 at 16 25 05@2x"
src="https://github.com/user-attachments/assets/5c7d53d7-4d65-4e35-aed1-edf0c104e140"
/>


Adds an "Install your first apps" step to the V2 onboarding, shown right
after import-contacts. It lets users opt into installing marketplace
apps (Call recorder and People Data Labs for now) during onboarding.

- New backend `OnboardingStatus.APPS_INSTALLATION` (between SYNC_EMAIL
and PROFILE_CREATION); V1 auto-skips it.
- The primary button sends the selected app ids to the server via
`triggerInstallAppsOnboardingStep`, which enqueues a dedicated job that
installs them asynchronously so onboarding isn't blocked. Skip continues
without installing.
- The workspace is credited per app on successful installation. Credits
are env-driven via `ONBOARDING_INSTALL_APPS_CREDITS_REWARD_PER_APP`,
shown as "Earn +N free credits (1 per tool)".

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22347?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-01 12:25:16 +00:00
neo773 9f3ebaaf22 feat(messaging): sync draft emails and edit them in the thread composer (#22178)
Stop excluding drafts from sync across all three providers (Gmail DRAFT
label, Microsoft/IMAP Drafts folder) and add an isDraft boolean field on
Message so drafts are queryable by the API and AI agents.

Drafts render in the thread with a Draft tag; clicking one opens the
existing reply composer pre-filled with the draft's recipients, subject
and body, and Send reuses the existing send-email flow.

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

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-06-30 13:07:53 +02:00
Paul Rastoin b0d7516951 Deprecate asExpression from field metadata search_vector (#22287)
## Summary

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

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

## Why

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

## What changed

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

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

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

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

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

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

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

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

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

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22287?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-30 09:16:01 +00:00
Félix Malfait 538b180824 feat(dpa): self-serve Data Processing Agreement generator (#22243)
## What

A single, region-aware DPA that serves all customers, generated
automatically from the customer's deployment. Two layers:

1. **Click-through DPA** — recorded at signup (acceptance = execution),
resolving merge fields from the deployment region. Cloud only.
2. **In-app signed-PDF generator** — Settings → Legal → Generate DPA:
preview the agreement, enter legal entity + authorized signatory,
download a PDF pre-signed by Twenty, and store the executed copy against
the workspace with its template version + timestamp. Deep-linkable at
`/dpa` (login-gated) for `twenty.com/dpa`.

## How it resolves

A typed variable matrix (`dpa-region-config.constant.ts`) maps the
deployment region to the contracting Processor entity and terms:

- **EU (default)** → Twenty.com SAS, hosting EU/Frankfurt, governing law
France, SCC section dormant.
- **US (custom)** → Twenty, Inc., hosting US, SCC section active.

Region is a deployment-wide setting (`DPA_DEPLOYMENT_REGION`, default
EU) behind a `DpaRegionService` seam so it can later become
per-workspace without touching callers. The legal text is verbatim from
the template (generated into `dpa-template.constant.ts` directly from
the source `.docx`); only the 6 merge fields are filled and the SCC
sections (7.2–7.5) stay in the document for every region per the spec —
only field values branch. Sub-processors are deferred to
trust.twenty.com (not enumerated). Billing stays decoupled (Twenty, Inc.
remains merchant of record regardless of Processor).

## UI

Standard list + create-page pattern (mirrors API keys / webhooks): a
list of executed copies (with re-download) — or the agreement preview
when none exists — and a top-right blue **Generate DPA** CTA opening a
standard create page. The "Legal" item is intentionally **not** in the
settings menu; the page is reached via the `/dpa` deep link.

## Notable implementation details

- **PDF** is rendered server-side with `@react-pdf/renderer`. The
built-in standard-14 fonts only encode ASCII and crash on the template's
curly quotes / em–en dashes / accented Latin, so Liberation Sans (OFL)
is **subset to a Latin glyph set and embedded as base64 data: URLs** —
no font files to ship or resolve at runtime (works in dev, prod-Docker
and CI).
- New `core.dpaAgreement` table via a fast instance command (FK hash
reproduced to match TypeORM).
- Self-hosted deployments (billing disabled) skip click-through
recording and stamp a prominent "not a valid agreement" banner on the
preview and PDF.

## Tests

- Unit: resolver (per-region entity/law/SCC state, EU default, no
unresolved `{{ }}`, SCC sections present in both regions, self-hosted
notice) and HTML renderer.
- Integration (`test/integration/graphql/suites/dpa`): preview has no
unresolved fields; `generateSignedDpa` renders + persists + returns a
downloadable PDF (asserted with accented input to guard the font
regression); list re-download.

## ⚠ Needs legal input before go-live (marked `TODO_CONFIRM` in
`dpa-region-config.constant.ts`)

- Registered-office addresses for Twenty.com SAS and Twenty, Inc.
- US deployment governing law (the template only specifies France).
- DPO name and the Twenty pre-signed authorized signatory name/title.

## Out of scope (flagged per spec)

Intra-group legal agreement and any Stripe/billing-entity changes. A
future e-sign provider would plug in at `DpaService.generateSignedDpa` +
the signatory input.

> Draft until the integration test passes in CI and the legal
`TODO_CONFIRM` values are supplied.

https://claude.ai/code/session_01Ahjydxx6J1souz1s1NeA9a

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22243?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-27 17:46:38 +02:00
Félix Malfait 0e22ae0521 feat: create calendar events on Google and Microsoft accounts (#22231)
## Context

Twenty can import calendar events and send emails, but cannot create
calendar events. This adds calendar event creation on connected
**Google** and **Microsoft** accounts, mirroring the existing email-send
architecture (`message-outbound-manager`).

## What it adds

The capability is exposed three ways, all backed by the same composer →
driver → persist pipeline:

- **GraphQL mutation** `createCalendarEvent` (metadata API)
- **AI agent tool** `create_calendar_event` (flows to MCP
automatically), gated by a new `CREATE_CALENDAR_EVENT_TOOL` permission
flag
- **Workflow builder node** "Create Calendar Event" in the **Core**
section, with a full settings form (variable interpolation supported)

CalDAV/IMAP is intentionally out of scope for now (different long pole).

## Design notes

- **Reuse over reinvention** — the created event is run through the
existing inbound formatters (`formatGoogleCalendarEvents` /
`formatMicrosoftCalendarEvents`) and persisted immediately via the
existing `CalendarSaveEventsService`, so it appears in Twenty right away
and is reconciled by the next provider sync (dedup on external id).
Persistence is best-effort.
- **OAuth scopes** — Google already requests `calendar.events`
(read+write), so no change there. Microsoft moves `Calendars.Read` →
`Calendars.ReadWrite`; existing Microsoft accounts must re-consent
(surfaced as a clear "reconnect" error via a missing-scope check).
- **Deliberate invitation semantics** — `sendInvitations` is off by
default. When off, the event is created with **no attendees** on either
provider, so creating an event never silently emails external people.
When on, attendees are attached and notified (Google `sendUpdates: all`,
Microsoft's default). This sidesteps Microsoft Graph having no
per-request suppression.
- **Timezone correctness** — Microsoft Graph interprets `dateTime` as
wall-clock in the supplied `timeZone` and ignores the offset, so the
absolute instant is converted to its wall-clock form before sending
(Google honors the offset directly). Both providers end up scheduling
the same instant.
- **Conferencing** — optional Google Meet
(`conferenceData.createRequest`, with a follow-up `events.get` to
resolve the async link) / Microsoft Teams (`isOnlineMeeting`).
- Attendees are a comma-separated string everywhere (tool input, GraphQL
DTO, workflow input), consistent with `send_email` recipients; the
composer parses to its internal list.

## Test plan

- **Unit**: 45 tests covering the composer (validation, all-day
boundaries, offset enforcement, timezone, scope checks, default-account
resolution), both provider drivers, the dispatcher, and the workflow
step-log builder.
- **Integration**: `createCalendarEvent` on the `/metadata` API fails
closed with a structured error for a non-existent account (the
auth/ownership/validation path that doesn't require provider mocking).
- **Manual**: verified the workflow node appears in the Core section,
the settings form renders and round-trips (edit → autosave → reload),
and the live mutation returns a structured failure for a bogus account.

## Open question for reviewers

The metadata mutation `createCalendarEvent` shares a name with the core
schema's auto-generated `createCalendarEvent(data:)` CRUD mutation for
the CalendarEvent object — they live on different endpoints (`/metadata`
vs `/graphql`) so there's no runtime conflict, but it's a potential
point of confusion for API consumers. Happy to rename (e.g.
`createCalendarEventOnConnectedAccount`) if preferred.

## Out of scope / follow-ups

- CalDAV/IMAP support
- Event update/delete and recurrence
- Existing Microsoft accounts need re-consent for the widened scope


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22231?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-06-27 14:05:58 +02:00
martmull e747bc3e42 Add backfill installation feature for pre-installed apps (#22199)
## After

<img width="1070" height="514" alt="image"
src="https://github.com/user-attachments/assets/9cbd2ff5-1678-4c2f-84da-074568f37c51"
/>


## Summary
Adds the ability to backfill application installations across all
existing workspaces. This allows admins to retroactively install a
pre-registered application on every active and suspended workspace
through a background job, making the feature idempotent and
non-blocking.

## Key Changes

- **Backend Service**: Added `backfillApplicationOnAllWorkspaces()`
method to `PreInstalledAppsService` that:
  - Validates the application registration exists
  - Iterates through all workspaces using `WorkspaceIteratorService`
  - Installs the app on each workspace
  - Swallows `APP_ALREADY_INSTALLED` errors for idempotency
  - Logs success/failure counts

- **Background Job**: Created `BackfillApplicationInstallationJob` to
process backfill requests asynchronously via the message queue

- **GraphQL Mutation**: Added `backfillApplicationInstallation` mutation
to `AdminPanelResolver` that:
  - Validates the application registration exists
  - Enqueues the background job
  - Returns immediately without blocking the request

- **UI Components**: Enhanced
`SettingsAdminApplicationRegistrationGeneralToggles` with:
- New "Pre-install on new workspaces" toggle for the `isPreInstalled`
flag
  - "Backfill on all workspaces" button with confirmation modal
  - Loading state and success/error snack bar feedback

- **Data Model**: Added `isPreInstalled` field to
`UpdateApplicationRegistrationPayload` input type

- **Tests**: Added comprehensive unit tests for
`PreInstalledAppsService.backfillApplicationOnAllWorkspaces()` covering:
  - Missing registration validation
  - Successful multi-workspace installation
  - Idempotent handling of already-installed errors
  - Proper error propagation for unexpected failures

## Implementation Details

The backfill operation is designed to be:
- **Idempotent**: Already-installed apps are skipped without error
- **Non-blocking**: Runs as a background job via message queue
- **Resilient**: Per-workspace failures don't block other installations
- **Observable**: Logs aggregated success/failure counts for monitoring


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22199?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: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-06-26 16:21:27 +00:00
nitin f86bf637d1 [BREAKING CHANGE] remove call recording feature flag and backfill upgrade command for existing command menu items navigation command (#22176)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22176?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-25 19:43:36 +05:30
martmull a20ebfa880 feat(applications): remove the application custom settings tab (#22156)
## Summary

Removes the application **custom settings tab** feature. This is one
half of #22059, split out so it can be reviewed/merged independently
from the variable-types enrichment.

## Changes

- Remove the `SettingsApplicationCustomTab` component and its tab
entry/rendering in `SettingsApplicationDetails`.
- Stop syncing `settingsCustomTabFrontComponent` from application
manifests — `ApplicationManifestMigrationService` now only syncs the
default role.
- Deprecate the now-unused fields (kept for backward compatibility, no
longer read or synced):
- `ApplicationDTO.settingsCustomTabFrontComponentId` (GraphQL
`@deprecated`)
-
`ApplicationManifest.settingsCustomTabFrontComponentUniversalIdentifier`
- the `settingsCustomTabFrontComponentId` column comment on
`ApplicationEntity`

The DB column is intentionally **not dropped**, so existing
installations upgrade cleanly.
2026-06-25 12:29:45 +02:00
Félix Malfait 94dbcc27a9 feat(billing): embed credit card form in the add-card trial-end modal (#22125)
## Context

When a trialing workspace (trial without a credit card) clicks **Add
Credit Card** from the "End trial period" banner or the AI-chat
usage-limit banner, the modal currently redirects the browser to
Stripe's hosted billing portal to collect the card. Since we already
embed the Stripe Payment Element in onboarding, this brings the same
in-app experience to the trial-end modal so the whole flow stays inside
Twenty.

## Why the onboarding flow couldn't be reused as-is

The onboarding embed (`createSubscriptionPaymentIntent` /
`SubscriptionPaymentForm`) **creates a new subscription** with
`payment_behavior: 'default_incomplete'`. In the trial-end case the
customer **already has a trialing subscription**, so that path throws
`BILLING_SUBSCRIPTION_INVALID`. The correct primitive here is a
**SetupIntent** against the existing customer: collect + save the card,
then end the trial.

A standalone SetupIntent attaches the card to the customer but does
**not** make it the default (the Stripe portal used to do that for us),
so the trial-end invoice would have no payment method. The backend now
backfills the customer default before charging.

## Changes

**Backend**
- `StripeCustomerService`: `createSetupIntent()` for an existing
customer, and `ensureDefaultPaymentMethod()` which sets the customer
default only when none is already set (won't clobber a portal-chosen
default).
- `BillingPortalWorkspaceService.createPaymentMethodSetupIntent()`:
returns a SetupIntent client secret for the current non-canceled
subscription's customer.
- `BillingSubscriptionService.endTrialPeriod()`: ensures a default
payment method before `trial_end: 'now'`.
- New `createBillingPaymentMethodSetupIntent` mutation +
`BillingSetupIntent` DTO; SDK schema snapshot synced.

**Frontend**
- `AddPaymentMethodForm`: Stripe Elements (`mode: 'setup'`), confirms
with `redirect: 'if_required'` so the common card case stays in-app; 3DS
still redirects and is finished by the existing
`EndTrialAfterPaymentMethodEffect`.
- `AddCreditCardModal`: hosts the embedded form.
- Both trial-end banners (`InformationBannerEndTrialPeriod`,
`AIChatNoMoreBillingCreditsBanner`) open the embedded modal instead of
redirecting when no card is on file; the AI-chat path preserves its
thread context in the 3DS return URL.

## Flow

1. User clicks **Add Credit Card** → embedded modal opens.
2. Card entered → `createBillingPaymentMethodSetupIntent` →
`confirmSetup({ redirect: 'if_required' })`.
3. Non-3DS: confirms inline → `endSubscriptionTrialPeriod` →
subscription active, no redirect.
4. 3DS: redirects to `?startSubscriptionAfterPaymentMethod=true` →
existing effect finishes activation.
5. Self-hosted instances without a Stripe publishable key fall back to
the existing portal redirect (the form renders an unavailable state).

## Notes for reviewers
- The metadata GraphQL types were regenerated by hand (codegen needs a
live `/metadata` server, which wasn't available in the authoring
environment); a `graphql:generate --configuration=metadata` run against
a live backend should be a no-op.
- Local `typecheck`/`lint` could not be run in the authoring environment
(dependency install was blocked); relying on CI to validate.
- Scope is intentionally limited to the two trial-end banner modals. The
Settings → Billing "update payment method" link still uses the Stripe
portal.

https://claude.ai/code/session_01VU7SfrSgaYWr2AhVL8DMfu

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22125?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-25 04:49:31 +02:00
Raphaël Bosi 02120aac42 Add v2 create-workspace onboarding screen (#22075)
https://github.com/user-attachments/assets/30d69db2-ef50-48b5-8233-d9a36511b5e8

Builds the second step of the new onboarding flow on top of #22027: the
v2 "Create your workspace" screen, shown inside `/welcome-v2` at the
`WorkspaceCreation` step.

What changed:
- New `SignInUpV2Header` (back chevron + Twenty logo) and
`SignInUpWorkspaceCreationFormV2` (left-aligned title/subtitle, logo
upload, Name + Subdomain fields, "Create workspace"), wired into
`SignInUpV2` for the workspace-creation step.
- When a subdomain is taken, a box now lists 3 server-verified-available
alternatives. Backend `SubdomainAvailabilityDTO` returns
`suggestedSubdomains` via a new `findAvailableSubdomains` helper.
- The shared `useWorkspaceSubdomainField` hook is extended additively
(new `suggestions` + `applySuggestionValue`) so the v1 `/welcome` screen
is untouched.

Reviewer notes:
- `generated-metadata/graphql.ts` was hand-patched (metadata codegen
needs a running server).
- Storybook: `Pages/Auth/SignInUpV2 → WorkspaceCreation`.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22075?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-24 17:13:20 +00:00
Félix Malfait 614bc7b7e6 feat: serve HTTP logic functions on isolated *.withtwenty.com domain (#22045)
## Summary

Implements
[core-team-issues#2473](https://github.com/twentyhq/core-team-issues/issues/2473):
serve HTTP-triggered logic functions from a dedicated, **cookieless**
public domain (`{workspaceSubdomain}.withtwenty.com`) instead of the
same-site `/s/` route, so functions can safely return **arbitrary
headers** — custom headers, `Permissions-Policy`
(camera/mic/geolocation), `Cross-Origin-Opener-Policy: same-origin`,
`Cross-Origin-Embedder-Policy: require-corp`, `Set-Cookie`, etc.

The `/s/` route stays the strict, same-site path it is today.
**Self-hosting is unchanged** — everything new is gated on
`PUBLIC_DOMAIN_URL` being set.

### Why

Today user-authored function responses are served same-site with the
Twenty app, so the response-header allow-list is restricted to 5 safe
headers and request headers are limited to a per-function allow-list.
Serving from an origin that shares nothing with `*.twenty.com` removes
that constraint safely — the same "user content domain" pattern as
GitHub (`*.githubusercontent.com`) and CodeSandbox (`*.csb.app`).

## What's in here

**Routing**
- The **root-path → `/s` rewrite happens at the nginx ingress**, not in
app code. The existing `api-ingress.yaml` already rewrites root paths
onto `/s` (host-agnostically) when the edge sets
`X-Twenty-Public-Domain: true`, so `*.withtwenty.com` and registered
custom public domains are handled by the same mechanism. (An earlier
in-app middleware was removed as a redundant, wrong-layer duplicate.)
- `WorkspaceDomainsService.resolveWorkspaceAndPublicDomain` recognizes
`*.` subdomains, resolves the workspace by subdomain, and returns
`isIsolatedOrigin`. Explicitly registered public-domain rows still take
precedence and keep their application scoping. The ingress preserves the
`Host` header, so this resolution still fires.

**Headers (server)**
- Isolated origin → all response headers pass through and all request
headers are forwarded. Same-site `/s/` keeps the strict allow-lists.
(Global CORS already handles preflight/ACAO.)

**`/s/` deprecation for new routes (cloud only)**
- New `LOGIC_FUNCTION_LEGACY_ROUTE_CUTOFF` config var (ISO date,
optional). When `PUBLIC_DOMAIN_URL` is set, functions created on/after
the cutoff return **410 Gone** on `/s/` with the new URL. Existing
routes and self-hosted instances are untouched.

**Frontend education**
- `publicFunctionDomain` added to `ClientConfig` (from
`PUBLIC_DOMAIN_URL`).
- The logic-function **Live URL** now resolves to
`https://{workspaceSubdomain}.{publicFunctionDomain}{path}` on cloud,
falling back to `/s/` for self-hosting.
- Front components call their functions through the SDK
(`RestApiClient`), which now targets the isolated domain via the
injected `TWENTY_FUNCTIONS_URL`.
- New **"Public URL"** section on the application **Settings** tab
explaining the isolated domain (shown when the app exposes
HTTP-triggered functions).

**Docs**: note the `withtwenty.com` domain for external callers in the
apps guide.

## Infra prerequisites (not code — needs dashboard work)
- Wildcard DNS `*.withtwenty.com` (proxied) + wildcard TLS in the
public-domain Cloudflare zone.
- Edge (Cloudflare) sets `X-Twenty-Public-Domain: true` for
`*.withtwenty.com` requests, so the existing nginx ingress rewrites them
onto `/s` (same header the custom-domain flow already relies on).
- Set `PUBLIC_DOMAIN_URL=https://withtwenty.com` on cloud.
- Submit `withtwenty.com` to the **Public Suffix List** (required for
cross-tenant cookie isolation before relying on `Set-Cookie`).

## Test plan
- [x] `nx typecheck twenty-server`, `nx typecheck twenty-front`
- [x] `lint:diff-with-main` + oxfmt clean (server + front)
- [x] `npx jest route-trigger public-function-domain
domain-server-config workspace-domains build-logic-function-event
client-config` → server unit tests passing (resolution tiers, header
passthrough vs allow-list, `/s/` cutoff 410)
- [x] `npx jest getLogicFunctionHttpUrl` (front) and `nx test
twenty-client-sdk` (RestApiClient routing) passing
- [x] CI green (server, front, sdk, renderer, ui, zapier, example apps)
- [ ] Manual: hit `{subdomain}.withtwenty.com/` end-to-end once infra is
provisioned

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22045?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-06-24 15:57:01 +02:00
martmull b5a1aed24b feat(server): run server-exposed logic functions in the owner workspace (#22002)
## Summary

Implements the server-level logic-function tier in the simplest shape: a
logic function is "server-exposed" iff its manifest entry carries
`serverWebhookTriggerSettings`. Execution delegates to the
owner-workspace copy of that function — billing, throttling, env vars,
and the existing executor all apply uniformly against that workspace.

Supersedes #21971 with the simplified design from that discussion (no
`applicationRegistrationLogicFunction` registry, no dedicated manifest
type, no separate SDK helper, no special throttling).

## Design

- **Manifest**: `LogicFunctionManifest` gains
`serverWebhookTriggerSettings?`. The declarative `workspaceIdResolver`
shape is dropped.
- **Materialization**: those settings become two new jsonb columns on
`LogicFunctionEntity`. The manifest → flat converter and the
create-from-source DTO/util forward them; the property-config map and
editable-properties list are extended.
- **Lookup**: a single QB query joins `logicFunction → application →
applicationRegistration` and filters on `lf.workspaceId =
reg.workspaceId` to get only the owner workspace's copy.
- **Webhook**: `POST /webhooks/server/:logicFunctionUniversalIdentifier`
→ `ServerWebhookTriggerService.handle` → join lookup →
`LogicFunctionTriggerService.run`. No registry table, no
`:applicationRegistrationUniversalIdentifier` segment, no resolver.
- **Gate**: `IS_SERVER_LOGIC_FUNCTION_ENABLED` config var (disabled by
default).

## Test plan
- [x] `npx jest server-webhook-trigger` — 9 unit tests across the
webhook service.
- [x] `npx jest logic-function` — 88 existing tests stay green.
- [x] `npx nx typecheck twenty-server`.
- [x] `npx nx lint:diff-with-main twenty-server`.
- [x] Reset DB → init → run `database:migrate:prod` → run
`database:migrate:generate --name pending-migration-check` → no drift.
- [ ] Manual: hit `/webhooks/server/<uid>` end-to-end against a manifest
carrying `serverWebhookTriggerSettings`.

https://claude.ai/code/session_01GgsnCGmYJ26xRirx8va1Yh

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22002?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-24 13:34:12 +00:00
Etienne 5ca41d55fb feat(ai): humanize tool-call (#21976)
# Humanize tool-call labels

cc: https://github.com/twentyhq/twenty/pull/21462

## Preview
<img width="459" height="156" alt="Screenshot 2026-06-22 at 19 13 11"
src="https://github.com/user-attachments/assets/e7a2f5f5-cd09-4ec6-920b-5eb16b98285c"
/>
<img width="461" height="156" alt="Screenshot 2026-06-22 at 19 14 54"
src="https://github.com/user-attachments/assets/c2114d2e-2aa8-499a-9801-68e3bb7c45f8"
/>
<img width="461" height="505" alt="Screenshot 2026-06-22 at 19 15 01"
src="https://github.com/user-attachments/assets/ee9ca5d0-8e79-4c63-a2ff-ed5e359a9a9c"
/>

## Why

In the AI chat, tool steps were displayed using raw tool identifiers
(`find_many_companies`, `create_one_task`, `send_email`...) and labels
were partially reconstructed/humanized on the frontend. This was hard to
localize and inconsistent across tool categories.

This PR makes the **backend the single source of truth for
human-readable, localized tool labels**, exposes them through
`getToolIndex`, and reduces the frontend to a thin resolver that picks
the right label for the current status (in-progress / completed).

## What changed

### Backend

- `ToolIndexEntry` (and the `getToolIndex` GraphQL DTO) now carry
`label`, `inProgressLabel?`, `completedLabel?`.
- New `getCrudToolLabels(operation, objectLabel, i18nService, locale)`
builds CRUD labels from a verb table (Search / Find / Group / Create /
Update / Upsert / Delete × imperative / in-progress / completed) + the
(translated, lowercased) object label.
- New `translate-tool-label.util.ts` translates a source label via
`I18nService` (`generateMessageId` → fallback to source when no
translation exists).
- Action tools: labels extracted to the `ACTION_TOOL_LABELS` constant
(`msg` + `i18nLabel`) and translated in
`ActionToolProvider.buildDescriptor`.
- Logic-function tools use the function name as label;
`toolSetToDescriptors` (workflow / view / metadata / dashboard) accepts
an optional `labels` map and falls back to a humanized tool name.
- Labels are localized server-side using the request locale
(`@RequestLocale` → `buildToolIndex` → `context.locale`, threaded
through `ToolContext` / `ToolProviderContext`).
- `code_interpreter` schema now asks the model for `loadingMessage`
(present tense) and `completedMessage` (past tense), so its status text
is model-generated.
- Removed the old generic `loadingMessage` injection mechanism
(`wrap-tool-for-execution.util.ts` deleted; `wrapJsonSchemaForExecution`
/ `stripLoadingMessage` no longer wrap every tool).

### Frontend

- New `useToolLabelMap()` hook builds a `Map<name, { label,
inProgressLabel, completedLabel }>` from `getToolIndex`.
- `getToolDisplayMessage` → `resolveToolDisplayMessage({ input,
toolName, isFinished, labelMap, output })`: a small resolver registry
keyed by tool name (`execute_tool`, `web_search`, `learn_tools`,
`load_skills`, `code_interpreter`, default).
- Default resolver prefers backend `completedLabel` / `inProgressLabel`,
falling back to `Ran X` / `Running X`.
- `learn_tools` / `load_skills` resolve their inner tool/skill names to
labels (label map → tool output labels via `getToolOutputLabelEntries` →
raw name).
- `code_interpreter` step is now expandable to show the code even while
running.

## How tool labelling flows (BE → FE)

```text
BACKEND
┌───────────────────────────────────────────────────────────────────────────┐
│ Tool providers (per category) → ToolIndexEntry                              │
│                                                                             │
│  DatabaseToolProvider                                                       │
│    getCrudToolLabels(operation, object.labelPlural/Singular, i18n, locale)  │
│      verb table (Search/Create/Update/Delete…) + translateToolLabel(object) │
│      → { label, inProgressLabel, completedLabel }                           │
│                                                                             │
│  ActionToolProvider                                                         │
│    ACTION_TOOL_LABELS[toolId] (msg) → translateToolLabel(…, locale)         │
│      → { label, inProgressLabel?, completedLabel? }                         │
│                                                                             │
│  LogicFunctionToolProvider   → label = logicFunction.name                   │
│  toolSetToDescriptors        → label = labels[name] ?? humanize(name)       │
│  (workflow / view / metadata / dashboard)                                   │
└───────────────────────────────────────────────────────────────────────────┘
            │ 
            ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ GraphQL  Query getToolIndex : [ToolIndexEntry]                              │
│   { name, label, inProgressLabel, completedLabel, description,              │
│     category, objectName, icon }                                            │
└───────────────────────────────────────────────────────────────────────────┘
            │
            ▼
FRONTEND ─ resolve the right label for the current status
┌───────────────────────────────────────────────────────────────────────────┐
│ useGetToolIndex() → useToolLabelMap()                                       │
│   Map<name, { label, inProgressLabel?, completedLabel? }>                   │
└───────────────────────────────────────────────────────────────────────────┘
            │
            ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ resolveToolDisplayMessage({ input, toolName, isFinished, labelMap, output })│
│                                                                             │
│   TOOL_LABEL_RESOLVERS[toolName] ?? defaultResolver                         │
│   ├─ execute_tool     → unwrap { toolName, arguments } then re-resolve      │
│   ├─ web_search       → "Searching/Searched the web for <query>"           │
│   ├─ learn_tools      → "Learning/Learned <labels>"                         │
│   ├─ load_skills      → "Loading/Loaded <labels>"                           │
│   │     inner names resolved via: labelMap → output labels → raw name       │
│   ├─ code_interpreter → model's loadingMessage / completedMessage           │
│   └─ default          → isFinished                                          │
│                           ? completedLabel ?? "Ran <label>"                 │
│                           : inProgressLabel ?? "Running <label>"            │
└───────────────────────────────────────────────────────────────────────────┘
            │
            ▼
   Rendered by ThinkingStepsDisplay / ToolStepRenderer
```

## Localization notes

- Standard object labels and action/CRUD verbs are translated
server-side via `I18nService` using the requester's locale.
- Custom object labels are not translated unless a workspace custom
translation exists (matched by `generateMessageId`); otherwise the
source label is used as-is.

## Tests

- **FE:** `resolveToolDisplayMessage` / `getToolOutputLabelEntries`
(status selection, inner-name resolution, `code_interpreter` model
labels, fallbacks).
- **BE:** `toolSetToDescriptors` (label map + humanized fallback) and
`database-tool.provider` label generation.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21976?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-24 13:41:09 +02:00
Félix Malfait 2e099c91e1 fix(domains): show custom domain DNS records and activation status without a page reload (#22037)
## Problem

Setting up a custom domain had two confusing UX issues, both caused by
local state not being refreshed after the relevant mutation:

1. **DNS records didn't appear after saving.** After hitting save you
got the green "Custom domain updated" snackbar, but the "Domain Setup"
section (the Cloudflare/DNS records to configure) stayed empty. You had
to leave the page and come back for the records to show up.
2. **The "Custom Domain" card stayed "Inactive"** even after the DNS
records validated as "Success". Only a full page reload flipped it to
"Active".

## Root cause

**Issue 1 — stale closure.** In `useSettingsCustomDomain.handleSave`,
the `updateWorkspace` `onCompleted` callback called
`setCurrentWorkspace({ ...currentWorkspace, customDomain })` and then
`checkCustomDomainRecords()`. But `checkCustomDomainRecords` guarded on
the closed-over `currentWorkspace.customDomain`, which was still `null`
at that render. The `setCurrentWorkspace` call doesn't synchronously
update that captured value, so the guard returned early and the records
were never fetched. Remounting the page (navigate away/back) ran the
on-mount effect with a fresh workspace, which is why the trip "fixed"
it.

**Issue 2 — `isCustomDomainEnabled` never refreshed locally.** The
Active/Inactive badge is driven by
`currentWorkspace.isCustomDomainEnabled`. The backend flips this flag
inside `checkCustomDomainValidRecords`
(`custom-domain-manager.service.ts`), but the mutation didn't return it,
so the local `currentWorkspaceState` stayed stale until a full reload
re-ran the bootstrap query. The green "Success" DNS rows read from a
different source (`record.status`), which is why the rows and the badge
disagreed.

## Changes

**Issue 1**
- `checkCustomDomainRecords` now accepts the domain explicitly
(defaulting to the workspace value), so the freshly-saved domain can be
passed straight from `handleSave` instead of relying on the stale
closure. No new `useEffect` introduced.
- Fixed the Reload button so it no longer passes its click event as the
domain argument.

**Issue 2**
- Added a nullable `isCustomDomainEnabled` field to the
`DomainValidRecords` GraphQL type, populated only by the custom-domain
check (the shared public-domain flow leaves it null, so it's backward
compatible).
- The frontend now writes that value back into `currentWorkspaceState`
when the check completes, using a **functional** Jotai update so a
concurrent `customDomain` update is never clobbered. The badge flips to
"Active" as soon as validation passes — on mount, on Reload, and right
after save.

I deliberately kept this targeted rather than introducing real-time
workspace sync: `isCustomDomainEnabled` only changes server-side during
the on-demand DNS check (mount/Reload/cron), so returning it from that
mutation is sufficient and far lower risk.

## Notes
- `packages/twenty-front/src/generated-metadata/graphql.ts` was updated
to match what `graphql:generate` produces for the new schema field
(codegen requires a running backend, which isn't available in this
environment). Worth re-running codegen in CI to confirm it's
byte-identical.
- No existing unit or integration tests reference these paths.

## Test plan
- [ ] Set a custom domain → DNS records appear immediately (no
navigation needed).
- [ ] Once DNS validates, the "Custom Domain" card flips to "Active"
without a reload.
- [ ] Reload button still refreshes records.
- [ ] Public domain validation flow is unaffected.

https://claude.ai/code/session_01BB6C6bpPZMUbMzKSCydEaj

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22037?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-23 18:02:44 +02:00
Paul Rastoin e9d5d71cd3 Wire up search field metadata (#21964)
## Part 1 - Exact scope of the current PR (#21964)

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

This PR introduces `searchFieldMetadata` as a first-class flat metadata
entity and migrates the existing search surface onto it, with **no
change to which records are searchable** (ISO with `main`).

In scope (what the PR does):
- New flat entity `searchFieldMetadata` (universalIdentifier,
applicationId, **`position`**, maps, conversions), registered in the
central flat-entity constants and the migration build orchestrator.
- `searchVector.asExpression` is **derived server-side** from
`searchFieldMetadata` rows (validated by `isSafeTsVectorExpression`);
never trusted from client input.
- **Derivation order is deterministic, driven by each row's `position`**
([compute-search-vector-as-expression-from-search-field-metadatas.util.ts](packages/twenty-server/src/engine/metadata-modules/flat-search-field-metadata/utils/compute-search-vector-as-expression-from-search-field-metadatas.util.ts)),
replacing the previous non-deterministic `(createdAt, id)` sort. That
sort collapsed to random UUIDs for standard fields (same `createdAt`),
so any rename/relabel rewrote the `STORED` generated column to a
logically-identical-but-textually-different expression and produced a
permanent per-workspace diff vs the standard definition. Ordering now
equals provisioning order; ties break on `universalIdentifier`.
- Provisioning at object creation mirrors the existing surface exactly
**and seeds `position`**:
- custom objects -> the `name` field only, at `position: 0`
([build-default-search-field-metadatas-for-custom-object.util.ts](packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-search-field-metadatas-for-custom-object.util.ts))
- standard objects -> their curated `SEARCH_FIELDS_FOR_*` sets,
`position` = the curated index
- Backfill (instance + workspace commands in `2-16`) provisions rows for
existing workspaces with the same surface **and the same positions**
(standard from the curated standard maps, custom `name` = `0`), scoped
to the workspace's own custom application
([build-search-field-metadata-backfill-operations.util.ts](packages/twenty-server/src/database/commands/upgrade-version-command/2-16/utils/build-search-field-metadata-backfill-operations.util.ts)).
The `position` column is added in the same `2-16` fast instance command
as `universalIdentifier`/`applicationId`.
- Field rename of an already-indexed field recomputes `asExpression`
(positions preserved, so order is stable)
([recompute-search-vector-on-field-rename.util.ts](packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/recompute-search-vector-on-field-rename.util.ts)).
- Field delete drops the matching row(s) and recomputes; remaining rows
keep their relative order (no renumber)
([from-delete-field-input-to-flat-field-metadatas-to-delete.util.ts](packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util.ts)).
- Object relabel is **additive** and ISO/regression-fix only: it indexes
the new label identifier **appended last (`position = max(existing) +
1`)** without dropping `name`
([recompute-search-vector-on-label-identifier-update.util.ts](packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-search-vector-on-label-identifier-update.util.ts)).
This is a deliberate, temporary bridge.

Explicitly OUT of scope (deferred):
- No API to edit `searchFieldMetadata` (no user-facing search-field
configuration, including `position` — it is internal and only written by
provisioning/backfill/recompute).
- No auto-indexing of arbitrary searchable fields. Creating a custom
TEXT/EMAILS/etc. field does NOT add it to search (the
`computeSearchFieldMetadataCreationForFields` behavior was removed in
`e6820ad`).
- No field-type-transition handling (field type is immutable - not in
`FLAT_FIELD_METADATA_EDITABLE_PROPERTIES`, so that path was dead code).
- No `position` validation (uniqueness/range) and no multi-vector /
per-field `weight` config — deferred to the configurable-search
follow-up (#1428).

Net: `searchFieldMetadata` becomes the source of truth for the *same*
surface as `main`. The only intentional divergences from `main` are
"relabel preserves `name`" (additive) and the deterministic
`position`-ordered `asExpression` (a correctness/perf fix that is
byte-identical to provisioning order, so it does not change the
searchable surface).

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2026-06-23 16:27:13 +02:00
avonian 47b48d83f7 fix(front): include isUIEditable/isRemote in CreateOneObjectMetadataItem so new objects aren't read-only until refresh (#21796)
## Problem

After creating a custom object in **Settings → Data Model**, the new
object's **"New Field"** (and **"Add relation"**) buttons are missing.
They only appear after a hard page refresh.

## Root cause

`CreateOneObjectMetadataItem`
(`packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts`)
selected only a subset of object-level fields and omitted
`isUIEditable`, `isRemote`, `isSystem`, `isUICreatable`,
`universalIdentifier`, `shortcut`, and `duplicateCriteria` — all of
which are present in the shared `ObjectMetadataFields` fragment used by
the bootstrap query.

`useCreateOneObjectMetadataItem` writes the mutation response into the
metadata store via `addToDraft`. Because the mutation resolves *after*
the SSE create event and `addToDraft` replaces entries by `id`, the
reduced mutation response overwrites the fuller record that arrived over
SSE. The stored object then has `isUIEditable === undefined`, so
`isObjectMetadataReadOnly` returns `true` (`!undefined`), and
`ObjectFields` hides the action buttons via its `{!readonly && …}`
guard.

A hard refresh "fixes" it only because the bootstrap query repopulates
the store from `ObjectMetadataFields`, which includes the missing
fields.

## Fix

Add the missing object-level fields to the `CreateOneObjectMetadataItem`
selection so a newly created object matches the bootstrap shape, and
regenerate the metadata GraphQL types. No other code changes required.

## How to test

1. Go to **Settings → Data Model** and create a new custom object.
2. Open the new object's **Fields** tab.
3.  The **"New Field"** button is visible immediately — no refresh
needed.

Before this change, the button was hidden until a manual refresh.

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


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21796?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-23 10:46:16 +02:00
neo773 9e31ffdf68 feat(messaging): webhook push sync for Gmail, Calendar and Microsoft (#21970)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21970?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-23 14:04:16 +05:30
Félix Malfait 23f5ba9ebf feat: add resizable kanban column width (#21828)
## What & why

Lets users resize the columns of a Kanban (record board) view. Requested
by a user; the design avoids the "ragged board" problem by making the
width a **single shared value**.

## Behaviour

- A drag handle appears on the right edge of every column header.
- Because all columns read **one** width value, dragging any handle
resizes **every** column together — they can never end up mismatched.
- Width is clamped between **150px** and **400px** (default **200px**).
- The width is **persisted per view** and restored on reload.

## Approach

**Backend** — a new nullable `View.kanbanColumnWidth` field, threaded
through the existing view-level setting pattern (the same one
`kanbanAggregateOperation` / `shouldHideEmptyGroups` use), so it gets
create/update/manifest/override support for free:
- entity column + `ViewOverrides` + `@WasIntroducedInUpgrade`
- `CreateViewInput` / `UpdateViewInput` (`Int`, `@Min(150)`/`@Max(400)`)
+ `ViewDTO`
- flat-view editable properties, entity-properties config, compare-type,
standard-view + manifest converters
- a fast instance command adding the `core.view` column

**Frontend** — the value hydrates into a view-scoped atom and drives a
single CSS variable set on the board container, which both column
headers and bodies read. Live dragging only writes that CSS variable (no
per-move React re-render); the final width is committed to the atom and
persisted via `updateView` on pointer-up.

## Nullability / defaults

`kanbanColumnWidth` is nullable — `null` means "never resized" and the
UI falls back to the 200px default, so existing rows need no backfill.

## Validation

- `nx typecheck twenty-server`  and `nx typecheck twenty-front` 
- `nx lint:diff-with-main twenty-server` ; frontend lint fixes applied
(split constants to one-per-file, removed `useRef`-for-state in favour
of `useState`).
- Draft pending a final green CI run (the dev container reclaimed
`node_modules` mid-session; re-running locally).

## Test plan

- [ ] Drag a kanban column edge → all columns resize together, clamped
150–400px
- [ ] Reload → width persists for that view; other views unaffected
- [ ] A view that was never resized still renders at 200px

https://claude.ai/code/session_016Qe6oDBkhVbrq2QkXBJ5nE

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-19 15:58:13 +02:00
Félix Malfait adf6eb572b feat(billing): embed Stripe Payment Element in onboarding (#21759)
## What & why

Replaces the hosted Stripe Checkout redirect on the onboarding "Choose
your plan" step (credit-card trial) with an inline Stripe **Payment
Element**, so users never leave the app to enter card details.

## How it works

- **Frontend:** a deferred `<Elements mode="setup">` renders the Payment
Element, themed via the Appearance API. On Continue: `elements.submit()`
→ `checkoutSession` mutation creates the trialing subscription
server-side and returns its pending SetupIntent `clientSecret` →
`stripe.confirmSetup()` confirms the card (handling 3DS) → redirect to
the existing `/plan-required/payment-success`.
- **Backend:** new `BILLING_STRIPE_PUBLISHABLE_KEY` config var exposed
via `/client-config`; the card path creates the subscription with
`payment_behavior: default_incomplete` + a free trial (so Stripe
attaches a `pending_setup_intent`) and returns its client secret. The
hosted-Checkout code path is removed.
- The **no-credit-card** trial path is unchanged.
- Billing address collection is **disabled** in the Payment Element to
reduce friction; `automatic_tax` is correspondingly disabled (tax needs
an address — collect it later, e.g. at conversion / via the billing
portal).

## Required before this works
1. Set `BILLING_STRIPE_PUBLISHABLE_KEY` (`pk_…`) on the server (infra
change pending).
2. Run `nx run twenty-front:graphql:generate --configuration=metadata`
against a server exposing the updated schema (see inline note on the
hand-authored document).
3. Verify in Stripe test mode: happy path, 3DS (`4000 0025 0000 3155`),
a decline.

## Verified
typecheck (front + server), oxlint + oxfmt clean,
`client-config.service.spec` passing. Not run here: the app end-to-end /
Stripe test mode and `graphql:generate` (no server/DB in the dev
container).

I've left self-review comments inline flagging cleanup opportunities
plus a couple of architectural/tech-debt items.

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

https://claude.ai/code/session_01TxCfinXq7abSrbF7aTw2cA

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-19 11:40:55 +02:00
neo773 616d58bc7e messaging: gmail folder backfill (#21753)
demo


https://github.com/user-attachments/assets/a157cee1-a8fa-4050-af1b-c31a83fb75da

/closes #17095


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21753?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-19 01:59:35 +02:00
Weiko 9f30915f6f fix(metadata): remove deprecated isCustom from Objects and Fields (#21799)
## Context

Follow-up to #21228, which deprecated `isCustom` on object/field
metadata but kept it exposed because the frontend still relied on it.
This removes it from the GraphQL API and the frontend entirely.

## Implementation

### Server
- Remove `isCustom` `@Field` from the `Object`, `Field`, and
`MinimalObjectMetadata` GraphQL types
- Remove the `isCustom` `@ResolveField` resolvers and the
`isCustomLoader` dataloader (+ payload/interface)
- Remove `isCustom` as an internal `@HideField()` on the Object/Field
DTOs used by the i18n standard-override gate > Use an explicit
isStandard instead (which is the correct gating)

### Frontend
- Add `getIsMetadataItemCustom` helper + `useGetIsMetadataItemCustom`
hook: an item is custom when `applicationId ===
currentWorkspace.workspaceCustomApplication.id`
- Migrate all consumers off `objectMetadataItem.isCustom` /
`fieldMetadataItem.isCustom`; `isRecordFieldReadOnly` now takes a
precomputed `isFieldCustom`
- Drop `isCustom` from the metadata fragment/mutations/minimal query, FE
types, zod schemas, and mock generators; regenerate GraphQL types

## Notes
- Breaking change on the (already-deprecated) `Object.isCustom` /
`Field.isCustom` GraphQL fields and the `isCustom` filter
- FE semantic is "belongs to the workspace custom app" (third-party-app
objects/fields are treated as non-custom)
- `isCustom` on IndexMetadata / View / Skill / Agent is a separate
column and is untouched
- Breaking changes on REST metadata API
2026-06-19 01:19:49 +02:00
Félix Malfait 6a1b28bc12 feat(auth): collect the workspace logo on the sign-up creation step (#21723)
## What & why

A single, consistent **workspace-creation step** for both
multi-workspace and single-workspace self-host — collecting **name +
logo** (and the **subdomain** in multi-workspace) — which **removes the
duplicate name/logo prompt** that previously reappeared on the workspace
subdomain (reported after #21641).

## Changes

**One creation form for both modes**
- With 0 workspaces, both multi-workspace and single-workspace route to
the shared `SignInUpWorkspaceCreationForm`; `SignInUp` renders it for
the `WorkspaceCreation` step regardless of domain/scope.
- The subdomain field shows only in multi-workspace; single-workspace
keeps its fixed address.

**Logo on the creation step**
- New scoped `uploadNewWorkspaceLogo(workspaceId, file)` mutation: the
creator sets a logo on their just-created `PENDING_CREATION` workspace
via the workspace-agnostic token (membership enforced — only the creator
is a member at that point), reusing `uploadWorkspacePicture`. Upload
size is capped via `settings.storage.maxFileSize` (also applied to the
existing logo / profile-picture uploads).
- The picked file is held locally (object-URL preview, revoked on
unmount) and uploaded right after creation (non-fatal on failure).

**Onboarding step → pure activation loader**
- The old "Create your workspace" form (name + logo) is removed. The
onboarding step now activates the pending workspace on mount and shows
the loader, with a **Retry** action on failure.

## Testing
- typecheck (front + server) ; oxlint + oxfmt clean on changed files 
- Unit tests: `auth.resolver.spec`, `useWorkspaceSubdomainField`,
`SignInUpWorkspaceCreationForm` (multi + single-workspace), `useAuth` 
- Metadata GraphQL + `twenty-client-sdk` schema regenerated.

Follow-up to #21641.

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

https://claude.ai/code/session_01Xw37hR5seiCyWnppG9z4op

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:56:14 +02:00
nitin 640a8e6ca6 Add post-call recording ingestion and billing (#21758)
## Summary

- Add post-call Recall recording ingestion for transcripts, audio, and
video
- Request/retrieve async transcripts and reconcile stale pending
transcript markers
- Complete call recordings atomically once all artifacts and billable
timestamps are available
- Charge `CALL_RECORDING` usage once per completed recording based on
recording duration
- Add Recall recording/media API helpers, transcript marker utilities,
and audio/video field identifiers
- Update generated metadata/SDK files and billing usage operation
support
- Add unit coverage for ingestion, completion, charging, Recall API
behavior, and reconciliation flows



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21758?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-18 14:01:18 +05:30
Etienne d99e479be8 feat(billing) - facilitate top up in ai chat (#21645)
Today, when a trialing user hits their AI usage cap inside the Ask AI
chat, ending the trial bounces them to the Stripe billing portal (and,
for card-less users, loses their place in the conversation). This PR
makes activating a paid plan / topping up credits feel seamless from
within the chat:

Trial users with a card on file activate their subscription in place,
without leaving the app.
Trial users without a card are sent to the Stripe payment-method portal
and, on return, the trial is ended automatically and they're dropped
back into the exact Ask AI thread they came from.
Credit-exhaustion and trial banners now reflect whether a payment method
exists (Add Credit Card vs Subscribe Now / End Trial Period) and upgrade
inline via a confirmation modal instead of redirecting to Settings.


Uploading Screen Recording 2026-06-16 at 07.51.12.mov…


https://github.com/user-attachments/assets/4ea77273-da63-4b32-b6f1-5ac9e9560651



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21645?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-17 16:20:11 +00:00
nitin 177afde866 [BREAKING CHANGE] fix chart cache collisions with key-based data plumbing (#21743)
closes
https://discord.com/channels/1130383047699738754/1514946035317997709

This fixes Apollo cache collisions for pie slices and line series by
keeping chart bucket identity as key end-to-end, matching how bar chart
already works.


What changed -- 

- Renamed pie/line chart response identity from id to key in the chart
data path.
- Kept key through frontend chart hooks, types, stories, and
tooltip/drilldown logic.
- Only adapt key to id at actual external boundaries like Nivo and
GraphWidgetLegend.
- Added/updated tests covering cache normalization and chart data
behavior.


before - 

<img width="2600" height="844" alt="CleanShot 2026-06-17 at 20 17 14@2x"
src="https://github.com/user-attachments/assets/b9ee83e9-db4b-423e-8668-a7beb4c4c62e"
/>

after - 

<img width="2614" height="800" alt="CleanShot 2026-06-17 at 20 16 17@2x"
src="https://github.com/user-attachments/assets/674a5417-ffc2-441d-9484-e1126438254c"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21743?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-17 16:13:27 +00:00
martmull 102c530d0f Add limit on view widget (#21718)
<img width="1345" height="463" alt="image"
src="https://github.com/user-attachments/assets/a5d9ac2f-6375-4956-895d-3675aa9bebc1"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21718?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: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-06-17 14:43:31 +00:00
Félix Malfait eeed998c9e Let users pick their workspace subdomain during sign-up (#21641)
## What & why

During onboarding the workspace subdomain was auto-generated at sign-up
and only editable later in Settings. This adds a subdomain picker to the
workspace-creation flow, with **live availability checking** and
**name-driven auto-fill**.

The subdomain is chosen **on the central sign-up domain, before the
redirect onto the workspace subdomain** — so there's no mid-onboarding
domain switch (which would otherwise force a re-auth, like the Settings
"this logs everyone out" flow). It works uniformly for credentials and
SSO, since workspace creation is a post-auth mutation.

## Flow

Authenticate → **Create a workspace** → new step (workspace name +
address with live availability + auto-fill, seeded from the work email)
→ workspace is created with the chosen subdomain → the single redirect
lands on the final subdomain → onboarding modal (name pre-filled).

## Changes

**twenty-shared**
- `getSubdomainSlugFromDisplayName` — friendly slug from a display name,
built on the existing `transliteration` package (also transliterates
non-Latin names, e.g. 日本語 → `ri-ben-yu`).

**twenty-server**
- `checkWorkspaceSubdomainAvailability(subdomain)` query
(workspace-agnostic, `UserAuthGuard`) → `{ isValid, available,
suggestedSubdomain }`.
- `SubdomainManagerService`: availability + suggestion logic with
friendly numbered suffixes (`acme`, `acme-2`, …) instead of random hex;
`generateSubdomain` reuses it.
- `signUpInNewWorkspace` accepts an optional `{ displayName, subdomain
}` input (validated; falls back to auto-generation when omitted —
backward compatible, so existing callers are unaffected). Concurrent
same-subdomain sign-ups return a clear "already taken" error instead of
a generic DB error.

**twenty-front**
- New `SignInUpStep.WorkspaceCreation` step +
`useWorkspaceSubdomainField` hook (debounced, stale-response-safe;
auto-fills from the name until the user edits it, with a one-click "use
suggested" when taken; ignores Enter during IME composition; surfaces a
clear error if the availability check fails).
- Onboarding modal name pre-filled from the chosen name.

## Testing

- Unit tests: shared slug util, the `useWorkspaceSubdomainField` hook
(real auto-fill/availability flows via `MockedProvider`), and the
workspace-creation component; existing sign-up tests still pass.
- Typecheck, lint, and format green across twenty-shared / twenty-server
/ twenty-front.

## Notes / out of scope

- No DB migration — the `subdomain` column already existed.
- Self-hosted single-workspace sign-up is unchanged; the step is gated
to multi-workspace (global scope).
- Low-priority follow-ups: length bounds on the subdomain / displayName
inputs, and an integration test for the availability query.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:52:51 +02:00
neo773 d8d5991977 fix(messaging): honor IMAP/SMTP encryption setting instead of inferring it from the port (#21562)
This pull request makes the IMAP and SMTP encryption setting actually
honor what the user selects.

As per spec there's 3 modes: SSL/TLS (implicit TLS from the start),
STARTTLS (it will attempt TLS but if the server doesn't support it, it
gracefully falls back to plaintext), NONE (plaintext)

Current implementation had a boolean flag for this, this replaces it
with the 3 modes

Upgrade command to migrate all existing accounts, to not risk breaking
anyone's existing account in production we map each account to the mode
that matches its current behavior, so nothing changes on the wire

/closes #21300

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

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-16 18:09:04 +02:00
Félix Malfait 61309c45e6 feat(onboarding): prefill the invite step with teammates from the connected calendar (#21640)
## What & why

Implements core-team-issues#1414: move the calendar/email connection
earlier in onboarding and use the freshly connected calendar to prefill
the **Invite your team** step with likely teammates, so users don't
start from an empty form.

## Approach

Everything is behind the feature flag
`IS_ONBOARDING_INVITE_SUGGESTIONS_ENABLED` (off by default).

**Reorder** — onboarding becomes `Workspace activation → Connect account
→ Create profile → Invite team`. Connecting before profile gives the
calendar sync a head start; connecting *before* the workspace exists
isn't possible (a connected account requires an activated workspace +
workspace member + OAuth transient token). Gated in both
`OnboardingService.getOnboardingStatus` (backend) and
`useSetNextOnboardingStatus` (frontend) so the two agree.

**Fast teammate lookup** — on Google/Microsoft connect *during
onboarding*, a background job (`FetchOnboardingInviteSuggestionsJob`)
runs a single bounded calendar fetch (recent events, attendees inline),
keeps same-work-email-domain colleagues (excludes self + aliases;
personal mailboxes yield nothing), ranks by meeting frequency, and
caches the top 5. The invite step reads the cache via a new
`getInviteSuggestions` query and prefills the form — polling briefly
while the cache warms, and never overwriting input the user has already
typed.

Providers: **Google** (Calendar `events.list`) and **Microsoft** (Graph
`calendarView`), routed by a `CalendarAttendeesService` dispatcher
(mirrors the existing `CalendarGetCalendarEventsService`). Any fetch
failure (missing scope, API error) degrades to today's empty form via
the orchestrator's best-effort catch.

## How to enable

Turn on `IS_ONBOARDING_INVITE_SUGGESTIONS_ENABLED` for a workspace
(admin panel).

## Notes

- New-workspace creators only (invitees never see the connect/invite
steps). Skipping the connect step, or signing up with a personal email,
falls back to the current empty form.
- The "We found teammates from your calendar" subtitle only shows once
suggestions are actually prefilled.
- i18n: the new `<Trans>` strings are extracted on merge to `main` by
the existing Crowdin workflow.

## Testing

- Frontend unit tests for the reorder state machine (both flag states).
- `npx nx typecheck` and `npx nx lint:diff-with-main` green for
`twenty-front` and `twenty-server`.
- Server boots with the new DI wiring (no circular dependency);
`getInviteSuggestions` / `InviteSuggestion` present in the live metadata
schema.
- Not exercised in CI: live Google/Microsoft OAuth end-to-end (requires
real accounts + calendar data).

https://claude.ai/code/session_019MyY3bfAEij4AwSXMCLtWY

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21640?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 <62795688+neo773@users.noreply.github.com>
Co-authored-by: neo773 <neo773@protonmail.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-06-16 15:45:11 +00:00
Weiko f0c3883fd1 fix(command-menu-item): persist overrides after save and add reset-to-default (#21623)
## Context
Command menu items are an overridable entity (like page-layout / FIELDS
widgets), but the override flow in layout-customization mode was broken:

- **Move / pin-unpin / hide-label didn't persist.**
`useSaveCommandMenuItemsDraft` fired the `updateCommandMenuItem`
mutations (backend persisted correctly) but never wrote the result back
into `metadataStoreState`, the source the live menu and edit panel read
from. So the UI reverted on exit and changes only showed after a hard
reload.
- **No true "reset to default".** Existing reset controls only reverted
the draft to the last-saved values (which still contained overrides).
there was no way to clear overrides back to the original values after a
save.

Notes
- removed the footer "Reset to default" button. This is not clear to me
how we want to build, let's re-implement better in the next version
- reset are done on click and not delayed on the save. This is similar
to other reset to default on page layouts where we actually usually
reload the component and this is because the FE has no idea what's
original VS override from the response itself

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21623?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-15 18:10:42 +02:00
Alexandre Ribeiro fefb6cdb94 feat(page-layout): add number format option to aggregate chart widget (#21521)
## Context
Closes #21522
Large values in the dashboard **Number** widget are always abbreviated
(e.g. `1300090` → `1.3m`) with no way to display the full number.
Following a discussion with the core team who were interested in this
feature
(https://discordapp.com/channels/1130383047699738754/1509604545381142649)
, this adds a **Format** option in the **Style** section of the Number
(aggregate chart) widget, letting users choose between **Short**
(abbreviated, current behavior) and **Full** (complete number with
thousand separators).

Only the displayed value of the Number widget is affected — axes, labels
and tooltips of other chart types are intentionally left untouched.

## What's inside

**Server**
- New `ChartNumberFormat` GraphQL enum (`SHORT` / `FULL`), following the
`AxisNameDisplay` pattern
- The existing — and previously unused — `format` field on
`AggregateChartConfigurationDTO` is now typed with this enum and
validated with `@IsEnum`
- The dashboard AI tool schema (`widget.schema.ts`) accepts the new
`format` option
- Regenerated GraphQL types and the `twenty-client-sdk` metadata client
to reflect the enum

**Front**
- New **Format** setting in the Style section of the Number widget
settings, with a Short/Full selection dropdown (same pattern as the Axis
name setting)
- `transformAggregateRawValueIntoAggregateDisplayValue` takes an
optional `numberFormat`:
- `FULL` → full number via `formatNumber` (currency values keep up to 2
decimals)
  - `SHORT` → abbreviated via `formatToShortNumber`
- not set → behavior unchanged (currency short, number full), so
existing widgets and the record table/board footers render exactly as
before

## Screenshots

| Full UI Look | 

<img width="1917" height="955" alt="Twenty_Showcas_FullShort"
src="https://github.com/user-attachments/assets/05d05779-395d-4e1a-8ff0-964f6fbef182"
/>

| Menu UI Look |
<img width="291" height="308" alt="Screenshot_2"
src="https://github.com/user-attachments/assets/82b5a1de-32fe-46ec-a9b8-add11ab4c6cd"
/>
 

## Tests

- Extended `transformAggregateRawValueIntoAggregateDisplayValue` unit
tests with SHORT/FULL cases for currency and number fields
- Updated the page-layout-widget creation/update integration tests and
snapshots to use `ChartNumberFormat.SHORT`


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

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-13 23:32:41 +02:00
neo773 5d892bdfd0 [WIP] Feat/marketing emails (#21173)
Marketing/campaign emails on top of the emailing-domain (SES) feature:
send a broadcast to a hand-picked list, with per-customer-domain
unsubscribe links and opt-out-only **unsubscribe topics**.

## Model

Standard objects (workspace schema, flat-metadata):
- `messageCampaign` — a campaign send (subject, body template, from
address, status, list, optional unsubscribe topic).
- `messageList` + `messageListMember` — the hand-picked audience (person
↔ list join). A campaign's recipients are its list's members; everyone
is sendable unless suppressed.

Core entities (`core` schema, workspace-scoped — readable by the public
unsubscribe flow without a workspace context):
- `unsubscribeTopic` — an opt-out-only category (name, description,
visibility). There is no opt-in subscription state.
- `messageSuppression` — the single consent store: a row with
`unsubscribeTopicId` NULL is a global block; a row with an
`unsubscribeTopicId` and reason `UNSUBSCRIBE` is a per-topic opt-out.
Two partial unique indexes dedupe global vs per-topic rows (Postgres
treats NULLs as distinct).
- `emailingDomain` — the workspace's SES sending domain,
auto-provisioned when an email channel is added (and cleaned up when its
last channel is removed), with verification status + DNS records.

Campaign messages reuse the existing `message` / `messageThread` /
`messageParticipant` model — one outbound `message` per recipient with a
`deliveryStatus` state machine.

## Sending

- `sendMessageCampaign` resolves the audience **under the caller's
permissions**, creates the campaign, and enqueues a single fan-out job
(the request never materializes per-recipient rows or jobs).
- The fan-out job materializes one QUEUED message per recipient
(deterministic ids → idempotent re-runs, reconciles crash-orphaned rows)
and fans out per-recipient send jobs carrying **only ids**.
- Each send job renders per-recipient `{{variable}}` merge fields and
sends via `EmailingDomainSenderService`, which applies suppression
(global + per-topic) and the unsubscribe footer/headers. Suppressed
recipients are recorded `SKIPPED`.
- The campaign finalizes `SENT`, or `SENT_WITH_ERRORS` if any recipient
terminally failed.
- `previewMessageCampaignAudience` returns a pre-send breakdown (total /
without-email / duplicate / globally-unsubscribed / topic-unsubscribed /
sendable), shown as a hint under the composer pickers.

## Unsubscribe

- Encrypted (AES-256-GCM) token carrying workspaceId, address, optional
`unsubscribeTopicId`, `issuedAt`, and a `preview` flag.
- One-click POST (RFC 8058) + `mailto:` — topic-scoped when the token
carries a topic, global otherwise.
- Preferences page: a checkbox per visible topic (checked = still
receiving); submitting creates per-topic opt-outs for unchecked topics
and lifts re-checked ones (UNSUBSCRIBE only — never
`BOUNCE`/`COMPLAINT`, never a global block).
- A **Preview** action in settings opens the live page via a
preview-claim token; opt-out POSTs are no-ops for preview tokens, so
previewing never mutates state.
- SES webhooks: inbound unsubscribe + outbound bounce/complaint →
suppression (race-safe against at-least-once delivery, with reason
escalation that never downgrades).
- Per-customer unsubscribe hostname (Cloudflare DNS); sends are gated on
it being active, except in LOG/demo mode.

## Architecture

Campaign orchestration, suppression, the sender, the unsubscribe
controller, and the SES webhook handlers live in `src/modules/emailing`
+ `src/modules/messaging-webhooks` (the workspace-feature layer).
`core-modules/emailing-domain` keeps the SES driver, domain
provisioning, the `unsubscribeTopic` / `messageSuppression` core
entities, and the unsubscribe token/hostname plumbing. Domain creation
is validated (`CreateEmailingDomainInput` — domain-format regex,
lowercased) before any value reaches SES or the unsubscribe hostname.

## Frontend

- Campaign composer side panel (from / list / unsubscribe topic /
subject / body) with a live audience-preview hint.
- Email settings: email channels each showing their auto-provisioned
sending domain in a single section (status + DNS records + a "Check
verification" action), plus an **Unsubscribe Topics** section to
create/manage topics and preview the recipient page. A demo-mode banner
is shown when the LOG driver is active.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-06-13 18:37:39 +02:00
Félix Malfait 76f69efb43 Keep synced messages and events when removing a workspace member (#21443)
## Context

Removing a workspace member deletes their connected accounts, which
cascades into deleting every message and calendar event those accounts
synced. For a CRM, losing the email history of departed teammates is a
big deal.

## What this does

Connected accounts are now kept and reassigned instead of deleted when a
member is removed:

- Ownership moves to the acting user (whoever removed the member). When
members remove themselves (leave workspace, account deletion), it falls
back to the oldest admin.
- OAuth tokens are revoked, credentials wiped, message/calendar channels
get `isSyncEnabled = false`, and the account is stamped with a new
`archivedAt` column (fast instance command included).
- Synced messages, threads and calendar events stay in the workspace.
Channel visibility settings keep applying as before, since channels and
associations survive.
- The reassigned account appears in the new owner's Settings → Accounts,
where it can still be deleted (with its data) like any other account.

The transfer happens synchronously during removal, while the member's
userWorkspace row still exists. This also removes
`DeleteWorkspaceMemberConnectedAccountsCleanupJob` and its listener: the
async job had to reconstruct the account-owner link from rows the
removal flow had just deleted, which was race-prone (see 2181fb541e).

Archived accounts are excluded from the workflow send-email default
account resolution, and both removal confirmation modals now mention
what happens to synced data.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-06-13 15:52:13 +02:00
Weiko f63f053444 CommandMenuItem overridable entity (#21486)
## Context
Second PR of the overridable-entities track (after #21436 for views):
command menu items become overridable so that edits on non-owned items
are stored as overrides instead of mutating the row, and
deletion/deactivation becomes reversible.

 ## What this does

- `CommandMenuItemEntity` now extends
`OverridableEntity<CommandMenuItemOverrides>` (adds `isActive` +
`overrides`). All editable properties are overridable for now (to
discuss).
- **Update**: mutations on a command item not owned by the caller
(standard items) are written into `overrides`; reads merge them in the
DTO. The command palette edit mode (pin,
reorder, shortLabel) now preserves standard values, "Reset label to
default" gains true post-save semantics.
- **Delete**: protected items are deactivated (`isActive = false`)
instead of deleted; custom items still hard-delete.
- **Object deactivate/enable toggle**: now flips `isActive` on the
command item (merged into the main migration call) instead of
delete/recreate; a create-if-missing fallback covers legacy deactivated
objects.
- **Front**: inactive command items are filtered out of the palette
selector.


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

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-13 14:21:27 +02:00