Commit Graph

756 Commits

Author SHA1 Message Date
Félix Malfait 1fdb5605f1 feat: kanban, calendar and grouped-table layouts for relation field widgets (#23112)
## Context

A relation field widget on a record page can already embed a
record-scoped view rendered as a **table** (`FieldDisplayMode.TABLE`) —
e.g. a Company's Opportunities. This brings **kanban, calendar and
grouped-table** to that same embedded view, so the board/calendar stays
scoped to *this* record's related records (not a standalone all-records
widget — that was the earlier #23003 approach, closed).

Builds directly on the merged dashboard widget layouts (#22963), reusing
its renderer, draft/save pipeline, and settings dropdowns.

## Approach — extend the existing "Table" display mode

The relation field widget already stores a `viewId` and renders it
through the layout-agnostic `RecordTableWidgetRendererContent` (which
branches on the embedded view's `type`), scoped to the current record
via `RecordFilterValueDependenciesContext`. So rendering + persistence
already work for any widget view type — only the authoring UI and one
server gate were missing. **No new `FieldDisplayMode`, no data
migration.**

## Server

- `view-widget-upsert.service.ts`: a field widget in table display mode
(`isFieldTableWidget`) could already persist viewFields/filters/sorts
through this path, but was **blocked from updating view settings**
(`type` / group-by / calendar), pinning its embedded view to a table.
The widget-type guard earlier in the method already rejects every widget
kind other than record-table and field-table, so the now-redundant
record-table-only guard on the view-settings branch is dropped. The
allowed-widget-view-types check and the downstream group-by /
calendar-field validations still apply equally.

## Frontend

- **One merged Layout picker.** The field widget's Layout dropdown lists
**Field / Card / Table / Kanban / Calendar** in a single flat list — you
pick Kanban directly, instead of "Display as: Table" first and a
separate embedded-view layout second. Picking a view layout selects the
`TABLE` display mode under the hood, seeds the record-scoped embedded
view on first use (with a default group-by / date field), and applies
the layout in the same click. Kanban/Calendar are disabled with a hint
("Needs a Select field" / "Needs a Date field") when the relation target
can't support them — same gating as the dashboard picker. The row's icon
and description reflect the effective selection (e.g. Kanban), and the
dropdown mounts the draft-init effect so switching straight from
Field/Card to Kanban works before the table renderer has ever mounted.
- **Contextual rows** (Group by / Date field / Calendar view / Hide
empty groups) extracted from the dashboard panel into a reusable
`WidgetViewLayoutSettingsRows` (source object passed in — fixed to the
relation target; no Source / Limit rows) and surfaced under the picker
while a view layout is active. Its standalone layout row is hidden here
(`isLayoutRowHidden`) since layout lives in the merged picker.
- Reuses the dashboard draft snapshot + `upsertViewWidget` save pipeline
and the group-by/calendar dropdown components unchanged.

## Scope

- **One-to-many relations only** (matches the existing
`getFieldWidgetAvailableDisplayModes` gate; junction / many-to-many stay
table-only — a pre-existing inconsistency left untouched here).
- Field-widget **calendars inherit the dashboard's behavior** (month
read-only by default; day/week + drag-to-reschedule only behind
`IS_CALENDAR_WEEK_VIEW_ENABLED`), since it's literally the same
renderer.

## Tests

- Server integration
(`upsert-view-widget-view-settings.integration-spec.ts`): a FIELD +
TABLE widget can switch its embedded view to `KANBAN_WIDGET` (with
group-by) and `CALENDAR_WIDGET` (with date field), and the kanban
group-by validation still applies through the newly-opened path.
- Front unit: `getWidgetViewLayoutSettingsItemIds` (keyboard-nav row ids
per layout/flag/group state).

## Follow-ups (intentionally not in this PR)

- Migrate the dashboard settings panel onto the shared
`WidgetViewLayoutSettingsRows` (kept out to avoid churning the
just-merged #22963 file; behavior-preserving refactor).

https://claude.ai/code/session_01E5N87kwwZWhDtEQaP72cMf
2026-07-24 12:01:53 +02:00
martmull fdb8865933 test: cover uninstall logic function hook execution (#23249)
Follow-up to #23227 ([review
comment](https://github.com/twentyhq/twenty/pull/23227#pullrequestreview-4771629391)):
adds integration coverage for the `defineUninstallLogicFunction` hook
execution on uninstall.

## What

New integration suite
`successful-uninstall-application-logic-function-hook.integration-spec.ts`
that drives the real `syncApplication` / `uninstallApplication` GraphQL
flow and asserts on the executor wiring:

- When the synced manifest declares an `uninstallLogicFunction`,
uninstalling the application resolves the hook and calls
`LogicFunctionExecutorService.execute` exactly once, before deletion,
with the `{ version }` payload.
- When the manifest declares no uninstall hook, uninstalling the
application does not call the executor.

The executor is spied via the running app container
(`getAppProviderByClassName`) and stubbed to a success result, so the
test verifies the server-side resolution/trigger path deterministically
without depending on the local function runtime.

## Test plan

- `npx jest --config ./jest-integration.config.ts
successful-uninstall-application-logic-function-hook` passes (2/2).
- Typecheck green for twenty-server; oxlint and oxfmt clean on the new
file.


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

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

---------

Co-authored-by: martmull <martin@twenty.com>
2026-07-24 09:26:04 +00:00
Abdul Rahman 148dc6dfaa Let server route resolvers answer the caller synchronously (#23233)
## Problem

A server route resolver can only return a dispatch target (`{
workspaceId, targetLogicFunctionUniversalIdentifier, payload }`), and
`ServerRouteTriggerService` always acks `202 {queued:true}`. The target
function runs off the queue, after the response has been sent, so its
return value can never reach the caller.

That makes it impossible to integrate a provider whose webhook URL has
to be proven with a handshake on the same response. Slack's Events API
is the case that surfaced it: `url_verification` sends `{ type,
challenge }` and will not accept the Request URL unless the challenge
comes back on that POST.

## Change

A resolver may now return a `Response` (the existing
`LogicFunctionHttpResponse`) instead of a dispatch target. The route
sends it as-is via `buildRouteTriggerResponse` and enqueues nothing.

- Reuses the marker and builder that HTTP route triggers already use, so
there is no new response shape.
- Dispatch results behave exactly as before; the resolver error path is
unchanged, just hoisted out of `parseResolverResult` so it runs before
the branch.
- SDK: `ServerRouteResolverResult` becomes `ServerRouteDispatchResult |
LogicFunctionHttpResponse`.

Additive: a resolver that returns a dispatch target sees no behavior
change. Previously, returning this shape threw
`RESOLVER_INVALID_RESULT`.

## Testing

`server-route-trigger.service.spec.ts` gains a case asserting the
resolver's response is sent verbatim and nothing is enqueued. 15/15
pass.

## Context

Split out of #22984 (Slack conversational assistant), which needs this
to complete the Slack Events URL verification. That PR depends on this
one merging first.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23233?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 08:56:34 +02:00
Abdul Rahman 2c79093b74 feat: agent roleUniversalIdentifier for manifest-driven role assignment (#23206)
## Summary
- Adds optional `roleUniversalIdentifier` on `AgentManifest` /
`defineAgent` so apps can declaratively assign a role to an agent (same
config shape as `defaultRoleUniversalIdentifier`).
- Wires `agentUniversalIdentifier` as a sync many-to-one FK on
`roleTarget`, and emits a deterministic `roleTarget` from the agent
during app sync (create / update / delete).
- Enables app agents (e.g. Slack assistant) to get a role on install
without postInstall hooks or manual admin assignment.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23206?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-24 05:57:40 +05:30
Scarab Systems d1c70ab0bf Fix dashboard record table widget aggregate persistence (#23008)
## Summary

- Update dashboard record-table widget aggregate changes to write into
the widget draft while page layout edit mode is active.
- Include `aggregateOperation` when saving record-table widget view
fields through `upsertViewWidget`.
- Persist aggregate operations server-side for widget view-field create,
update, and clear flows.
- Add frontend utility tests and backend integration coverage for widget
aggregate create/update/clear behavior.

Fixes #22934.

## Why

Dashboard record-table widgets use their own draft view state while a
page layout is being edited. The aggregate footer path was resolving
fields through the normal current-view flow and then trying to persist
immediately, which can miss widget draft fields and fail before the save
flow runs.

This change keeps aggregate edits in the widget draft during page layout
editing, then saves the aggregate operation with the rest of the widget
view configuration.

## Validation

- `npx nx lint twenty-front`
- `npx nx typecheck twenty-front`
- `npx nx test twenty-front --configuration=ci`
- `npx nx build twenty-front`
- `npx nx build twenty-server`
- `npx nx lint twenty-server --configuration=ci`
- `npx nx typecheck twenty-server`
- `npx nx test twenty-server --configuration=ci`
- `npx nx jest --config ./jest-integration.config.ts --logHeapUsage
--runTestsByPath
test/integration/metadata/suites/view/upsert-view-widget.integration-spec.ts`
- `git diff --check`

Disclosure: I used AI-assisted coding tools while preparing this PR. I
reviewed the changes myself, tested them, and take responsibility for
the implementation and any follow-up revisions needed.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23008?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-23 08:07:50 +02:00
Weiko 0108a34765 Rekey metadata caches when flat map hashes change (#23164)
## Context

The `/metadata` GraphQL response cache (`ObjectMetadataItems`,
`FindAllViews`) and the workspace SDL cache were keyed on
`workspace.metadataVersion`, an integer bumped on every object/field
migration. That mechanism is legacy (the migration runner literally
calls it `getLegacyCacheInvalidationPromises`): the data plane already
moved to `WorkspaceCacheService`, which versions each flat entity map
with its own hash minted on invalidation.

Version keying had two concrete costs: the version bump was the only
proactive invalidation for `ObjectMetadataItems`, and keeping
`FindAllViews` fresh required `flushGraphQLOperation`, a full Redis
keyspace SCAN on every relevant migration. It is also the main blocker
for deprecating `metadataVersion` entirely.

This PR re-keys both caches on the flat-map hashes instead.

## What changed

**Response cache** (`use-cached-metadata.ts`): the key is now
`{operation}:{workspaceId}:{combinedDependencyHash}[:{userWorkspaceId}]:{locale}:{queryHash}`.
Each cached operation declares which flat maps its resolvers read
(`metadata-graphql-operations-to-cache.constant.ts`) plus a scope:
`ObjectMetadataItems` stays workspace-shared, `FindAllViews` is per-user
because unlisted-view visibility depends on the caller. When any
declared map changes, its hash rotates and the key rotates with it; no
flush needed. The key is resolved once per request and reused in
`onResponse`, so a rotation mid-request can never cache a response under
a fresher key than the data it was built from. If hash resolution fails,
the request is served uncached (Sentry-captured).

This also fixes three pre-existing key soundness gaps: `FindAllViews`
ignored locale although view names are translated server-side, the query
hash ignored GraphQL variables (`$viewTypes`), and mid-request version
rotation could re-key between request and response.

**SDL cache** (`workspace-graphql-schema-sdl.service.ts`): keyed on the
combined hash of the four maps the schema is generated from, taken from
the same `getOrRecomputeWithHashes` call that returns the data, so key
and content cannot skew. The `metadataVersion` read/seed block there is
gone; the Redis seed moved to `middleware.service.ts` so the
`X-Schema-Version` "refresh the page" check keeps working after the
Redis key's TTL expires.

**`WorkspaceCacheService`**: the internal pipeline now threads `{data,
hashes}` through every stage (local hit, hash validation, Redis fetch,
recompute) and the memoizer stores the pair, so returned hashes are
always consistent with returned data. New public
`getOrRecomputeWithHashes` and `getOrRecomputeCombinedHash`
(hashes-first: one MGET of the small `:hash` keys, full pipeline only
for missing ones, so cold pods never pull map payloads just to build a
key).

**Atomic pair writes** (`cache-storage.service.ts`): `mset` on the Redis
driver now delegates to the store's own `mset` (a MULTI of `SET ... PX`,
or native `MSET`), grouped by TTL. Previously it was a `Promise.all` of
independent SETs, so two concurrent recomputes could interleave and
leave one recompute's `:data` next to the other's `:hash`; with
hash-keyed caches that torn pair could persist a stale response under a
live key. `CoreEntityCacheService` writes through the same method and is
fixed for free.

**Cleanup**: `flush()` lost its `metadataVersion` parameter (always
pattern-flush per key on workspace deletion),
`METADATA_VERSIONED_WORKSPACE_CACHE_KEY` became
`HASH_KEYED_WORKSPACE_CACHE_KEYS` with the `MetadataVersion` key
relocated to `WORKSPACE_CACHE_KEYS` and the dead `ORMEntitySchemas`
entry removed.

## Deliberately unchanged

- `incrementMetadataVersion` and all its callers stay: the version still
feeds the `X-Schema-Version` check and the pinned upgrade commands.
Deprecating the column is a later stage.
- The runner's `FindAllViews` pattern-flush is kept for exactly one
release: view-only migrations never bump `metadataVersion`, so old pods
in a rolling deploy have no other invalidation signal for their
version-keyed entries. It gets deleted next release, which removes the
SCAN entirely.
- Old-shape cache entries are not migrated; they expire via the 7-day
TTL.

## Known limitations (follow-ups, not regressions)

- The plugin reads dependency hashes Redis-fresh while resolvers can
serve up to 10s-old memoized data, so a request landing right after a
migration can cache a pre-rotation response under the new key. Same
shape existed under `metadataVersion`; closing it needs request-scoped
snapshot plumbing.
- Concurrent recomputes are last-writer-wins (lost update). Fencing with
a conditional write is a follow-up.

## Validation

- Unit: response-cache plugin behavior (scope, key stash, serve-uncached
on failure, prototype-name guard), atomic `mset` batching, existing
`WorkspaceCacheService` spec passing unchanged.
- Integration: a new drift-guard spec runs the real
`ObjectMetadataItems`/`FindAllViews` operations with full frontend
selection sets against the in-process app, spies on
`WorkspaceCacheService`, and fails if resolvers read a flat map missing
from the declared dependency lists, so the constant cannot silently
drift.
- Manual against a live server: creating a field rotates the field-map
hash and the very next `ObjectMetadataItems` response contains it (hash
rotation is now its only invalidation path); warm hits are ~5ms; SDL
entries appear under hash-shaped keys via introspection.

## Suggested reading order

1. `workspace-cache.service.ts`, `workspace-cache-key.type.ts`,
`combine-cache-hashes.util.ts` (the `{data, hashes}` pipeline)
2. `use-cached-metadata.ts`,
`metadata-graphql-operations-to-cache.constant.ts`,
`metadata.module-factory.ts` (response cache)
3. `workspace-graphql-schema-sdl.service.ts`,
`workspace-cache-storage.service.ts` (SDL cache and renames)
4. `middleware.service.ts` (metadata version seed relocation)
5. `cache-storage.service.ts` (atomic writes)
6. Tests
2026-07-22 16:50:09 +00:00
Abdul Rahman 04d1c2035c feat(connections): run a logic function on connection provider connect (#23167)
## What

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

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

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

## How

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23167?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-22 17:38:01 +02:00
martmull f59bda1dbf feat(server): queued-only server-route dispatch (#23134)
## Context

Follow-up to the incident where 500s and latency spiked around 6pm until
the Recall webhook was disabled. Server routes
(`/webhooks/server/:resolverUid`) ran the resolver **and** the target
logic function synchronously inside the API request, so any handler
throw became a 500 and any slowdown past Svix's delivery timeout marked
the delivery failed — Svix redelivered, feeding load back into the API
in a self-sustaining storm.

## What changed

- Every server-route request acks with **202 `{ queued: true }`** as
soon as the resolver returns; the target runs on `logicFunctionQueue`.
Signature verification stays synchronous in the resolver and still
rejects with a non-2xx. External senders never observe target latency or
failures.
- The resolver contract is unchanged from main: `{ workspaceId,
targetLogicFunctionUniversalIdentifier, payload? }`.
- The target lookup still happens synchronously before enqueueing,
scoped to the resolver's application registration, so unknown targets
404 as before.
- Endpoints whose caller must read the response body (challenge
handshakes, Slack commands) should use `httpRouteTriggerSettings`
routes.

Final diff is 4 files: the server-route service, its spec, the
integration spec, and the docs page. Trigger jobs, fan-out, message
queue, shared types, and SDK are all untouched.

## Tests

- `server-route-trigger.service.spec.ts`: 202 ack + enqueue,
unknown-target 404 without enqueue, resolver auth/contract/error
mapping.
- Integration: `server-route-trigger-authorization.integration-spec.ts`
asserts the 202 queued ack (run locally against a seeded DB, green).
- `typecheck` + `lint:diff-with-main` + `oxfmt` clean.

## Notes

- **Breaking for existing server-route resolvers**: responses are always
202; the target's return value no longer reaches the caller. Existing
resolvers returning response bodies must move those endpoints to
`httpRouteTriggerSettings`.
- A queued target's handler failure is recorded in execution logs but
not retried (same as other queue-executed functions today); retry
semantics are deliberately out of scope here.
- Follow-up candidates: retry-on-failure semantics for queued
executions, `addBulk` for single-round-trip fan-out, declarative
signature verification to take resolver code out of the request path,
moving the call-recorder 250s artifacts import off the API request path.
- Companion PR #23135 (call-recorder): no app change needed for dispatch
— queued dispatch applies by default.
2026-07-22 14:57:18 +02:00
Thomas Trompette 25f28ee299 Fix TWENTY-SERVER-60Y: register permissions exception filter globally (#23104)
## Context

Sentry
[TWENTY-SERVER-60Y](https://twenty-v7.sentry.io/issues/6633503406)
("Permission Denied: Entity performing the request does not have
permission") has ~12.9k occurrences / 151 users. It groups by the shared
throw site `settings-permission.guard.ts:57`, so it's a **catch-all
bucket** for settings-permission denials across many resolvers, not a
single operation. Sampled events include `findOneApplication` (app
runtimes reading their own `applicationVariables`),
`uploadFilesFieldFileByUniversalIdentifier`,
`UpdatePageLayoutWithTabsAndWidgets`, `CreateFileUpload`, etc.

## Root cause

Settings-permission denials are only converted to a client-appropriate
`FORBIDDEN` when a resolver manually attaches
`PermissionsGraphqlApiExceptionFilter`. **28** GraphQL resolvers do;
**~35** guarded resolvers do not. On those, the guard-thrown
`PermissionsException` (a `CustomException`, not a `BaseGraphQLError`)
falls through to the Yoga error hook, is serialized as
`INTERNAL_SERVER_ERROR`, and `shouldCaptureException` reports it to
Sentry as a 500-class error.

REST is not affected: every `SettingsPermissionGuard` controller already
carries `PermissionsRestApiExceptionFilter` (15/15).

## Fix

Register `PermissionsGraphqlApiExceptionFilter` globally via
`APP_FILTER` in the core and metadata engine modules, mirroring the
existing global GraphQL filters `BillingGraphqlApiExceptionFilter` and
`FlatEntityMapsGraphqlApiExceptionFilter`. The filter is guarded on the
GraphQL context (`host.getType()`), so REST keeps its existing
per-controller filter untouched and no request-scoped dependency is
pulled into a global provider.

Every settings-guarded resolver now returns `FORBIDDEN` for denials,
which is both the correct client error code and excluded from Sentry.
Existing per-resolver
`@UseFilters(PermissionsGraphqlApiExceptionFilter)` entries remain valid
(handler-scoped takes precedence, identical result); collapsing them
into the global registration is a possible follow-up.

## Test

Added a case to `granular-settings-permissions.integration-spec.ts`: a
member without the `APPLICATIONS` flag calling
`findOneApplication{applicationVariables{key value}}` (the exact Sentry
query, and a resolver with **no** resolver-scoped filter) must receive
`FORBIDDEN`, exercising the global filter.

Verified against the local test DB:
- With the global filter: passes (`FORBIDDEN`).
- Without it: fails with `INTERNAL_SERVER_ERROR`, reproducing the leak.
- The existing roles / workspace-members / api-keys denial tests
(per-resolver filters) still pass, confirming no precedence conflict and
no boot issue from dual `APP_FILTER` registration.

Fixes TWENTY-SERVER-60Y
2026-07-22 12:10:49 +02:00
Abdul Rahman 4c4a154d31 key-value storage for applications (#23089)
## What

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

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

## Scopes

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

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

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

## Follow-ups

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23089?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-22 11:19:44 +02:00
Félix Malfait 3ad3e8bd1a feat: kanban, calendar and group-by table layouts for dashboard view widgets (#22963)
## Context

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

## Server / API

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

## Frontend

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

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

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

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

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

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

## Scope (v1)

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

## Tests

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

https://claude.ai/code/session_01E5N87kwwZWhDtEQaP72cMf
2026-07-21 15:41:08 +02:00
Paul Rastoin 1be5a0e54a System side effect relations (#22882)
Closes twentyhq/core-team-issues#2667

## What

Default relations to the standard relation objects
(`timelineActivities`, `attachments`, `noteTargets`, `taskTargets`) are
now fully owned by the **metadata side-effect engine**. Neither the API
transpilers nor the SDK manifest builder provision them anymore: any
object creation, rename or deletion — regardless of the caller — goes
through the same engine handlers.

## Why

- Provisioning was duplicated across the API path and the SDK manifest
builder, with diverging behavior.
- Universal identifiers of relation fields were derived from object
**names**, so renaming an object mutated them and forced lossy
delete+create cycles on manifest sync.

## How

### Engine-owned lifecycle (side-effect handlers)

- `objectSystemRelationsOnCreate`: provisions the 8 forward/reverse
relation fields (+ join column indexes) when an object is created.
- `objectSystemRelationsOnUpdate`: renames the reverse morph fields
(`target<ObjectName>`) when their host object is renamed — a lossless
`fieldMetadata.update`.
- `objectSystemSideEffectsOnDelete`: cascades deletion of engine-owned
fields/indexes when the object is deleted.
- The API transpilers and the SDK `buildManifest` no longer inject these
fields; `isSystemSideEffect: true` marks engine-owned entities, guarded
by a granular property allowlist (only `isActive` is user-editable) and
excluded from manifest deletion inference.

### Name-free deterministic universal identifiers

New `getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier, objectUniversalIdentifier,
relationTargetObjectUniversalIdentifier })` in `twenty-shared`, exported
from `twenty-sdk/define`. The identifier is keyed on the two **object**
identifiers instead of field names (direction encoded by argument
order), so object renames never mutate relation field identifiers. It
cannot collide with the name-based `getFieldUniversalIdentifier`
derivation (field names cannot contain `:`).

### twenty-standard re-owned

All 48 forward/reverse system relation field declarations in
`STANDARD_OBJECTS` now pin the derived name-free identifiers (computed
inline via the shared util) and carry `isSystemSideEffect: true`, with
labels/icons declared explicitly (translated via `msg`).
`twenty-standard` is projected as if the engine had generated these
fields itself.

### 2.23 upgrade commands

- `reconcile-system-relation-field-universal-identifier`: structurally
matches existing default relation fields per workspace and backfills the
derived universal identifiers, `isSystemSideEffect` flags, and standard
labels/icons.
- `upgrade-people-data-labs-application`: upgrades installed PDL apps to
`1.0.7` right after the backfill to close the desync window (its views
reference the re-derived identifiers).

### Misc

- `people-data-labs` `1.0.7`: views temporarily pin the new derived
identifiers (TODO: import from the next released `twenty-sdk`).
- `UpgradeStatusModule` split out of `UpgradeModule` so the application
module cluster can consume upgrade status/migration services without
importing the versioned command bundles (fixes a require cycle that
crashed boot).
- Docs: `system-fields.mdx` documents the system relation fields and
their resolver; `sync-and-recovery.mdx` plan example no longer shows
auto-injected relations.

## Known red CI

`people-data-labs (dockerhub-latest)` fails by design until the 2.23
server image is published: the app pins the new identifiers which only
exist on a 2.23 server. The `local` leg (server built from this branch)
is green.

## System fields are no longer manifest-authorable (accepted regression)

The manifest converter no longer derives `isSystem` /
`isSystemSideEffect` from field names. Reserved-system-named manifest
fields (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`,
`updatedBy`, `position`, `searchVector`) are now skipped at conversion
time when they carry the exact derived universal identifier (keeps
manifests built with older SDKs installable), and rejected with
`INVALID_INPUT` when they pin any other identifier. System fields are
therefore fully engine-canonical: nothing a manifest carries can produce
a system-flagged entity anymore.

**Accepted regression**: a manifest can no longer influence system field
properties at all. Previously a (legacy) re-declaration could shape them
at creation — which actually produced broken system fields, e.g. a
nullable, non-unique `id` — and could still toggle the allowlisted
`isActive` / `universalSettings` afterwards. We consider this acceptable
for now: per-app granularity over system fields will be reintroduced
later through the **override framework**, which will also settle update
semantics by forbidding direct updates over `isSystemSideEffect: true`
entities and expressing divergence as overrides.

`isSystemSideEffect`-only entities (the default relation fields
provisioned by this PR) still have no engine-level update guard (see
Follow-up below); that part is unchanged and also lands with the
overrides refactor.

## Follow-up

`isSystemSideEffect` field update/delete guards intentionally live at
the API layer (`sanitize-raw-update-field-input.ts`,
`from-delete-field-input-...util.ts`) rather than in the engine-level
`FlatFieldMetadataValidatorService`. Moving them into the validator
requires threading operation-origin (direct field mutation vs engine
cascade) through the migration matrix, otherwise legitimate object
rename/delete cascades (which carry `isSystemBuild=false`) would be
rejected. Tracked in twentyhq/core-team-issues#2671.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22882?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-20 18:53:24 +02:00
Raphaël Bosi 240e185323 Show connect emails step to invited users without granting credits (#23058)
Invited users never saw the connect-emails onboarding step, so they
could not connect their inbox while onboarding. Now they do, but only
the first user (the workspace creator) earns the import-contacts reward
for it.

The credit is gated on the workspace having a single member, reusing the
same "first user" signal the frontend already uses to gate the
invite-team step. The connect-account step is still claimed for everyone
so invited users' onboarding advances normally.

The frontend hides the "free credits" tag and the header counter bump
for invited users, so we do not promise credits they will not receive.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23058?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-20 17:00:39 +02:00
Charles Bochet 39082cf787 Only show the install-apps onboarding step to workspace creators (#22990)
New users joining an existing workspace through an invite link were
routed through the "Install your first apps" onboarding step, which is
meant for workspace creation only.

#22347 set `ONBOARDING_INSTALL_APPS_PENDING` inside
`activateOnboardingForUser`, which is shared by both sign-up paths. Gate
it per path like the connect-account step: `true` on
`signUpOnNewWorkspace`, `false` on `signInUpOnExistingWorkspace`.

Verified locally: invited users now go straight from create-profile into
the workspace, and workspace creators still get the install-apps step.
Covered by a unit test on the invite path and integration tests
asserting the onboarding status per sign-up path (invite →
`PROFILE_CREATION`; workspace creation → `SYNC_EMAIL` then
`APPS_INSTALLATION`).
2026-07-17 15:33:42 +02:00
Félix Malfait 5e27e04c0a Add mostly-empty field hints to data model settings (#22962)
## What

Fields that are empty in almost all records now show a subtle `Mostly
empty` hint next to their name in the object's Fields settings table
(same visual treatment as `Deactivated`), with a tooltip explaining the
signal and a matching **Mostly empty** toggle in the search filter
dropdown. The goal is to nudge admins to clean up and deactivate fields
nobody uses, while keeping the page untouched when the data model is
healthy.

## How

**No table scans.** Emptiness is read from Postgres planner statistics,
so the cost is a catalog lookup regardless of table size:

- `pg_class.reltuples` gates the feature on an approximate row count (≥
100 records; never-analyzed tables mean no hints). Reuses the shared
helper extracted from `ObjectRecordCountService`.
- `pg_stats.null_frac` plus the sampled frequency of the column type's
empty sentinel (`''` for text columns, `'{}'` for arrays, `'{}'`/`'[]'`
for json — matched per physical column type) gives a per-column empty
fraction. A value dominating ≥ 95% of a column is guaranteed to appear
in the most-common-values list, so the approximation is reliable exactly
at the threshold we care about.

**Decision rules** (pure util, unit-tested):

- Flag when every relevant column is ≥ 95% empty and the object has ≥
100 records.
- Skip system fields, the label identifier, relations, booleans, and
actor fields (exhaustive switch — a new `FieldMetadataType` fails to
compile until classified).
- Composite fields must have all their columns empty, with column sets
derived from `compositeTypeDefinitions`; only default-bearing code
columns (`currencyCode`, phone country/calling codes) are excluded so
stamped defaults don't mask emptiness.
- Anything unknown (missing stats, new column since last ANALYZE)
degrades to silence — no hint is ever shown on missing data.

**API:** one `mostlyEmptyFieldMetadataIds(objectMetadataId)` query on
the metadata schema, guarded by the `DATA_MODEL` settings permission,
fetched lazily when the fields page opens.

**UI:** exception-based — no new columns, no persistent controls. The
badge and the filter toggle only materialize when at least one field
qualifies, and disappear once things are cleaned up.

## Test

- Unit tests for the decision util (threshold,
system/label-identifier/inactive exclusion, missing statistics,
composite all-columns rule, links label/secondary data, currency
narrowing, excluded types).
- Catalog SQL validated against Postgres 16 with a table mimicking
Twenty's column shapes (text `''` defaults, enums, arrays, jsonb,
currency pairs), including the type-aware sentinel matching (a text
column full of literal `"{}"` strings does not count as empty).
- End-to-end on a seeded dev instance: 899 companies with a mix of
filled/empty fields — the API returned exactly the five fields predicted
by the raw statistics (`annualRevenue`, `employees`, `introVideo`,
`tagline`, `workPolicy`) and correctly excluded `address` (city 33%
filled), actor/system fields, and the label identifier.
- UI driven with Playwright: badge, tooltip copy, filter toggle, and
filtered table all verified visually.
- `lint:diff-with-main`, `typecheck` (server + front), and all three
`graphql:generate` configurations + SDK metadata client regenerated and
committed.
2026-07-17 12:03:11 +00:00
martmull 6360599943 Unify application version gate, registration writes and upgrade paths across sources (#22931)
Continues the source-unification arc after #22921. Three related
consolidations, one commit each.

## 1. Single semver version gate (`793f4849`)

The "incoming version must move forward" rule was hand-rolled twice:
workspace installs (validate semver, reject equal as
`APP_ALREADY_INSTALLED`, lower as `CANNOT_DOWNGRADE_APPLICATION`) and
tarball deploys (reject `lte` as `VERSION_ALREADY_EXISTS`).
`ApplicationVersionValidationService.validateVersionProgression` now
owns the comparison rules and messages; new maps in
`version-reason-to-exception-code.constant.ts` translate failure reasons
to each caller's existing exception codes, so error contracts observed
by the frontend/CLI are unchanged. A non-semver current version never
blocks, matching both previous behaviors.

## 2. One registration-metadata writer (`13eb5f91`)

Tarball upload and marketplace catalog sync wrote registration metadata
with their own repository calls, duplicating the gallery-image fileId
preservation and variable-schema sync, and bypassing the
per-registration lock and transaction that `updateFromManifest`
provides. Both now delegate to `updateFromManifest` (new
`additionalFields` allowlist for their extra columns: `tarballFileId`,
`isListed`, `isVetted`, `ownerWorkspaceId`, `sourcePackage`, `name`), so
every manifest-bearing registration write serializes on the same lock
and applies the same rules. The shared gallery fileId preservation moved
to a `buildRegistrationManifestUpdateFields` util. Tarball uploads can
no longer race installs on the registration row.

Behavior notes: a tarball re-upload whose manifest lacks
`application.displayName` now keeps the existing registration name
instead of resetting it to "Unknown App", and a re-upload without a
`package.json` version keeps the stored `latestAvailableVersion` instead
of nulling it — both strictly less destructive.

## 3. TARBALL upgrades (`b20aaba9`)

`upgradeApplication` only supported NPM; TARBALL apps had no update path
for installing workspaces. It now accepts TARBALL registrations and
re-installs the stored tarball, whose contents define the target version
— the install flow already gates same-version and downgrade installs.
The settings UI shows the latest-version row and the Upgrade button for
both NPM and TARBALL apps via a shared
`isUpgradableApplicationSourceType` util. LOCAL (dev-sync updates) and
OAUTH_ONLY (no code artifacts) stay rejected with a clearer message.

## Validation

- New tests: `validateVersionProgression` matrix in
`application-version-validation.service.spec.ts`,
`buildRegistrationManifestUpdateFields` gallery-preservation spec
- All 27 application suites (148 tests) pass; typecheck and lint green
on twenty-server and twenty-front
2026-07-17 09:06:20 +02:00
Weiko a5108d512f Block filters on restricted fields (#22873)
## Summary
- Reject filter conditions that target fields without read access,
including relation traversals
- Add unit and integration coverage for denied field filter access

The issue is an information leak through filtering: a user who cannot
read a field can still infer its values from totalCount

### Manual reproduction
- Create or use a non-admin role.
- Give it read access to People records.
- Disable read access to the Person jobTitle field.
- Assign a test member to that role.
- Authenticate as that member.

Run:
```gql
query People($filter: PersonFilterInput) {
  people(filter: $filter, first: 0) {
    totalCount
  }
}
Variables:
{
  "filter": {
    "jobTitle": {
      "like": "Par%"
    }
  }
}
```
Ensure at least one Person has a matching jobTitle.
Before the fix, the request succeeds:
```gql
{
  "data": {
    "people": {
      "totalCount": 1
    }
  }
}
```
The caller can probe restricted values using different filters.
After the fix, it returns a permission error:
```gql
{
  "errors": [
    {
      "message": "Permission denied"
    }
  ]
}
```
The same should happen through a relation filter, for example filtering
Companies by a restricted Person field:
```gql
query Companies($filter: CompanyFilterInput) {
  companies(filter: $filter, first: 0) {
    totalCount
  }
}
{
  "filter": {
    "people": {
      "jobTitle": {
        "like": "Par%"
      }
    }
  }
}
```
2026-07-16 15:19:46 +02:00
Weiko fdb78b35d0 Handle scalar select filter values when recomputing view filters (#22930)
## Summary
Fixing `Internal Server Error: Unexpected invalid view filter value for
filter`

Updating a select field’s options failed with an INTERNAL_SERVER_ERROR
when the field had an associated IS_NOT_EMPTY view filter.
The affected filter stored an empty string as its value:
```
operand: IS_NOT_EMPTY
value: ""
```

### Root cause
The option-update side effect treated every associated select filter
value as an option array. It attempted to parse the empty string and
then rejected the result because it was not an array.
However, IS_NOT_EMPTY is a value-less operand, so its empty value is
valid and should not participate in option recomputation.

### Fix
Skip option-value recomputation for operands that do not expect a value,
including IS_NOT_EMPTY.
Normalize legacy scalar select-filter values using the same logic as
filter validation.
Leave subfield filters unchanged.
Preserve compatibility with legacy filter-value representations until
they are migrated to the canonical JSON format.
2026-07-16 12:52:10 +00:00
Charles Bochet 8e03921372 Add CREATED workspace activation status (read path + enum migration) (#22904)
## Context

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

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

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

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

## What this PR does (read path only)

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

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

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

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

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

## Week interactions

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

## Configuration and data loading

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

<img width="1285" height="808" alt="Screenshot 2026-07-15 at 15 50 17"
src="https://github.com/user-attachments/assets/b7e3f7f1-ca77-492f-8cce-cca186ebca0b"
/>
2026-07-15 16:30:18 +02:00
Abdul Rahman f4ff234db8 feat: make record avatar/icon resolution data-driven via a configurable image identifier field (#22644)
## Summary

Today the avatar/icon shown for a record is hardcoded per object —
Company pulls a favicon from its domain link, Person uses `avatarUrl`,
etc. This PR replaces that hardcoding with a generic, data-driven
abstraction based on a configurable **image identifier field** on each
object's metadata (mirroring the existing **label identifier** concept).

An object's image identifier can point to:
- a **`FILES`** field → the uploaded image is used directly (rounded
avatar), or
- a **`LINKS`** field → a favicon is derived from the primary URL via
the Twenty icons service (squared avatar), gated by
`ALLOW_REQUESTS_TO_TWENTY_ICONS`.

This lets any object type (Opportunity, a custom "Listing", etc.) define
its own avatar/icon without code changes, and makes the field
configurable/overridable for standard objects.


##  Open question: also allow `TEXT` → direct image URL?
Right now the image identifier is restricted to `FILES` (uploaded file)
and `LINKS` (favicon). We deliberately left out `TEXT` → **direct image
URL** (e.g. an imported/synced photo URL stored in a text field).
There's precedent for it — Person's avatar was originally a `TEXT`
`avatarUrl`, and WorkspaceMember still is — and it's unambiguous (a
`TEXT` field has no favicon-vs-image ambiguity, and selecting it as the
image identifier is itself the declaration of intent). It's a small,
clean extension:
- add `TEXT` to the allowed image-identifier types,
- add an explicit `TEXT → raw URL` case
- `getAvatarType`: `TEXT → rounded`.
Caveats: it relies on admin assertion that the text values are image
URLs (no data-level guarantee), and external image URLs load third-party
content in the browser (IP-leak/hotlinking, same as favicons — a
proxy/cache would be the more robust long-term answer).

###  Resolution
Decision: **we will not support `TEXT` as an image identifier.** Image
identifiers stay restricted to `FILES` and `LINKS`, and any other type
fails closed (returns no avatar) on both the frontend and backend.
Instead, the legacy items that still rely on a `TEXT` avatar — Person's
deprecated `avatarUrl` and WorkspaceMember's `avatarUrl` — will be
migrated to `FILE` fields in a follow-up PR. Until then, WorkspaceMember
remains an exception (its `avatarUrl` still resolves through the
existing CorePicture path), and legacy Person `avatarUrl` values that
haven't been migrated will show initials placeholders.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22644?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-15 19:15:47 +05:30
Paul Rastoin 14dacd8d35 [Slow db query] Resolve applicationId from cache in FileStorageService (#22870)
## Context

Sentry flagged a recurring slow DB query (TWENTY-SERVER-HZJ): `SELECT
... FROM core.application WHERE workspaceId = $1 AND universalIdentifier
= $2 AND deletedAt IS NULL LIMIT 1`, emitted on every file write under
`POST /graphql` (record avatar/file fields) and `POST /metadata`.

`FileStorageService` re-resolved the owning application row from
`core.application` by `(workspaceId, universalIdentifier)` on every file
write, uncached and synchronously in the request path. The row was only
used to recover `application.id`. The workspace cache already exposes
this mapping via `flatApplicationMaps.idByUniversalIdentifier`.

Closes twentyhq/core-team-issues#2668.

## Changes

- Injected `WorkspaceCacheService` into `FileStorageService` in place of
the `ApplicationEntity` repository.
- Added `resolveApplicationIdOrThrow`: resolves `applicationId` from
`flatApplicationMaps.idByUniversalIdentifier` on the normal
(already-committed) path, throwing
`FileStorageException(FILE_NOT_FOUND)` on a cache miss. When a
`queryRunner` is provided (application-creating transactions, where the
freshly created row is not yet in cache), it keeps the DB read through
`queryRunner.manager` so it can see uncommitted rows.
- Added `resolveApplicationUniversalIdentifierOrThrow` for the by-id
lookup in `deleteByFileId`, resolved from `flatApplicationMaps.byId`.
- Applied the cache path to `writeFile`, `createPendingFile`,
`deleteFile`, `deleteFolder`, and `deleteByFileId`. Only `writeFile`
carries a `queryRunner`; the others never do.
- Updated `FileStorageModule` to import `WorkspaceCacheModule` and drop
the now-unused `ApplicationEntity` repository registration.

No migration needed: a partial unique composite index on
`(universalIdentifier, workspaceId) WHERE deletedAt IS NULL AND
universalIdentifier IS NOT NULL` already exists on `ApplicationEntity`
and covers the query.

## Tests

Extended `file-storage.service.spec.ts`:
- cache hit resolves `applicationId` without a DB call,
- cache miss throws `FILE_NOT_FOUND`,
- the `queryRunner` path still reads from the DB and skips the cache.

All 95 file-storage unit tests pass; typecheck, oxlint, and oxfmt are
clean on the touched files.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22870?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-15 10:04:22 +00:00
martmull 7381038452 Paginate admin panel app registrations list (#22734)
## Context

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

## Changes

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

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

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

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

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

---------

Co-authored-by: Weiko <corentin@twenty.com>
2026-07-13 14:07:11 +02:00
Paul Rastoin b94e38c2d9 fix(server): stabilize flaky app-install workspace-version gate test (#22851)
## What

The integration test
`failing-app-installation-workspace-version.integration-spec.ts` is
flaky depending on the shape of the upgrade sequence, especially right
after a version bump. It intermittently fails at upload time with:

```
App requires Twenty server >=2.21.0 but this server is 2.20.0.
(SERVER_VERSION_INCOMPATIBLE)
```

## Why

The test mixed two different version sources:

- The upload-time check (`validateServerCompatibility`) compares the
app's required version against the **instance** inferred version, i.e.
the last attempted instance command (`workspaceId IS NULL`, via
`getInferredVersion`).
- The test's `beforeAll` instead derived the required version from the
**workspace** cursor.

These agree most of the time but diverge right after a version bump
whose newest upgrade segment ends in workspace-scoped commands and adds
no new instance command. In that state the seeded workspace cursor sits
at the new version while the instance is still at the previous one. The
test then uploads an app requiring `>=newVersion`, which fails the
instance gate at upload time before the workspace gate under test is
ever reached.

## How

Derive the gate version in `beforeAll` from the last attempted instance
command, mirroring exactly what `getInferredVersion()` uses. The
required version is then always `>=` the instance's own version, so the
upload passes; injecting that same command as a failed workspace attempt
drops the workspace to the previous completed version, so the install
reliably hits the workspace gate and returns
`WORKSPACE_VERSION_INCOMPATIBLE` as the snapshot expects. This holds
regardless of whether the newest version's segment ends in an instance
or workspace command.

No production code changed; the fix is confined to test setup logic.


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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22851?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-13 10:33:45 +02:00
Paul Rastoin 3614183200 fix(server): stabilize app install version-gate integration test (#22826)
## Why

The `failing-app-installation-workspace-version` integration suite fails
on CI (e.g. [this
run](https://github.com/twentyhq/twenty/actions/runs/29105697459/job/86405891168)):

```
App requires Twenty server >=2.21.0 but this server is 2.20.0.
subCode: SERVER_VERSION_INCOMPATIBLE
```

The test uploads an app requiring `>=${TWENTY_CURRENT_VERSION}` and
expects the install to be rejected by the **workspace** version gate.
But after the `2.21.0` version bump, `TWENTY_CURRENT_VERSION` (`2.21.0`)
moved ahead of the latest instance upgrade command (`2-20`, so
`getInferredVersion()` returns `2.20.0`). The tarball upload runs the
**instance** server-compat check first, which rejects `>=2.21.0` against
a `2.20.0` server before the workspace gate under test is ever reached.

The sibling sync test is unaffected because sync only validates
workspace compatibility, not the upload-time instance check.

## What

Derive the required version range from the version the instance actually
reached (the workspace upgrade cursor via
`extractVersionFromCommandName`) instead of the drifting
`TWENTY_CURRENT_VERSION` constant. This way:

- The upload passes the instance server-compat check (server satisfies
`>=<current version>`).
- The workspace, which resolves one version behind after the injected
failed cursor, still fails the workspace gate, producing the expected
`WORKSPACE_VERSION_INCOMPATIBLE` error.

The error assertion keeps using the normalized snapshot
(`scrubSemverVersions`), so the concrete version numbers do not leak
into the snapshot and future version bumps won't churn it.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22826?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-10 16:33:25 +00:00
Thomas Trompette cb2e325c4b fix(workflow): make prefilled workflow ids unique per workspace (#22800)
## Problem

\`prefillWorkflows\` (run for every workspace on \`activateWorkspace\`)
inserts workflows and versions with **hardcoded ids**
(\`QUICK_LEAD_WORKFLOW_ID = 8b213cac...\`, etc.). So every workspace
carries the same workflow/version record ids. Within a workspace schema
that's harmless, but it means workspace record ids are **not unique
across workspaces**, which:
- breaks the workflowVersion backfill on the shared core table (surfaced
as the \`IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW\` duplicate-key
error, since multiple workspaces claim the same active \`workflowId\`),
and
- collides on \`core.workflow\`/\`core.workflowVersion\` PKs once
workflows migrate to core (the core row reuses the workspace record id),
causing cross-workspace clobbering.

## Fix

Derive the prefill ids **deterministically per workspace**:
\`getWorkflowPrefillIds(workspaceId)\` returns \`v5(label:workspaceId,
namespace)\` for each of the workflow/version/trigger ids. Deterministic
(stable across the idempotent \`orIgnore\` re-runs) but unique per
workspace. The command-menu-item prefill uses the same helper so its
\`workflowVersionId\` reference stays consistent.

Only affects **new** workspaces; existing workspaces keep their current
ids (prefill is skipped on re-activation).

## Test

Reset seeds two workspaces; both now get a Quick Lead workflow with a
**distinct** v5-derived id (not the old \`8b213cac\`), and internal
references stay consistent (\`version.workflowId == workflow.id\`,
\`lastPublishedVersionId == version.id\`). Typecheck + lint clean.

Companion to #22795 (which scopes the active index to workspace).
Together they fix the backfill duplicate-id failures.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22800?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-10 16:21:04 +02:00
Abdul Rahman ffdda50afc remove nestjs-query auto-resolver from index-metadata (#22775)
## Summary

Removes the `NestjsQueryGraphQLModule.forFeature` block from
`IndexMetadataModule`, continuing the incremental migration off
`@ptc-org/nestjs-query`.

- Drops the dead auto-generated read surface: `index` and
`indexMetadatas` queries, the `IndexConnection` /
`IndexObjectMetadataConnection` types, and the `Index.objectMetadata`
field. No client consumes these — the frontend reads indexes via
`ObjectMetadata.indexMetadatas`.
- Keeps `IndexMetadataDTO` nestjs-query-compatible (`@Authorize`,
`@FilterableField`, `@QueryOptions`, `@IDField`) because
`ObjectMetadataDTO` still references it via
`@CursorConnection('indexMetadatas')` until object-metadata is migrated.
- Hand-written `createOneIndex` / `deleteOneIndex` mutations and the
`indexFieldMetadataList` resolve-field are unchanged.
- Deletes the now-obsolete `index-metadatas` integration test and
regenerates the GraphQL schema artifacts (frontend + client-sdk).

## Breaking change

This is an intentional GraphQL schema breaking change
(`api-breaking-changes` CI will flag it)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22775?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-10 14:00:23 +02:00
martmull 1c8b8970fd Allow CLI dev mode on catalog-synced apps without mutating the shared registration (#22756) 2026-07-10 12:10:00 +02:00
neo773 4cce9b1544 Fix integration test hang on unmocked requests + de-flake object metadata suite (#22758) 2026-07-10 14:27:25 +05:30
martmull d430e38a9a Fix shard 12 integration test failure: regenerate stale object metadata snapshot from #22594 (#22768)
# Context

Since #22594 merged (July 9, 16:59 CET), `server-integration-test (12)`
fails deterministically on every main-based branch. The failure is easy
to misread as flakiness because nx truncates the failed task's replayed
output before jest's `FAIL`/summary lines reach the CI log; the visible
`timelineActivity` FK-violation errors are pre-existing noise also
present in green runs.

Note: the flakiness PRs merged yesterday morning (#22699, #22701,
#22702) are not the cause. The failure window starts with #22594.

# Root cause

#22594 moved `searchVector` provisioning out of the
reserved-system-fields path into its own side-effect handler, registered
after `ObjectSystemFieldsOnCreateSideEffectHandlerService` in
`metadata-side-effect-handlers.module.ts`. The `searchVector` field
entry therefore now appears after `updatedAt`/`updatedBy` in the
create-object validation report. The snapshot for
`failing-create-one-object-metadata-v2.integration-spec.ts` was
rewritten in #22594 but kept the old `position -> searchVector ->
updatedAt` ordering, so 15 of the 19 tests fail on a snapshot mismatch.

Reproduced locally on latest main: 15/19 fail, diff is exactly the three
reordered name lines.

# Fix

Regenerated the snapshot with `jest -u` against a freshly reset
database. Suite is 19/19 green afterwards. The sibling snapshot suites
in the same directory (`failing-update-one-object-metadata`,
`failing-update-one-standard-object-metadata`,
`successful-update-one-standard-object-metadata`) were re-run and pass
unchanged.

Pure block move: only the `searchVector`/`updatedAt`/`updatedBy` name
lines relocate, 15 occurrences each (45+/45-), one file, no product
code.

# Related

- #22757 is an earlier draft with the same snapshot regeneration.
- #22758 fixes the same suite by replacing the snapshot with per-case
contract assertions (plus an msw catch-all). If that one merges first,
this PR becomes unnecessary.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22768?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: Martin <martin@twenty.com>
2026-07-10 09:58:42 +02:00
Paul Rastoin e9a030f762 fix(server): allow relabelling onto a field introduced in the same manifest sync (#22727)
## Context

Closes twentyhq/core-team-issues#2655.

`computeOrderedMigrationActions` runs `objectMetadata.update` **before**
`fieldMetadata.create`. In a single manifest sync that both introduces a
new field and relabels the object's
`labelIdentifierFieldMetadataUniversalIdentifier` onto that field, the
object update handler resolved the label identifier's universal
identifier against the persisted `flatFieldMetadataMaps` only. Since the
field's `fieldMetadata.create` runs later in the same migration, the
field isn't in the maps yet and the sync failed with `ENTITY_NOT_FOUND`.

The API metadata path is unaffected because create-field and
update-object are separate requests (separate transactions), so the
field is already persisted by the time the object update resolves.

## What this does

`update-object-action-handler.service.ts` now resolves
`labelIdentifierFieldMetadataId` and `imageIdentifierFieldMetadataId`
against the deterministically preallocated field ids first, then falls
back to the persisted flat maps for fields that already exist.

The preallocated ids
(`preallocatedIdByUniversalIdentifierByMetadataName`) are built from
every create action before the migration loop starts
(`buildPreallocatedIdByUniversalIdentifierFromActions`) and are the same
ids `create-field-action-handler` persists the fields with. This is the
same "preallocated-first, then flat maps" resolution that
`resolveUniversalRelationIdentifiersToIds` already uses for modeled
many-to-one relations, so no ordering change or new machinery is needed,
and the single sync stays one atomic transaction.

This does not touch the underlying action ordering or the hand-rolled
label/image identifier handling flagged by the `#2172` TODO;
generalizing those into the relation config remains the follow-up.

## Test

Adds `relabel-onto-new-field-manifest-sync.integration-spec.ts` (used as
the TDD reproduction, now green):
- introducing a field and relabelling onto it in a single sync succeeds,
and the object's `labelIdentifierFieldMetadataId` points at the new
field;
- the split path (introduce in one sync, relabel in the next) still
succeeds and exposes the enriched `labelIdentifierFieldMetadataId`
through the metadata API.

Both cases pass against the fix. `oxlint`, `oxfmt`, and `typecheck` are
clean.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22727?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-09 17:00:49 +02:00
Paul Rastoin 60fd322b49 Centralize system field side effects + search field metadata (#22594)
## Introduction

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

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

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

## What changed

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

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

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

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

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

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

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

### Deterministic identifiers for the standard app

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

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

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

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

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

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

Design notes:

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

## Tests

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

## Upgrade / migration notes

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

## Follow-up

* `object-metadata.service.ts` still carries a `TODO: remove once
default view fields move to the metadata side effect engine` — default
view fields are the next candidate to move into the engine.
* A single manifest sync cannot yet both create a field and relabel the
object onto it, because `objectMetadata.update` is ordered before
`fieldMetadata.create` in the migration runner. Tracked in
twentyhq/core-team-issues#2655; to be fixed in a follow-up.
2026-07-09 16:59:54 +02:00
martmull 67fa0cc93c Fix flaky webhook delivery integration test (fixed sleep -> poll) (#22699)
# Context

Part of a CI flakiness sweep. `webhooks.integration-spec.ts` › "should
deliver webhook successfully when safe mode is disabled" intermittently
fails with `expect(receiver.receivedPayloads.length).toBe(1) ...
Received: 0` on unrelated PRs (example: run 28959576750, shard 3).

# Root cause

The test asserted delivery after a fixed 100ms sleep. Delivery actually
crosses: a fire-and-forget `EventEmitter2.emit` (the GraphQL response
returns before the job is even enqueued) → BullMQ hop 1
(`CallWebhookJobsJob`, which also recomputes the just-invalidated
`flatWebhookMaps` cache) → BullMQ hop 2 (`CallWebhookJob`) → HTTP POST
to the in-test receiver. Two Redis round trips plus a cache rebuild
routinely exceed 100ms on loaded CI runners. The global
`waitForAllJobsToFinish` only runs in `afterEach`, after the assertion.

# Fix

- Poll the receiver with the existing `expectEventually` helper (30s
deadline, 100ms interval) instead of sleeping, and give the test an
explicit 60s timeout (suite default is 20s). Worst case the test fails
slower; it can no longer fail while delivery is merely in flight.
- Bonus bug found during adversarial review of this fix: the `finally`
cleanup deleted config key `HTTP_TOOL_SAFE_MODE_ENABLED` while the test
creates `OUTBOUND_HTTP_SAFE_MODE_ENABLED`, silently leaving outbound
safe mode disabled in the DB for every suite that runs after this one.
Fixed the key.

Duplicate-delivery risk was checked: `CallWebhookJob.handle` never
throws (errors swallowed), so `retryLimit: 3` can't produce a second
payload that would break `toBe(1)`.

Test-only change, 1 file, +15/-9.

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22699?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-09 10:58:07 +02:00
martmull 0ea0c23556 refactor(app-marketplace): rename featured to vetted (#22674)
## What

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

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

## How

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

### Database

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

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

## Testing

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

## Notes

- The `api-breaking-changes` check flags the removal of the `isFeatured`
GraphQL field — that is expected and inherent to this rename.
- Translation catalogs (`locales/`) are intentionally not touched here
since they are managed via Crowdin; new English strings render via
Lingui's default-message fallback until translated.
2026-07-08 16:41:58 +00:00
Paul Rastoin 163c96c2e5 Validate range version app dev sync (#22625)
# Introduction
Also now validating the workspace version when running a sync manifest

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22625?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-08 16:37:42 +00:00
martmull b733a79821 feat(server): support server-scoped files via nullable workspaceId on file table (#22587)
Part of the app settings architecture cleanup
(twentyhq/core-team-issues#2456) — PR 1 of the server-level documents
plan, reworked after the revert of #22560 (#22579). Same capability,
different shape: **no new entity** — server-level documents live in the
existing `file` table with a nullable `workspaceId`.

## Problem

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

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

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

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

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

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

## Next PRs in the plan

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

## Verification

- New spec `server-file-storage.service.spec.ts` (traversal table,
upsert conflict semantics, row-before-bytes reads, best-effort byte
deletion, registration cascade) + scope-validation util spec; affected
suites all green
- Typecheck (server + shared), `lint:diff-with-main`, full `oxfmt
--check src/` on both packages clean
- Fresh `database:reset` on the re-slotted branch: the 2-20 command
executes, generator then reports **no schema drift**; both ownership
checks and the composite unique verified live (dual-owner insert and
duplicate registration+path both rejected)
2026-07-08 11:56:24 +02:00
neo773 d99e6db93d test(messaging): messaging and calendar sync integration suites (#22567)
13 integration suites driving the real sync pipeline end to end — OAuth
connect via the actual `/auth/google-apis/get-access-token` /
`microsoft-apis` callbacks (transient token + mocked provider token
exchange), real queue workers, provider APIs mocked at the HTTP layer
with msw.

**Messaging (8):** Gmail list fetch + import, Gmail folder discovery,
Microsoft folder discovery, history-based incremental sync, stale-sync
recovery, sync failure lifecycle (429 throttle → exhaustion → relaunch;
declined refresh token → insufficient permissions), token refresh,
connected-account cleanup cascade.

**Calendar (5):** Google events import (full + sync-token incremental),
Microsoft events import (delta fetch + import), stale-sync recovery,
failure lifecycle, cleanup cascade.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22567?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 18:38:05 +05:30
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
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
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
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 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
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
Charles Bochet 99f99adf8f fix(server): require confidential client auth in authorization_code grant (#22548)
## Summary

Closes a **confidential-client authentication bypass** in the OAuth
`authorization_code` grant.

`OAuthService.exchangeAuthorizationCode` only validated `client_secret`
**when one was supplied** (`if (clientSecret)`), and the fallback check
at the end (`if (!clientSecret && !storedCodeChallenge)`) treats a valid
PKCE `code_verifier` as sufficient to complete the exchange. As a
result, a **confidential client** — one registered with a
`client_secret` (`oAuthClientSecretHash` set) — could have its
authorization codes redeemed using PKCE alone, with **no client
authentication**.

PKCE is defense-in-depth for public clients; it is not a substitute for
authenticating a confidential client (RFC 6749 §4.1.3, OAuth 2.1
§4.1.3). The `refresh_token` grant already enforces this exact rule —
this PR mirrors that gate in the `authorization_code` grant so any
client issued a secret must always present it.

## The fix

```ts
// Confidential clients (those issued a secret) must always authenticate,
// even when PKCE is used.
if (applicationRegistration.oAuthClientSecretHash && !clientSecret) {
  return this.errorResponse(
    'invalid_client',
    'Client authentication required for confidential clients',
  );
}
```

The check runs immediately after client resolution and before the
authorization code is even looked up. Public (PKCE-only) clients — those
without a stored secret hash — are unaffected.

## Testing

Added `oauth.service.spec.ts` covering:
- **Regression:** a confidential client presenting only PKCE and no
`client_secret` is rejected with `invalid_client` before any code
lookup.
- A wrong `client_secret` for a confidential client is still rejected.
- A public (PKCE) client is **not** blocked by the new gate and proceeds
to the code lookup.

Verified the regression test fails without the fix and passes with it.
Existing `application-oauth` suites remain green (8/8). Lint (`oxlint
--type-aware`, `oxfmt`) clean; the touched files typecheck.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22548?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-04 20:36:03 +02:00
Paul Rastoin 43730d7748 Centralized side effects devxp basis (#22295)
# Introduction

This PR introduces a centralized, strictly-typed **metadata side-effect
engine** that unifies how system metadata side effects are derived and
applied across both metadata entry points — the **metadata GraphQL API**
and the **application sync / manifest** flow — and migrates the first
side effect end-to-end: **a unique scalar field owns its backing
single-field `UNIQUE` index** (full create / update / delete lifecycle).

## New conventions

- **Engine-owned companions**: metadata flagged `isSystemSideEffect:
true` is owned by the engine. Its deletion is never inferred from
absence in a manifest — it results from PG-level cascade or from a
delete side effect (a side effect always has a cause, its parent
metadata).
- **Reserved deterministic identifiers**: apps cannot declare metadata
reusing an engine-owned deterministic `universalIdentifier`. Doing so
fails validation with `RESERVED_SYSTEM_UNIVERSAL_IDENTIFIER` (until an
explicit override API exists).
- **Record-native operation matrix**: the operation matrix is keyed by
`universalIdentifier` (`AllFlatEntityOperationRecordByMetadataName`)
instead of arrays, making parent resolution and deduplication O(1).
Array-based API callers are transpiled to records at the
validate-build-and-run boundary.
- Twenty-sdk user-facing experience with system fields will only be
related to overrides.

# What this PR does

## 1. Side-effect engine (foundation)

- `MetadataSideEffectEngineService.expandWithSideEffects(...)` takes the
intention-carrying record matrix and returns it expanded with derived
side effects, or a structured failure.
- Handlers are registered via a typed **decorator + registry** pattern
(`MetadataSideEffectHandler({ operation, metadataName, name, description
})`), with runtime duplicate-name detection. Multiple handlers per
(operation, metadataName) are supported.
- Handler contract mirrors the validator pattern:
- receives the trigger flat entity, the live record matrix, and
**strictly-typed related flat entity maps**
(`MetadataFlatEntityAndRelatedFlatEntityMapsForSideEffect<P>`, derived
from declared companion metadata names — no loose
`Partial<AllFlatEntityMaps>` context)
- returns `MetadataSideEffectResult`: `success` (operations record) |
`noop` | `fail` (structured failure)
- **Non-recursion is structural**: triggers are read from the original
caller input, never from the expanded matrix, so a side effect can never
trigger another side effect.
- **Deduplication + collision detection**: side effects are deduped by
`universalIdentifier` per operation; a caller-declared entity colliding
with an engine-owned deterministic identifier is recorded as a
collision.
- **Unified failure channel**: handler failures and reserved-identifier
collisions are merged into the same `OrchestratorFailureReport` contract
as builder validation errors, and the run short-circuits (fail-closed,
nothing is applied).

## 2. First migrated side effect — unique field → backing unique index

Three handlers own the complete lifecycle of the deterministic
single-field `UNIQUE` index backing a unique scalar field:

- **create**: unique scalar field → generate the deterministic backing
index (`fieldUniqueBackingIndexOnCreate`)
- **update**: `isUnique` flips and renames of still-unique fields (the
index name — and therefore its deterministic identifier — derives from
the field name, so a rename drops the stale index and recreates the
deterministic one) (`fieldUniqueBackingIndexOnUpdate`)
- **delete**: cascade-delete the backing index
(`fieldUniqueBackingIndexOnDelete`)

Supporting rules:
- The primary key `id` field never spawns a backing index (uniqueness
comes from the PK constraint) — explicit `isPrimaryKeyFlatFieldMetadata`
guard.
- Parent object resolution is **optimistic-first**: an object created or
updated in the same batch wins over the workspace cache (so e.g.
renaming an object while flipping a field to unique builds the index
from the post-rename object), resolved in O(1) via the record matrix.
- A missing parent object is reported as a structured side-effect
failure, never silently skipped.

## 3. Path convergence — manifest and API share one flow

- The manifest sync now derives a from→to **record matrix** from the
cache and feeds `validateBuildAndRunWorkspaceMigrationFromRecord`, the
same flow the API uses — both paths converge on the engine.
- Manifest-side unique-index generation and API transpiler
system-unique-index handling were removed (declared/composite/relation
indexes stay untouched).
- New `WorkspaceMigrationFlatEntityMapsService` mutualizes
flat-entity-maps computation between the side-effect engine and the
builder: cache keys are derived from the caller metadata names (+
validation- and side-effect-related closures) instead of hardcoded
loads.
- App-scoping and pruning are folded into one shared primitive
(`getSubAllFlatEntityMapsByApplicationIdsOrThrow`): slicing dependency
maps to the involved applications always prunes dangling one-to-many
aggregators — callers can no longer forget it.
- **Behavior change**: an app extending another app's view with a view
field now syncs successfully (cross-app view-field extension), covered
by a dedicated integration test.

## 4. Backfill upgrade command (2.19)

`upgrade:2-19:backfill-system-unique-index-universal-identifier`
rewrites legacy system unique-index `universalIdentifier`s to their
deterministic value so the engine can own pre-existing indexes. The
backfill is **driven from `isUnique: true` fields** (mirroring the
engine ownership predicate — excludes PK / morph / relation fields) and
resolves each field's backing index in O(1).

# Bugs fixed along the way

- `database:reset` seeding failed with
`INDEX_FIELD_INVALID_DEFAULT_VALUE`: the engine derived a backing
`UNIQUE` index for the default `id` primary key. Fixed with the explicit
primary-key guard.
- `isUnique` updates on system-flagged standard fields (e.g.
auto-created `name`) did not trigger the backing-index side effect.
- Manifest sync crashed with "Could not find flat entity with universal
identifier ..." when app-scoped slices left dangling aggregator
references — fixed by centralizing pruning in the shared slice primitive
2026-07-03 18:13:20 +02:00
Félix Malfait 13f380b80d perf(front-component): fingerprint built-JS URLs by path for CDN caching (#22530)
**Stacked on #22523** — base is that branch, so the diff shows only this
commit. GitHub will retarget it to `main` automatically once #22523
merges.

Follows up on @FelixMalfait's question on #22523: move the
BuiltFrontComponent cache key from a query string into the path so it
plays well with Cloudflare cache rules.

## What

- URL: `/rest/front-components/:id?checksum=<c>` →
`/rest/front-components/:id/<c>.js` (`getFrontComponentUrl`).
- Route: the controller now accepts `[':frontComponentId',
':frontComponentId/:cacheKey']`. `:cacheKey` is a pure cache-buster the
server **ignores** — it still resolves by `:frontComponentId`, exactly
as the query param did.

## Why a path segment (not `:id-<checksum>.js`)

A path-based, extension-bearing URL is matched by Cloudflare's
**default** static-asset caching and by trivial `*.js` path cache rules,
and it's immune to any "ignore query string" cache setting that would
otherwise collapse `?checksum=` to one entry and serve stale JS.

I used a path **segment** (`/:id/:checksum.js`) rather than the literal
`:id-<checksum>.js` you sketched because the id is a **UUID — which
itself contains hyphens** — so a `-` separator is ambiguous to parse. A
segment is unambiguous and equally CDN-friendly (still ends in `.js`).

## Backward compatibility

The bare `:frontComponentId` route is kept, so URLs minted before this
deploys (query-string form, or in-flight pages) still resolve. It can be
dropped in a later release once no client mints the old form. No data
migration — the URL is computed at render time from `frontComponentId` +
`builtComponentChecksum`.

## ⚠️ Decision for you: this alone does not edge-cache — `private` vs
`public`

BFC is served behind `WorkspaceAuthGuard` and #22523 set its header to
**`private`**, max-age, immutable. `private` means shared caches
(Cloudflare) **won't** store it — so today this is browser-cache only,
and the path change just makes it *ready* for edge caching + clean cache
rules.

To actually get **edge** caching you'd additionally either flip BFC to
`public` or add a Cloudflare rule that overrides cache-control — which
means **accepting that the `id`+`checksum` URL becomes the access
capability** (a cache hit is served without re-checking origin auth).
The cache key is unique per component+build so there's no
cross-workspace mixup, but the built JS effectively becomes
public-by-URL (same posture PublicAsset already has). I've **left it
`private`** here; flipping to `public` is your call and can be a
one-line follow-up.

## Tests

- `getFrontComponentUrl` unit test: fingerprinted path when a checksum
is present, bare fallback otherwise.
- Integration test: the `/front-components/:id/:checksum.js` path serves
the built JS with `Content-Type: application/javascript` and
`Cache-Control: private, max-age=86400, immutable`. Existing bare-route
tests remain and still pass.

https://claude.ai/code/session_01AKwhTxYFDhWhCZ4b7sf35W

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22530?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 17:17:14 +02:00
Paul Rastoin cdd667b106 fix(server): restrict /webhooks/server dispatch to server-route-exposed functions (#22469)
## What

`ServerRouteTriggerService.findResolver` resolved a logic function
purely by `universalIdentifier` and app-registration ownership, then
executed it before its resolver result shape was validated. As a result
the public `/webhooks/server/:universalIdentifier` route could dispatch
any owner-workspace app function — including ones exposed only as
authenticated HTTP routes, tools, or workflow actions — instead of only
functions declared as server-route resolvers.

## Change

`findResolver` now requires `serverRouteTriggerSettings`:
- DB predicate `serverRouteTriggerSettings: Not(IsNull())`, so
non-exposed functions are never fetched
- in-memory `isDefined(...)` guard alongside the existing
owner-workspace check

A function that did not opt into server-route exposure is now rejected
at `findResolver`, before any execution. A legitimately exposed resolver
is unaffected.

## Tests

- Unit (`server-route-trigger.service.spec.ts`): asserts the resolver
query carries the exposure predicate, and that an owner-workspace
function without `serverRouteTriggerSettings` is rejected and never
handed to the executor.
- Integration
(`server-route-trigger-authorization.integration-spec.ts`): exercises
the public endpoint end to end — a non-exposed owner-workspace function
is rejected before execution, while a server-route-exposed resolver
still passes the boundary.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22469?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 16:52:14 +02:00
Etienne 1270054d35 feat(ai): dashboard & view building (#22411)
## Why

Building a dashboard through AI chat used to cost ~9 sequential LLM
round-trips
(~160K input tokens for a single request): the agent had to resolve
object/field UUIDs and assemble views through many granular tool calls,
each
step replaying the full cached context.

## What changed

### 1. Reference objects & fields by name (fewer round-trips)
The agent no longer needs to resolve UUIDs before acting.
- `get_object_metadata`: filter by `objectName` (singular/plural) and a
new
`includeFields` flag returning each object's fields (`{id, name, type,
label}`)
  inline — object + field IDs in one call.
- `get_field_metadata`: accepts `objectName` as an alternative to
  `objectMetadataId`.
- All three dashboard write tools (`create_complete_dashboard`,
`add_dashboard_widget`, `update_dashboard_widget`): accept `objectName`
and
`*FieldName` variants (`aggregateFieldName`,
`primaryAxisGroupByFieldName`,
`secondaryAxisGroupByFieldName`, `groupByFieldName`, ratio `fieldName`),
resolved to UUIDs server-side by `resolveWidgetFieldNamesToIds`. UUID
variants
  still win when both are given.

### 2. `upsert_complete_view` — one atomic call to build/reconfigure a
view
- New `upsert_complete_view` tool + `ViewService.upsertCompleteView`:
create or
  update a view together with its fields, filters, and sorts.
- Children are **declarative**: a provided array replaces all existing
entries of
that kind, `[]` clears them, omitting leaves them untouched. Fields are
  referenced by name or UUID; no child-row IDs needed.
- Runs as a **single workspace migration** (`view` + `viewField` +
`viewFilter` +
`viewSort` in one `validateBuildAndRunWorkspaceMigration` matrice)
instead of
chained per-entity service calls. New
`buildCompleteViewChildrenFlatOperations`
  util assembles the child create/delete operations.
- Granular tools (`create_view_filter`, `update_view_sort`, …) are
retained for
  surgical single-entry edits.

### 3. Chart filters on dashboard widgets (end-to-end)
- Added `chartFilterSchema` (`recordFilters` + optional
`recordFilterGroups` for
AND/OR logic) to the four chart configs, with field-by-name or -UUID
references
  and documented operands/value formats.
- **Relative dates supported** — e.g. `PAST_7_DAY`, `THIS_1_MONTH`,
`NEXT_3_WEEK`,
plus open-ended `IS_IN_PAST` / `IS_IN_FUTURE` / `IS_TODAY`. Filters
route
through the same read pipeline (`computeRecordGqlOperationFilter`) as
view
  filters, so they resolve and apply correctly.
- `resolveChartFilterFieldNamesToIds` resolves filter `fieldName` → id
against the
  widget object.

### 4. Re-enable AI-assisted dashboards
- Removed the "coming soon" gating (`isActive: false` on the dashboard
skill and
the "not available yet" copy in the MCP server + chat prompts) and
registered
  `DashboardToolProvider`.
- Rewrote the dashboard skill prompt: confirmation gate (present a plan,
wait for
confirmation), completion guard (once confirmed, emit the create tool
in-turn —
no "now let me…" preambles), default-and-proceed (pick sensible defaults
for
missing fields instead of stalling), and an intent gate so informational
  dashboard questions are answered directly without loading skills.

### 5. Frontend: clearer advanced-filter labels
- `useRecordFilterField` now derives the filter label from field
metadata and
appends the relation target field (e.g. `Company → Name`), so
relation/target
filters — including those set by the AI — display correctly instead of
showing
  a stale/blank stored label.

## Fixes
- **`get_object_metadata({ objectName })` crash.**
`ObjectMetadataService.findManyWithinWorkspace`
  spread an array-form (`OR`) `where` into a plain object, producing
`{ "0": {...}, "1": {...}, workspaceId }` → `Property "0" was not found
in
"ObjectMetadataEntity"`. Now injects `workspaceId` into each OR clause,
so name
  lookups work.
- **Invalid SELECT/MULTI_SELECT filter options silently produced broken
charts/views.**
Chart-configuration validation and the migration-layer
`FlatViewFilterValidator`
now reject filters that reference options that don't exist, with a clear
  `Allowed values: …` message at creation time (shared
  `getInvalidSelectFilterOptionValues` util + tests).
- **Non-atomic view assembly.** The previous multi-call view build could
leave a
half-built view on failure; `upsert_complete_view` now runs as a single
transaction (one validation pass, one cache recompute, rollback on
error).
- **Blank RECORD_TABLE widgets from UNLISTED views.** Guidance + the
upsert
ownership check steer widget-backing views to `WORKSPACE` visibility; an
  UNLISTED view created without an owner renders a blank widget.
- **Extra discovery round-trip removed.** Deleted the skill→tool bundle
mechanism
(`SKILL_TOOL_BUNDLES`, `getBundledToolNamesForSkills`, and the
`load_skills`
  schema-loading path) that forced a second `learn_tools` call.
- **Type-safety of widget resolution.** Reworked the widget resolver to
build a
properly typed `WidgetWithMetadataIds` (dedicated input/output types)
instead of
  returning an untyped, cast-heavy object.

## Notes
- Backend changes are in `twenty-server`; one small `twenty-front`
change to the
  advanced-filter label hook. No entity/schema changes, so no migration.
- Tests added: `getInvalidSelectFilterOptionValues`,
`resolveWidgetFieldNamesToIds`
(incl. filter/relative-date resolution), `update_dashboard_widget`, and
expanded
  view-tools factory specs.
- Design decisions: dedicated composite tool over code-interpreter
orchestration
(atomicity + validation + consistency with `create_complete_dashboard` /
  `create_complete_workflow`); name-or-UUID but no child-row IDs on
`upsert_complete_view`; name→id resolution kept as stateless utils, not
services.

## Test plan
- [ ] `npx nx run twenty-server:typecheck`
- [ ] `npx nx lint:diff-with-main twenty-server` and `twenty-front`
- [ ] `npx nx test twenty-server` (view tools factory,
`getInvalidSelectFilterOptionValues`,
      `resolveWidgetFieldNamesToIds`, `update_dashboard_widget`)
- [ ] AI chat: "Create a dashboard with a chart of deal value by
pipeline stage
      and a table of the top 10 open opportunities" → plans, waits for
      confirmation, then builds with fewer round-trips
- [ ] AI chat: add a chart widget filtered by a relative date (e.g.
deals created
      in `PAST_7_DAY`) and confirm the chart is actually filtered
- [ ] Filter on a non-existent SELECT option is rejected with a clear
error

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22411?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 14:15:27 +00:00
martmull 1a85b88d38 feat(files): direct-to-storage upload endpoints with pending file lifecycle (#22449)
<img width="1484" height="404" alt="image"
src="https://github.com/user-attachments/assets/b2d363bf-d9e1-49fb-9811-8cc98041aa79"
/>


## Context

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

## What it does

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

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

**Pending lifecycle safety:**

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

**Driver support (both drivers):**

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

## Tests

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

## Notes for reviewers

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

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22449?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 15:11:53 +02:00
martmull 0992d0b748 feat(server): promote registration display fields to first-class columns (#22513)
Part of the application settings architecture work:
https://github.com/twentyhq/core-team-issues/issues/2456 — follow-up to
#22453, delivering the promised removal of the temporary manifest load.

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

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

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

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

https://claude.ai/code/session_011sST4rPLU1Koi2oVGi84ei

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

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