Commit Graph

13450 Commits

Author SHA1 Message Date
Paul Rastoin bd8bf89653 Revert "feat(messaging): message campaign delivery stats + views" (#22452) (#22627)
Revert "feat(messaging): message campaign delivery stats + views
(#22452)"

This reverts commit 2e1117d442.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22627?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 14:17:13 +02:00
Etienne 54aa52d11c feat(index): support composite unique indexes in create-many upsert conflict resolution (#22604)
## Context

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

## What changed

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

## Tests

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

## Test plan

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

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22604?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 13:54:33 +02:00
github-actions[bot] b6e74004d2 i18n - docs translations (#22624)
Created by Github action

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

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-07 13:34:56 +02:00
Paul Rastoin 6966af735b chore(apps): patch bump public apps moved to sdk 2.19.0-alpha.1 (#22623)
## Summary

Bumps the `version` field (patch) of the public apps that were moved to
`twenty-sdk@2.19.0-alpha.1` in #22601. That PR intentionally left
`version` untouched ("they'll be bumped at publish time") — this is that
follow-up bump.

| App | Before | After |
|---|---|---|
| `@twentyhq/call-recorder` | 1.0.6 | 1.0.7 |
| `@twentyhq/people-data-labs` | 1.0.3 | 1.0.4 |
| `@twentyhq/last-contact` | 1.0.1 | 1.0.2 |

## Notes

- `twenty-partners`, `postcard` and `self-hosting` are intentionally
**not** bumped.
2026-07-07 13:16:11 +02:00
Paul Rastoin fe442fe5fe chore(apps): bump sdk to 2.19.0-alpha.1 and require twenty server >=2.19.0 (#22601)
## Summary

- Bumps `twenty-sdk` / `twenty-client-sdk` to the exact `2.19.0-alpha.1`
prerelease for the apps under `packages/twenty-apps` that actually
target a mutated standard identifier, and refreshes their lockfiles.
- Declares `"engines": { "twenty": ">=2.19.0" }` in those apps so
pre-2.19 servers refuse to install or upgrade to the rebuilt packages.

Only apps that reference a standard object's **system-field** universal
identifier, define a **relation into** a standard object, or call the
field-UID derivation helper need 2.19 (the identifiers those touch
changed from hardcoded UUIDs to deterministic hashes). Apps that only
define their own custom objects, or add plain scalar fields to a
standard object via its stable object-level id, were left on their prior
SDK pins. Currently bumped: `postcard`, `self-hosting`,
`twenty-partners`, `call-recorder`, `people-data-labs`,
`twenty-last-contact`.

## Context

Follow-up to #22565 (deterministic system field universal identifiers)
and #22599 (SDK prerelease bump).

Packages built with SDK ≤ 2.18 carry legacy system field identifiers and
are rejected by servers running `main`. Rebuilding with the 2.19 SDK
fixes that — but a rebuilt package must not be *upgraded into* by a 2.18
server, since 2.18 has no deterministic-identifier validation and would
diff the changed system field identifiers as a destructive delete +
create (the 2.19 backfill has not run there yet).

The `engines.twenty` constraint closes that gap: `doInstallApplication`
validates it via `validateServerCompatibility` before any mutation, and
this check has shipped since ~2.10, so every 2.18 server enforces it.
Resulting matrix:

- 2.18 fresh install of a rebuilt app: works, converges as a no-op once
the 2.19 backfill runs
- 2.18 upgrade of an existing install: rejected with
`SERVER_VERSION_INCOMPATIBLE` before any mutation
- 2.19 (post-backfill) install/upgrade: syncs cleanly

## Expected CI failures

**The `CI Twenty Apps` integration-test jobs are expected to fail on
this PR** (e.g. `people-data-labs`, `twenty-partners`). This is a
server-version mismatch, not an app bug — lint, typecheck and unit tests
all pass:

- The integration step spawns a real Twenty server from Docker Hub
`twentycrm/twenty-app-dev:latest` and runs `twenty dev` to sync each
app's metadata into it.
- `latest` currently resolves to **v2.18.5** — no `2.19` image is
published to Docker Hub yet.
- These apps now reference 2.19's **deterministic system-field universal
identifiers** (e.g. `company.createdBy`, `opportunity.createdAt`). A
2.18 server still carries the legacy identifiers, so the sync rejects
every 2.19-derived reference with `INVALID_VIEW_DATA` /
`FIELD_METADATA_NOT_FOUND` ("Field metadata not found").
- The failure surfaces as low-level field errors rather than a clean
`SERVER_VERSION_INCOMPATIBLE` because the `engines.twenty` gate
(`validateServerCompatibility`) only runs on the `app:install` / publish
paths — **not** on the `twenty dev` dev-sync path the integration tests
use.

These jobs will go green automatically once `twenty-app-dev:2.19` is
published to Docker Hub (or once CI pins the spawn action's
`twenty-version` to a 2.19 tag).

## Intentionally not included

- App `version` fields are untouched; they'll be bumped at publish time.

## Test plan

- [x] Refresh each bumped app's `yarn.lock` (`2.19.0-alpha.1` is now on
npm)
- [ ] Rebuild one app manifest and verify default field identifiers
match `getFieldUniversalIdentifier`
- [ ] Verify a 2.18 server rejects an upgrade to a rebuilt package with
`SERVER_VERSION_INCOMPATIBLE`
- [ ] Re-run `CI Twenty Apps` integration jobs once a
`twenty-app-dev:2.19` image is available

Made with [Cursor](https://cursor.com)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22601?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-light.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 13:03:43 +02:00
Thomas Trompette 99f54d9ea8 fix(front): honor user-set label on LINKS/URL social links (#22586)
## Problem

On LINKS and URL fields, recognized social links **always** rendered the
derived handle (e.g. `@cristiano`) and ignored any user-set `label`.
This was a regression: `SocialLink` did `getDisplayValueByUrlType(...)
?? label`, and since a provider always matches for social links, `label`
was never reached. Adding the Instagram/TikTok/Bluesky providers in
v2.16 widened the set of affected links.

| Input | Expected | Before |
|-------|----------|--------|
| `instagram.com/cristiano`, label `Cristiano Ronaldo Official` |
`Cristiano Ronaldo Official` | `@cristiano` |

## Fix (display half of #22265)

- `SocialLink`: prefer a non-empty `label`; only derive the handle from
the URL as fallback (then `href`). Prop widened to `string \| null`.
- `LinksDisplay` / `LinkDisplay` / `URLDisplay`: pass the **raw,
nullable** label into `SocialLink` instead of a pre-coalesced string, so
derivation still works when no label is set. `URLDisplay` passes
`label={null}` (URL fields have no label) so handles still render.
- Stories: dropped `label` args the old code silently ignored (keeps
existing visual snapshots stable) and added a `WithCustomLabel` story
asserting precedence.

## What's left (not in this PR)

The **label input in the UI** (issue's second half) is intentionally
deferred. `MultiItemFieldInput` carries the in-progress edit as a single
string and seeds edits with the URL only, so exposing a Label field
cleanly requires a small generalization of that shared component (not a
JSON-serialization workaround). That change needs manual in-app
verification and will be a follow-up.

## Verification

- `twenty-ui` typecheck clean; oxlint clean on all changed files;
`getDisplayValueByUrlType` tests pass (38).
- Added Storybook `play` assertion for the custom-label case.

Fixes #22265 (display half). Related: #16414.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22586?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 13:03:29 +02:00
Raphaël Bosi 3bacf7a24b Widen front component crossing attributes to aria-*, data-* and draggable (#22614)
Only a closed allow-list of props crossed the front-component
worker→host boundary (`id, className, style, title, tabIndex, role,
aria-label, aria-hidden, data-testid`), so arbitrary `aria-*`/`data-*`
attributes and `draggable` never reached the host DOM. That breaks
headless UI libraries (Radix, cmdk, react-aria) that drive styling/state
through those attributes.

This widens the crossing set to all `aria-*`, all `data-*`, and
`draggable`:
- `draggable` becomes an enumerated remote property (it's a DOM IDL
property React may set as a property, bypassing `setAttribute`, so it
can't ride the prefix path).
- Arbitrary `aria-*`/`data-*` are forwarded in the worker by patching
`setAttribute`/`removeAttribute` through remote-dom's attribute channel,
only for names not already synced as observed attributes.

Security: only inert `aria-*`/`data-*`/`draggable` cross, and they still
route through the host `filterProps` guards (non-function `on*` dropped,
`javascript:` URLs denied) — nothing bypasses them. The enumerated
`aria-label`/`aria-hidden`/`data-testid` keep their existing path.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22614?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 12:42:03 +02:00
martmull 46d281f0cb Update claude session settings for a proper PR behavior (#22619)
required so it is taken into account by claude in Cloud claude sessions

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22619?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 12:40:17 +02:00
Abdul Rahman 81cfcecdc7 chore(server): migrate 5 modules off NestjsQueryTypeOrmModule wiring (#22595)
## Summary
Continues the incremental removal of `@ptc-org/nestjs-query`. Migrates
five core modules from `NestjsQueryTypeOrmModule.forFeature` to the
standard `TypeOrmModule.forFeature`. These modules only used
`nestjs-query` for repository registration — their resolvers are
hand-written and registered as normal providers — so this is a pure
module-wiring swap with no behavior or schema change.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22595?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 12:39:59 +02:00
Abdul Rahman f90137b536 chore(server): remove nestjs-query from user-workspace module (#22591)
## Summary
Continues the incremental removal of `@ptc-org/nestjs-query`. Migrates
the `user-workspace` module to plain NestJS/TypeORM. This module
registered no resolvers, so `nestjs-query` was only acting as module
wiring and providing the service's inherited query methods — no public
API behavior depended on it.

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


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22591?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 12:37:31 +02:00
Paul Rastoin 628ab153a8 App installation workspace version check engines constraint (#22613)
## What

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

## Why

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

## How

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

## Notes

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

## Testing

- Unit tests for `ApplicationVersionValidationService`
(`validateServerCompatibility` + new `validateWorkspaceCompatibility`)
covering: no requirement, invalid semver range, satisfied/unsatisfied
ranges, and the uninterpretable-cursor case.
- Unit tests for `UpgradeStatusService.getWorkspaceCompletedVersion`
against a three-segment mock upgrade sequence (multi-command version,
instance-only version, workspace-terminated version).
- New integration suite
`failing-app-installation-workspace-version.integration-spec.ts` (+
snapshots) exercising the real install flow: rejects installation when
the workspace hasn't completed the required version, and when the
workspace's upgrade cursor can't be interpreted. Adds a
`create-app-tarball.util.ts` test helper.
2026-07-07 10:05:06 +00:00
nitin cabe5545ae Use SDK calendarEventRecordPageFields identifiers in call-recorder (#22618)
Replaces the hardcoded calendarEventRecordPageFields view/group
identifiers in the call-recorder preference view-field with
`STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.views.calendarEventRecordPageFields`,
resolving the TODO. The published `twenty-sdk@2.18.0` (already pinned by
the app) ships these identifiers with values matching the previously
hardcoded ones.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22618?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 15:32:18 +05:30
github-actions[bot] 18ca89bcdd i18n - docs translations (#22617)
Created by Github action

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

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-07 11:54:54 +02:00
martmull 07a921f8ca Add Document Generator SDK app + step-by-step tutorial (#22522)
## What & why

This adds a **guided tutorial** that teaches the Twenty SDK by building
one real, useful app end to end — plus the finished app itself, ready
for the marketplace.

The app, **Document Generator**, turns reusable templates into
personalized documents using CRM data: write a template once with
`{{placeholders}}`, then generate a filled-in document for any Person or
Company from the command menu, an AI agent, or a workflow.

## Two parts

**1. The app — `packages/twenty-apps/public/document-generator`**

Each capability maps to one tutorial chapter:
- **Data:** `documentTemplate` + `document` objects, fields, and a
bidirectional relation
- **Logic:** a single `generate-document` handler exposed as an **AI
tool**, a **workflow action**, and an **HTTP POST route**; plus a public
**HTML view route**
- **UI:** two views + sidebar navigation, a **command-menu item** (on
Person selection) that opens a **React front component**
- **AI:** an agent + skill; a default application role; marketplace
metadata + logo
- **Tests:** unit tests for the template renderer + an install
integration test

**2. The tutorial —
`packages/twenty-docs/.../apps/tutorials/document-generator/`**

A six-chapter series under **Developers › Apps › Tutorial** (Overview →
Data model → Generating documents → HTTP routes → Building the UI → AI
agent → Publishing). Minimal prose, paste-ready code, inline links to
the matching reference pages, and real screenshots. Registers a new
"Tutorial" nav group and regenerates `docs.json` + the navigation
template.

## Verification

Validated against a running Twenty instance (`twenty-app-dev` on
`:2020`):
- `twenty dev --once` installs cleanly (28 metadata objects created)
- Generated a real document from a Person — placeholders resolved (name,
job title, `company.name`, email), zero missing tokens
- Command menu → front component → generate flow works in the UI
- Public HTML view route renders the document
- App gates green: `yarn lint` (0/0), `yarn typecheck`, `yarn test:unit`
(7/7)

All screenshots in the tutorial are captured from this run.

## Notes
- Left out per-app CI workflows (`.github/workflows`) to keep scope
tight — happy to add them if wanted.

https://claude.ai/code/session_012FoC76zPbPmpgtN7MXMPwy

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

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-07 11:13:34 +02:00
martmull 2a495c3477 feat(app): allow claiming ownership of unclaimed app registrations (#22609)
## After

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


## Context

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

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

## Behaviour

Admin panel → app registration detail → General tab:

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

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

## Changes

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

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

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

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

https://claude.ai/code/session_01U7rbxhBSUQRWBbdP5TmAgZ
2026-07-07 11:07:21 +02:00
Paul Rastoin 5dc9d7ab36 fix(server): make all view children reparentable across a workspace migration sync (#22600)
## Summary

Uniformizes the workspace migration engine so **every** view child
entity — `viewField`, `viewFieldGroup`, `viewGroup`, `viewFilter`,
`viewSort`, `viewFilterGroup` — can be reparented from one view to
another within a single manifest sync, including when the previous
parent view is deleted in the same sync.

### Context

When an app manifest deletes a view and reparents its children onto
another view in the same sync (e.g. replacing a custom `FIELDS_WIDGET`
view with a standard one), the sync failed with a builder validation
error `View field to update parent view not found`. Root causes:

1. `viewField`, `viewFieldGroup` and `viewGroup` had `viewId.toCompare:
false`, so the diff never detected the parent-view change and never
emitted a reparent update (the already-reparentable siblings
`viewFilter`/`viewSort`/`viewFilterGroup` had `toCompare: true`).
2. `validateFlatViewFieldGroupUpdate` resolved the *old* parent view (it
ignored the update patch), inconsistent with the other view-child
validators.
3. Once the builder no longer errors, the runner would fail silently:
`view.delete` ran **before** the child reparent updates, and `viewId` is
`onDelete: CASCADE`, so the old view's deletion cascade-deleted the
children before they could be reparented (silent data loss, since
`repository.update` on a missing row is a no-op).

### Changes

-
**`all-entity-properties-configuration-by-metadata-name.constant.ts`**:
set `viewId.toCompare: true` for `viewField`, `viewFieldGroup`,
`viewGroup`. Because `viewId` maps to `universalProperty:
'viewUniversalIdentifier'`, the diff compares **only**
`viewUniversalIdentifier` (never the raw FK). Snapshot updated
accordingly.
- **`flat-view-field-group-validator.service.ts`**: merge
`flatEntityUpdate` and resolve the **new** parent view, matching the
`viewField`/`viewGroup`/`viewSort` validators.
- **`compute-ordered-migration-actions.util.ts`**: move `view.delete` to
run **after** all view-child create/update actions so a child can be
reparented off a view that is being deleted in the same sync. Child
`delete → create → update` order is preserved (needed for `viewField`'s
partial-unique `(fieldMetadataId, viewId)`).
- **New integration test**
`successful-manifest-reparent-view-children.integration-spec.ts`
covering reparenting of every view child (a) between two persisting
views and (b) when the source view is deleted in the same sync.

## Test plan

- [x] `nx typecheck twenty-server`
- [x] oxlint + oxfmt on changed files
- [x] Unit snapshot regenerated:
`all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec`
- [x] New integration test passes (both scenarios)
- [x] Verified the delete-source scenario **fails** on the old action
ordering (children cascade-deleted, `Received length: 0`) and **passes**
after the reorder — confirming it's a genuine regression guard

Made with [Cursor](https://cursor.com)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22600?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 10:52:34 +02:00
Raphaël Bosi 1d3f6176b2 Use record pickers for People Data Labs enrichment workflow inputs (#22596)
Follow-up to #21494, which added record-typed logic function workflow
inputs but deferred the People Data Labs migration until the SDK
release.

twenty-sdk 2.16.0 (published) now includes the `record`/`records` input
schema support, so this types the enrichment inputs accordingly:
`records` on enrich-people/enrich-companies and `recordId` on
enrich-person/enrich-company render as record pickers bound to
Person/Company.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22596?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 10:41:40 +02:00
Raphaël Bosi c55bfab11e Fix side panel open/close animation glitches in the page header and panel content (#22598)
When the side panel opens or closes, the pinned header button (e.g. New
Company) was flat-clipped for the whole 300ms animation: the ⋮ toggle
mounts instantly and shifts the button's slot, framer's `layout` prop
compensates with a transform that the surrounding `overflow: hidden`
containers don't follow, and the ResizeObserver-driven rerenders re-seed
that transform every frame. Removing `layout` lets the button follow
plain reflow, which cannot clip.

The panel content also squeezed during the animation (labels
re-truncating at every intermediate width) because the inner panel was
`width: 100%` of the width-animating wrapper. Pinning it to
`var(--side-panel-width)` turns the animation into a rigid drawer slide;
drag-resize is unaffected since it writes the same CSS variable.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22598?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 10:40:26 +02:00
Raphaël Bosi d3b79320b1 Remove book a call step from onboarding (#22597)
The book a call screen was shown as a dedicated onboarding step after
sending team invites. It is no longer part of the flow: the
`BOOK_ONBOARDING` status, its pending user var, the
`skipBookOnboardingStep` mutation and the `BookCallDecision` screen are
removed, and onboarding completes right after the plan step.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22597?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 10:34:20 +02:00
martmull ee28ae363f feat(files): use direct-to-storage upload for email and AI-chat attachments (#22610)
## Context

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

## What this does

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

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

## Why these are safe to migrate

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

## Out of scope

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

## Tests

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

## Verification

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

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22610?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 10:26:04 +02:00
Paul Rastoin 9086b031e8 chore: bump sdk packages to 2.19.0-alpha.1 prerelease (#22599)
## Summary

- Bumps `twenty-sdk`, `twenty-client-sdk` and `create-twenty-app` from
`2.19.0` to `2.19.0-alpha.1` so the CD pipeline can publish a prerelease
of the SDK.

## Context

#22565 made system field universal identifiers deterministic and
fail-closed: any app package built with SDK ≤ 2.18 carries legacy system
field identifiers in its `manifest.json` and is now rejected at
install/sync time on servers running `main`. Rebuilding the apps
requires a published SDK carrying the new derivation.

Publishing `2.19.0-alpha.1` lets us rebuild all apps in
`packages/twenty-apps` (follow-up PR) against the new derivation while
keeping `latest` on `2.18.0` for authors targeting prod, which has not
run the 2.19 backfill yet.

⚠️ The publish job must tag this prerelease under a non-`latest`
dist-tag (e.g. `next`): the server resolves app installs and the upgrade
version check against the `latest` dist-tag.

Server version constants (`TWENTY_CURRENT_VERSION`, etc.) are
intentionally untouched.

## Test plan

- [ ] Verify the three package versions are `2.19.0-alpha.1`
- [ ] Verify the publish workflow in the CD repo tags the release as
`next` (not `latest`)

Made with [Cursor](https://cursor.com)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22599?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 09:49:16 +02:00
Marie c8f7315c67 fix(server): converge app-sync isUnique diff for single-column unique constraints (#22592)
## Context

Fixes #22550. On app sync (`twenty dev`), a field backed by a
single-column unique constraint produces a **permanent, non-converging
`[isUnique] changed` field-metadata diff**: the change is reported, the
apply succeeds, and the identical change is reported again on the very
next sync.

## Root cause

Since the uniqueness-source-of-truth moved from a
`FieldMetadata.isUnique` column to `IndexMetadata` (@FelixMalfait's
#20846 / #20883), `field.isUnique` is a **derived** property. The two
sides of the app-sync diff derive it differently:

- **"from" side** (workspace cache,
`WorkspaceFlatFieldMetadataMapCacheService`) derives `isUnique` from
indexes via `computeUniqueFieldMetadataIdsFromIndexes`, which counted
**any** single-column `UNIQUE` index.
- **"to" side**
(`from-field-manifest-to-universal-flat-field-metadata.util.ts`) sets
`isUnique` from the field-level manifest flag (`fieldManifest.isUnique
?? false`).

For a field whose uniqueness is declared with `defineIndex({ isUnique:
true, fields: [oneField] })` (no field-level flag):
- "from" derives `true` (the custom unique index exists),
- "to" is `false` (no field-level flag),

so the diff emits a `fieldMetadata … [isUnique] changed` update forever.
The field-update runner drops `isUnique` before the SQL `UPDATE` (it has
no column), and the custom index persists, so the derived value never
changes — the loop cannot converge.

## Fix

Restrict the derivation in `computeUniqueFieldMetadataIdsFromIndexes` to
the field's **engine-owned backing constraint** — a `UNIQUE` index with
`isSystemSideEffect: true` — rather than any single-column unique index.

This makes `field.isUnique` mean the same thing on both sides:

| declaration | backing index (`isSystemSideEffect`) | "from" derived |
"to" flag | converges |
|---|---|---|---|---|
| field-level `isUnique: true` | side-effect handler generates it →
`true` | `true` | `true` |  |
| `defineIndex({ isUnique: true, fields:[x] })` | custom index → `false`
| `false` | `false` |  (index converges on its own) |

Standard objects and the create/update side-effect backing indexes are
all `isSystemSideEffect: true`
(`create-standard-index-flat-metadata.util.ts`,
`generate-deterministic-index-for-flat-field-metadata-or-throw.util.ts`),
so their fields keep `isUnique = true`. Only a user-declared custom
`defineIndex` unique index (`isSystemSideEffect: false`) is now excluded
— which is also what stops the create/update side-effect from generating
a **second, duplicate** backing index for a field the custom index
already covers (which would otherwise trip `DUPLICATE_UNIQUE_INDEX` on
first apply).

## Why this location (and not the manifest "to" side)

An earlier attempt derived `isUnique` on the manifest "to" side from the
built indexes. That breaks after #22295: `field.isUnique === true` is
the **trigger** for the `fieldUniqueBackingIndexOnCreate/Update`
side-effect handlers, so forcing it to derive from the compute-service
maps (which don't yet contain the not-yet-generated backing index) would
suppress the backing index for field-level unique fields. Narrowing the
shared "from" derivation keeps the side-effect trigger intact and makes
both sides symmetric in one place.

## For review — @FelixMalfait

This touches the uniqueness model you own in #20883 ("make IndexMetadata
the source of truth for uniqueness"), and interacts with the side-effect
engine from #22295. The semantic change is: **`field.isUnique` now
reflects only the field's backing constraint, not an arbitrary
user-declared single-column unique index.** A `defineIndex`-declared
single-column unique field now surfaces `isUnique: false` on the field
(the constraint is still enforced by the index). If instead you'd want
`defineIndex` single-column uniqueness to surface as `field.isUnique:
true`, the fix would need to live in the side-effect engine (dedupe the
backing index against the declared one) rather than the derivation —
happy to take it that direction. Flagging for your call before this
leaves draft.

## Related

- Issue #22550
- @FelixMalfait #20883, #20846 (IndexMetadata as source of truth for
uniqueness)
- #22295 (centralized side-effect engine — unique field backing index)
- #21383 (adjacent field-`isUnique` handling)

https://claude.ai/code/session_01T1Cqvt5tHS6tZ1FeQQWyRo
2026-07-07 09:34:47 +02:00
neo773 2e1117d442 feat(messaging): message campaign delivery stats + views (#22452)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22452?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 04:47:52 +02:00
Marie 8a4bcd1445 (Billing for self hosts) Tie enterprise key to server (#22464)
# Enterprise key: bind to a server, free dev instances, self-serve
transfer, shorter license

## Summary

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

## Product behavior

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

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

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

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

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

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

## What changes for existing self-hosted customers

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

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

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

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

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

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

## Technical implementation (brief)

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

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

Makes Twenty's **MCP server** and **REST/GraphQL APIs**
auto-discoverable by catalogs (e.g. integrations.sh) and AI agents,
using vendor-neutral open standards rather than a proprietary manifest.

The tricky part is that Twenty is **multi-tenant and the REST OpenAPI is
generated per workspace** (it reflects each workspace's custom objects,
and with no token even the base schema is empty). So there is no single
public URL that describes the full API contract. This PR solves that
with two complementary layers.

## 1. Static standards on `twenty.com` (`twenty-website`)

The brand-level catalog entry, using `{your-workspace-url}` placeholders
since `twenty.com` is not a workspace host:

- `public/.well-known/mcp/server-card.json` — MCP Server Card (SEP-2127)
- `src/app/.well-known/api-catalog/route.ts` — RFC 9727 linkset (route
handler so the `application/linkset+json` content type survives the
global `nosniff` header)
- `public/llms.txt` — LLM-readable overview

## 2. Dynamic per-host serving from `twenty-server`

A new `well-known` core module serves the same documents built from the
**request host**, so every workspace subdomain, custom domain, and
self-hosted instance advertises its own **real, connectable** endpoints
(`https://{that-host}/mcp`, its live `/rest/open-api/core`, etc.) — no
placeholder:

- `GET /.well-known/mcp/server-card.json`
- `GET /.well-known/api-catalog`

Both are public + CORS + cached. The api-catalog's `service-desc` points
at each host's **live** per-workspace OpenAPI — the honest answer to
"it's generated per workspace" (real endpoint, real custom objects,
still token-gated). The `version` comes from `APP_VERSION`.

The two layers are complementary: the static one serves
catalog/marketing discovery at the brand domain; the dynamic one serves
connecting clients the real endpoints — which is where the MCP spec
expects the server card to live (same origin as `/mcp`).

## Refactor

Extracted the request→base-URL logic that `OAuthDiscoveryController` had
as a private method into a shared
`src/utils/get-request-base-url.util.ts`, now used by both it and the
new controller.

## Notes

- Docs URLs are sourced from the shared `DOCUMENTATION_BASE_URL`
(server) and the `SITE_URLS` registry (website) rather than hardcoded.
- MCP endpoint, transport (`streamable-http`), and protocol version
(`2025-06-18`) are read from the existing MCP constants.
- OAuth resource metadata (`/.well-known/oauth-protected-resource`)
already existed and is unchanged.

## Testing

- `twenty-server` unit tests for the builders and controller (host
derivation, version fallback, linkset shape) — passing.
- `nx typecheck twenty-server` — passing.
- `oxlint` + `oxfmt` clean on both packages; website `check-conventions`
OK.

https://claude.ai/code/session_01F6g7kefcfpjXSZjH6cwqhi

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22589?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 17:53:05 +02:00
Weiko 8580cd6f27 feat(ai): open Ask AI side panel with a preprompt in two modes (#22582)
Add the ability to open the Ask AI side panel pre-filled with a prompt
from any frontend component, with a mode to control whether the message
is sent automatically or left for the user to review.

- agentChatPrepromptState: holds the pending preprompt and its mode
(PREFILL = fill only, SEND = fill and auto-submit)
- useOpenAskAiPageWithPreprompt: seeds the new-thread draft, opens a
fresh Ask AI thread and stores the preprompt intent
- AgentChatPrepromptEffect: applies the intent once the chat editor and
send listener are mounted, either restoring the editor content or
dispatching the send event and clearing the editor

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22582?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:38:28 +00:00
Raphaël Bosi 62c7e8f6b4 Polish onboarding v2 verify animation and step screens (#22585)
https://github.com/user-attachments/assets/1d9b8dc2-ef01-4202-a97e-b41cab048f87





A few polish tweaks to onboarding v2:

- **Verify/workspace-creation animation:** emphasize the key phrase of
each message in medium weight, the rest regular (e.g. "Creating your
**workspace**…").
- **Wider content column:** 340px → 440px. Collapses to full width on
mobile via the existing `max-width: 100%` on every consumer.
- **Sticky disabled buttons:** step submit buttons now stay disabled
from submit through navigation instead of briefly re-enabling once the
mutation resolves.
- **Fewer pulse loaders:** stop the pulsing logo from flashing when
navigating between onboarding steps (removed the step-page Suspense
fallback loader). The verify animation, cold-boot gates, and sign-in
fallbacks are unchanged.

Note: the reworded activation messages get new Lingui catalog IDs, so
non-English locales fall back to English until catalogs are
re-extracted.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22585?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 14:44:44 +00:00
Paul Rastoin 6c40c7b91a Deterministic system field universal identifier (#22565)
# Introduction

Close twentyhq/core-team-issues#2641

Auto-provisioned field metadata used to get its `universalIdentifier`
from three unrelated sources: random `v4()` on the server when creating
custom objects, hardcoded values in `STANDARD_OBJECTS`, and an ad-hoc
`v5` derivation in the SDK manifest build. This PR unifies all of them
behind the shared `getFieldUniversalIdentifier` derivation:

```
universalIdentifier = f(applicationUniversalIdentifier, objectUniversalIdentifier, fieldName)
```

## Ownership model

The rollout is built on an explicit split of who owns a field's
universal identifier:

- **The 8 system fields** (`id`, `createdAt`, `updatedAt`, `deletedAt`,
`createdBy`, `updatedBy`, `position`, `searchVector`) are
**server-owned**. Their universal identifiers are always the
deterministic derivation, on **every** application (standard,
workspace-custom, installed). Clients cannot provide custom values: a
temporary check in `validateObjectMetadataSystemFieldsIntegrity` rejects
any non-derived system field identifier at migration build time. This
check stands in until system fields are generated exclusively server
side by the metadata side-effect engine and stripped from client inputs
— at which point it becomes structurally impossible to send one.
- **`name` is a default field, not a system field**: it is
auto-provisioned when absent (server side for custom objects, SDK side
for application objects) but authors can define their own. It is only
derived where it is guaranteed to be auto-provisioned. In particular,
standard objects keep their **historical hardcoded** `name` identifiers:
the standard app authors its `name` fields like any installed app would,
and moving those identifiers would break every installed application
referencing them (e.g. views on `opportunity.name`).
- **User-created and author-provided fields** keep random / explicit
identifiers, untouched.

## Server

- `validateObjectMetadataSystemFieldsIntegrity` now validates, on top of
the existing type/`isSystem` checks, that each system field's
`universalIdentifier` equals the deterministic derivation. Runs for
every object creation going through the migration orchestrator: app
sync, custom object creation, standard provisioning
- `build-default-flat-field-metadatas-for-custom-object.util.ts` derives
the system field identifiers (and the auto-provisioned `name`) with
`getFieldUniversalIdentifier` instead of `v4()`
-
`build-default-relation-flat-field-metadatas-for-custom-object.util.ts`
derives both the forward and the reverse default relation field
identifiers deterministically
- `generateMorphOrRelationFlatFieldMetadataPair` accepts optional
`sourceFieldUniversalIdentifier` / `targetFieldUniversalIdentifier` so
callers can inject deterministic values; user-created relations still
default to `v4()`

## twenty-shared

- `STANDARD_OBJECTS` system field identifiers (the 8) are now computed
at module load via `buildStandardObjectSystemFields`; `name` and every
other identifier keep their hardcoded values
- New snapshot test pinning **every** universal identifier of
`STANDARD_OBJECTS`: any identifier change now requires an explicit
snapshot update and should ship with a coordinated backfill

## SDK (breaking, pre-GA)

- `generateDefaultFieldUniversalIdentifier` delegates to
`getFieldUniversalIdentifier` and now requires
`applicationUniversalIdentifier`
- Reverse default relation field identifiers are derived from the
field's real coordinates (standard object UID + actual field name, e.g.
`targetRocket` on `attachment`) instead of the legacy custom-object UID
+ synthetic `${fieldName}Inverse` hash input. Field *names* are
unchanged
- The manifest build threads the application universal identifier
through default field injection (two-pass over object configs)
- `twenty dev:add` now resolves the application universal identifier
upfront and refuses to scaffold anything until `defineApplication`
declares one — no more `fill-later` placeholder for the app UID in
generated files

## Upgrade

A 2.19 **workspace command** backfills existing
`fieldMetadata.universalIdentifier` rows to the deterministic
derivation. Coverage follows the ownership model:

- **The 8 system fields**: taken over for **every application**,
whatever value they currently hold. This is both safe and required now
that sync rejects non-derived values — leaving a row unconverged would
make its application unsyncable
- **`name`**: workspace-custom app → always taken over
(server-generated, no author to clobber); installed applications → only
rows still carrying the legacy SDK derivation are recomputed,
author-provided identifiers are never touched; standard app → never
touched (hardcoded in `STANDARD_OBJECTS`)
- **Default relation fields**: workspace-custom app → forward fields on
custom objects and reverse fields on the standard relation objects;
installed applications → legacy-derivation probe only

All identifiers of a workspace are updated inside a single transaction,
then the command flushes the field-metadata-related workspace caches and
bumps the metadata version.

Stored `applicationRegistration.manifest` snapshots are intentionally
**not** rewritten: installs and upgrades always sync from the
`manifest.json` inside the resolved package (npm/tarball), the stored
column is only used for display/marketplace purposes.

## Breaking behavior for old packages (fail closed)

Packages built with an older SDK carry legacy system field identifiers
in their tarball `manifest.json`. Installing or upgrading such a package
now fails with an explicit `INVALID_SYSTEM_FIELD` validation error
("universal identifier is not deterministic") instead of silently
mismatching against the backfilled rows and triggering a destructive
delete+create. The remediation is to rebuild the package with the new
SDK; the backfill has already converged the installed rows, so the
rebuilt manifest syncs cleanly.

## Test plan

- [x] `twenty-sdk` unit tests (526 tests) and typecheck
- [x] `twenty-shared` unit tests (1635 tests) including the
`STANDARD_OBJECTS` snapshot; `name` identifiers verified byte-for-byte
identical to `main`
- [x] Lint and typecheck clean on all touched packages
- [x] Integration: create a custom object and verify system + default
relation field identifiers match the deterministic derivation
(`create-one-object-metadata-deterministic-field-universal-identifiers`,
13 assertions passing)
- [x] Integration: `failing-sync-application-object-system-fields`
extended with a non-derived system field identifier case; all
identifiers in the spec pinned deterministically so snapshots embedding
expected/actual values are stable across runs (verified with a double
run)
- [x] Integration: all application sync suites pass with the derived
system field identifiers now required by the
`buildDefaultObjectManifest` test helper (9 suites, 20 tests)
- [x] Full test-database reset: standard app provisioning and seeded
workspaces pass the new validation
- [x] SDK manifest build verified on the postcard example app: all
auto-generated default field identifiers match the derivation
- [ ] Run
`upgrade:2-19:backfill-deterministic-field-universal-identifiers`
(dry-run then real) on a seeded workspace and verify identifier
convergence with a rebuilt app manifest
2026-07-06 13:34:33 +00: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
nitin 9657c59272 Inject functions URL into logic function env (#22583)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22583?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:18:07 +02:00
Raphaël Bosi 29920738dc Fix stale token race forcing re-login on verify pages (#22573)
Landing on /verify often forced users to refresh and log in again. The
culprit is a logout side effect triggered by a stale token: when a
previous session's token pair is still in localStorage, boot queries use
it, fail, and the failed token renewal reacts by logging the user out
(onUnauthenticatedError clears the token pair). That logout fires while
the loginToken exchange is running, so it can wipe the fresh session
that was just stored.

Fix: clear the stale token pair right before exchanging the loginToken
(in useVerifyLogin, so both /verify and /verify-email are covered) —
with no stale token to renew, the logout side effect never fires against
the new session. Also removes the redundant clientConfig gate on the
verify effect, stops that same logout side effect from redirecting users
off /verify-email mid-verification, and always re-enables app redirects
after loading the user.

Note: opening a loginToken link now replaces an existing valid session
instead of keeping it.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22573?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 14:58:32 +02:00
martmull 2327ae7122 Revert "feat(server): add instance-level file storage layer" (#22579)
Reverts twentyhq/twenty#22560

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22579?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 11:47:45 +00:00
Paul Rastoin 22b49a502c chore(server): remove unused flat-field-metadata per-object mocks (#22581)
## Context

The `flat-field-metadata/__mocks__/` directory contained 11 large
per-object `as const` mock catalogs (`OPPORTUNITY_FLAT_FIELDS_MOCK`,
`PERSON_FLAT_FIELDS_MOCK`, `PET_FLAT_FIELDS_MOCK`, ...) plus a
`getRelationTargetFlatFieldMetadataMock` helper. An audit of the whole
server package (searching both the constant names and any import of the
directory) showed almost none of them are consumed anymore — tests have
moved to building exactly the fields they need with the
`getFlatFieldMetadataMock` factory.

Usage found:
- `getFlatFieldMetadataMock` (factory): ~25 spec files + 2 core-modules
mocks — **kept**
- `COMPANY_FLAT_FIELDS_MOCK`: 1 spec
(`object-record-event-publisher.spec.ts`), which only used the `name`
field
- The other 10 `*_FLAT_FIELDS_MOCK` catalogs and
`getRelationTargetFlatFieldMetadataMock`: **zero consumers**

## Changes

- Delete the 11 unused `*-flat-fields.mock.ts` catalogs and
`get-morph-or-relation-target-flat-field-metadata-mock.ts` (~4,900
lines). Only `get-flat-field-metadata.mock.ts` remains.
- In `object-record-event-publisher.spec.ts`, build the company `name`
field inline with `getFlatFieldMetadataMock` (wired to
`COMPANY_FLAT_OBJECT_MOCK.id`/`workspaceId`) and replace the three
`COMPANY_FLAT_FIELDS_MOCK.name.type` references with
`FieldMetadataType.TEXT`.

The sibling `flat-object-metadata/__mocks__/` catalogs are untouched —
several of those are still consumed by the morph/relation specs.

## Verification

- `object-record-event-publisher.spec.ts`: 27/27 passing
- `npx nx lint:diff-with-main twenty-server`: green
- `npx nx typecheck twenty-server`: green

https://claude.ai/code/session_01XcGEtwdXQo9uJibGRPexuG

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22581?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 13:47:17 +02:00
Thomas Trompette fb0a54c73a fix(server): pace lambda control-plane calls to avoid 'Rate exceeded' on release (#22569)
## Problem

Logic functions intermittently fail with:

```
Lambda invocation failed for function '<id>' during build: Rate exceeded
```

`Rate exceeded` is AWS Lambda's control-plane throttling
(`TooManyRequestsException`), thrown during the **build** phase — before
invoke — inside `buildExecutor`.

### Why it spikes on release
A build is skipped (`canSkip = true`, zero control-plane calls) unless
the executor is missing/inactive **or**
`flatApplication.isSdkLayerStale` is true. `isSdkLayerStale` is flipped
to `true` for the **whole application at once** whenever the SDK client
regenerates (app install / development / schema change).

So on release, every logic function in the app goes stale simultaneously
→ each enters `ensureExecutor` in its own per-function lock → a burst of
`Create`/`Update`/`PublishLayer`/`GetFunction` calls across many
functions at once → the low, account-region-wide control-plane quota is
exceeded → `Rate exceeded`. Between releases everything is warm and no
control-plane calls happen — hence "spikes on release, silent
otherwise".

The Lambda client was created with no retry override, so it used the SDK
default (`standard` mode, `maxAttempts = 3`): a few retries with
backoff, but no client-side pacing.

## Change

Configure the shared Lambda client with:
- `retryMode: 'adaptive'` — adds a client-side token-bucket rate limiter
that slows outgoing requests when it sees throttling, instead of
fire-then-backoff.
- `maxAttempts: 8` — rides out the burst.

Applied after the options spread so it always takes effect, and covers
**every** control-plane call including the
`waitUntilFunctionActive/UpdatedV2` pollers (same client).

## Scope / follow-up

This is the cheap, high-leverage mitigation and dampens the burst per
process. It does **not** add a cross-function/cross-pod concurrency cap,
so a large enough release across multiple replicas could still exceed
the account quota. A follow-up could add a limiter (in-process
semaphore, or a distributed token bucket via the existing Redis
cache-lock) around `ensureExecutor`.

## Testing

- `tsc --noEmit` on twenty-server: clean.
- Not runtime-tested — AWS control-plane throttling can't be reproduced
locally. Worth confirming against a real release-time CloudWatch window
after deploy.

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

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

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

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

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

## Next PRs in the plan

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

## Verification

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

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22560?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 12:23:42 +02:00
Raphaël Bosi 11edd56505 Don't list headless front components in the widget picker (#22578)
Front components can be marked headless (`isHeadless: true`), meaning
they render no UI and only run logic. Both the record-page and dashboard
widget pickers were listing every front component, including headless
ones, which have nothing to render as a widget.

This filters out headless front components where each picker reads them
from `FIND_MANY_FRONT_COMPONENTS`. The downstream select-item mapping,
keyboard-navigation list, and the "Front Components" group guard all
derive from that array, so filtering once excludes them everywhere and
hides the section when every front component is headless.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22578?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 12:20:05 +02:00
Abdul Rahman faaeeee6f2 refactor(server): remove nestjs-query from key-value-pair module (#22575)
## Summary

First step toward removing `@ptc-org/nestjs-query` from the codebase
(follow-up to the `indexFieldMetadatas` DI bug
[discussion](https://github.com/twentyhq/twenty/pull/22439#issuecomment-4864265452)).

The `key-value-pair` module wrapped its entity in
`NestjsQueryGraphQLModule.forFeature`, but registered **no resolvers** —
the `KeyValuePair` type is exposed in no GraphQL schema, and
`KeyValuePairService` only uses a plain TypeORM repository. The
nestjs-query layer was doing nothing except registering that repository
as a side effect.

## Changes

- Replace the empty `NestjsQueryGraphQLModule.forFeature({...})` wrapper
with a plain
  `TypeOrmModule.forFeature([KeyValuePairEntity])`
- Swap the entity's `@IDField` (nestjs-query) for the standard `@Field`
from `@nestjs/graphql`

`nestjs-query` is no longer referenced anywhere under `key-value-pair/`.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22575?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 12:19:24 +02:00
Raphaël Bosi 4cd05b2b3e Reverse pinned command menu item order on record header (#22577)
## Before
<img width="502" height="102" alt="CleanShot 2026-07-06 at 12 10 42@2x"
src="https://github.com/user-attachments/assets/da186911-5a1f-446f-a590-1af4a191554f"
/>

## After
<img width="500" height="102" alt="CleanShot 2026-07-06 at 12 10 17@2x"
src="https://github.com/user-attachments/assets/6e7bb914-55af-4968-a15a-fb17378fffc7"
/>

Pinned command menu items in the record page header rendered
left-to-right by position, putting the first item on the left. They
should read the other way: first item on the right, last on the left.

Fixed with `flex-direction: row-reverse` on the items container so the
reversal is purely visual. The DOM/source order stays in position order,
so the responsive overflow logic still keeps the highest-priority items
visible and keyboard/screen-reader order is unaffected.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22577?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 12:17:30 +02:00
martmull e5e3fadbbb feat(files): reap stale pending direct-upload files via hourly cron (#22531)
## Context

Follow-up to #22449 (direct-to-storage upload endpoints). That PR
introduced the `PENDING` → `UPLOADED` file lifecycle: `createFileUpload`
inserts a file record in `PENDING`, the client uploads the bytes
directly to storage, then `completeFileUpload` flips it to `UPLOADED`.

A client that initiates an upload but never confirms — a crash, a closed
tab, an expired presigned URL — leaves a `PENDING` file record and a
possibly-partial storage object behind forever. This PR reaps them.

## What this does

Adds an hourly cron that hard-deletes `PENDING` files older than 24h
together with their storage objects, in bounded batches.

- **`PendingFileCleanupService`** — finds `PENDING` files with
`createdAt` older than `PENDING_FILE_MAX_AGE_MS` (24h), capped at
`PENDING_FILE_CLEANUP_BATCH_SIZE` (200) per run, and deletes each via
`FileStorageService.deleteByFileId` (which tolerates a missing object).
A failure on one file is logged and skipped so the rest of the batch
still gets cleaned.
- **`PendingFileCleanupCronJob`** — `@Processor(cronQueue)` job that
runs the service and reports exceptions.
- **`PendingFileCleanupCronCommand`** — registers the job on the hourly
pattern (`0 * * * *`).
- Wired into `FileUploadModule` (providers + export) and registered in
`cron:register:all`.

### Why 24h

The reaper threshold sits well past the presigned URL expiry, so a
`PENDING` file only becomes reapable long after any legitimate in-flight
upload could still complete — the cleanup can never race a real upload.
A file that was never confirmed is referenced by nothing; the client
recovery path is simply re-uploading under a fresh `fileId`, so we never
promote to `UPLOADED`.

## Tests

`pending-file-cleanup.service.spec.ts` covers: the query shape (status +
age threshold + batch cap), deleting each stale file and returning the
count, continuing past a per-file deletion failure, and the empty-batch
no-op.

## Scope

Server-only, non-breaking, no user-facing change. Part of the
incremental direct-upload rollout being split into small PRs.

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/22531?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 08:39:32 +00:00
martmull d6b6962604 feat(files): content-verify direct uploads and pin pending files to octet-stream (#22533)
## Context

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

## What this does

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

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

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

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

## Tests

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

## Verification

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

## Scope

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

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22533?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 08:12:30 +00:00
github-actions[bot] e23f82f700 chore: sync AI model catalog from models.dev (#22568)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.

**Please review before merging** — verify no critical models were
incorrectly deprecated.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22568?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: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-07-06 09:25:31 +02:00
nitin fe8ec0463a Call recorder: parse conference links from calendar event text (#22555) 2026-07-05 17:44:13 +02:00
Matt Van Horn 3cd4498bb2 fix: convert Microsoft calendar event HTML body to plain text description (#22540)
## Summary
Calendar events synced from Microsoft accounts now show a readable
plain-text description instead of raw HTML. Microsoft Graph returns
event bodies as HTML by default; the importer stored
`event.body.content` verbatim, so descriptions rendered as markup soup
in the record page and the event drawer.

## Why this matters
Issue #22537 reports Microsoft-synced calendar events displaying full
`<html><body>...` content in the description field. Google Calendar
events don't have this problem because Google returns plain text. The
fix converts HTML bodies with the `html-to-text` package that's already
a `twenty-server` dependency (used the same way in
`packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/utils/format-text-body.utils.ts`
for email bodies), then normalizes line endings, non-breaking spaces,
and blank-line runs. Non-HTML bodies pass through unchanged, and the
existing `sanitizeCalendarEvent` step still runs afterwards.

## Testing
Added specs to `format-microsoft-calendar-event.util.spec.ts` covering
HTML-to-text conversion with `<br>` and block elements, HTML entity
decoding and `&nbsp;` handling, empty/null bodies, and text-body
passthrough.

Fixes #22537


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22540?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: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: neo773 <neo773@protonmail.com>
2026-07-05 19:23:05 +05:30
Félix Malfait 3a21089e0a feat(server): abort ai stream jobs that outlive the shutdown drain budget (#22517)
## Why

#22514 makes SIGTERM drain workers, but `worker.close()` waits for
active jobs with **no upper bound** (BullMQ semantics). An AI stream job
can run for 10 minutes; a deploy would either hang the rollout or hit
the pod's termination grace deadline and get SIGKILLed anyway — back to
the frozen-stream + stalled-rerun failure this series eliminates.

## What

On shutdown, `aiStreamQueue` gets a bounded drain: active stream jobs
have `AI_STREAM_SHUTDOWN_DRAIN_MS` (60s) to finish naturally; stragglers
are then aborted and terminate exactly like a stream failure —
`lastStreamError` persisted with the new typed
`AiExceptionCode.STREAM_INTERRUPTED`, the pinned `stream-error` →
`queue-updated` terminal sequence published, claim released. The client
shows the interrupted state with Retry (#22434) within seconds instead
of a stream frozen mid-sentence. This is deliberately **not** the
user-cancel path, which resolves cleanly and persists no error.

Mechanism — evaluated BullMQ 5.78's native cancellation vs a parallel
in-process registry, and picked native:

- The driver's processor now declares the 3-arg signature, which makes
BullMQ create a per-job `AbortController` (`processor.length >= 3` is
the trigger), and the signal is handed to job handlers as an optional
`MessageQueueJobContext`.
- `worker.cancelAllJobs()` is purely cooperative: it aborts the signal
and nothing else, so the job's own persist/publish/cleanup still runs to
completion and `worker.close()` still waits for it — no force-fail race,
no second signaling channel to maintain, and the timer lives inside the
same `closeWorker()` call so there is no dependence on Nest
module-destroy ordering.
- The stream job maps the shutdown signal onto its **existing**
AbortController (the one already wired through the AI SDK for user
cancel), with an `AiException(STREAM_INTERRUPTED)` reason to tell the
two apart. One abort path end to end, no new infrastructure.

Error-type choice: the job throws a plain `AiException`, not BullMQ's
`UnrecoverableError`. Stream jobs are enqueued with `attempts: 1` (no
`retryLimit`), so there is no BullMQ retry to suppress — retryability
for this queue lives at the app layer (`lastStreamError` + client
Retry), and an `UnrecoverableError` would only obscure the typed
exception.

`STREAM_INTERRUPTED` also replaces the string constant introduced on the
base branch (#22482's reap now uses the same enum member) — one code,
two producers (reap for dead workers, abort for live shutdowns),
identical client behavior.

Notes:
- 60s is a static constant mirroring `AI_STREAM_LOCK_DURATION_MS` rather
than an env var — it has to move in lockstep with the worker's
`terminationGracePeriodSeconds` (120s, twenty-infra PR) anyway, and we
ship multiple releases a day. Happy to lift it into a config variable if
you want runtime tunability.
- The `onModuleDestroy` scaffolding (drain logs,
workers-close-before-queues) deliberately matches #22514; whichever
lands second rebases clean.

Stacked on #22482 (needs the heartbeat/reap base). Merge order: #22482 →
this. Depends on #22514 for SIGTERM to reach the driver at all.

## Validation

- `stream-agent-chat.job.spec.ts`: shutdown-abort persists
`STREAM_INTERRUPTED`, publishes `stream-error` before `queue-updated`,
releases the claim, skips the queued-message flush; user-cancel
semantics unchanged with a wired-but-idle shutdown signal (60/60 ai-chat
tests green).
- Local end-to-end: real AI stream mid-flight, SIGTERM the worker →
drain window → abort → interrupted state persisted, process exits on its
own. (Transcript in the PR conversation.)


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22517?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-05 14:15:24 +02:00
neo773 904957ea1e message campaign redesign (#22508)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22508?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-05 13:44:34 +02:00
github-actions[bot] e90ab56c7a chore: sync AI model catalog from models.dev (#22558)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.

**Please review before merging** — verify no critical models were
incorrectly deprecated.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22558?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: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-07-05 09:03:02 +02:00
Thomas des Francs a4ed561e11 Add focus-safe side panel shortcuts (#22499)
## Summary

- Add side-panel-owned Escape and Backspace behavior for the side-panel
search input.
- Keep side-panel Escape scoped to side-panel focus and avoid
left-content fallback behavior.
- Add a Side Panel group to the keyboard shortcut menu.
- Reuse the side-panel focus id for AI chat thread-list shortcuts.


## Demo


https://github.com/user-attachments/assets/80a632d6-7ff7-496b-905f-a3f95f9cfc14

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-07-05 01:19:39 +02:00
neo773 f14e62ec0c Run integration tests against the real BullMQ driver (#22551)
Migrate whole suite to real BullMQ

Shard times unchanged, still 5-6 min.
2026-07-05 00:30:21 +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
Thomas des Francs 59c16ef46f Polish settings billing and MCP UI (#22554)
## Summary

- Polish Billing credits progress rounding and secondary action styling.
- Update MCP setup logos, card spacing, grouping, and badge color.

## Before/After

MCP & APIs

<img width="2258" height="2010" alt="MCP & APIs settings visual"
src="https://github.com/user-attachments/assets/915c8b50-5b98-4ba9-8e5c-a33f36440f6e"
/>

Billing

<img width="2240" height="1644" alt="Billing settings visual"
src="https://github.com/user-attachments/assets/9e3eb332-5792-4a4a-80b0-1b984e574008"
/>
2026-07-04 23:27:40 +02:00