Commit Graph
12143 Commits
Author SHA1 Message Date
Félix Malfait 46b0c46beb fix(workflow): include relation target field metadata in find-records action
Workflow Search Records action built its fields list from
flatObjectMetadata.fieldIds — only the source object's own fields. The
shared dispatcher then couldn't resolve the relation target field by id
and silently dropped the filter, so a configured one-hop traversal
(e.g. People where Company → Name Contains "Airbnb") wasn't applied.

Collect relationTargetFieldMetadataId from the input record filters
and add them to the field id list before resolving against
flatFieldMetadataMaps.
2026-05-18 11:12:59 +02:00
Félix Malfait 52726ce943 fix(filters): plumb flattenedFieldMetadataItems through remaining computeContextStoreFilters callers 2026-05-15 15:03:56 +02:00
Félix Malfait 312bb37c40 fix(filters): include relation target field metadata in frontend gql filter compute
Every call site of computeRecordGqlOperationFilter was passing only the
source object's fields (objectMetadataItem.fields). The dispatcher needs
the relation target field by id to nest the inner filter under the
source field's GraphQL key — without it, the target lookup returns
undefined and the filter is silently dropped, producing { and: [] }.

The user-visible symptoms: relation-traversal chips show in the UI but
the GraphQL request carries an empty filter, and typing in the value
input doesn't trigger a refetch (variables unchanged because the filter
is being dropped at compute time).

Introduce augmentFieldsWithRelationTargets which extends the base field
list with the target fields referenced by the record filters, resolved
from flattenedFieldMetadataItemsSelector (workspace-wide flat field
list). Wire it through every computeRecordGqlOperationFilter caller in
the frontend (record table table/aggregate/empty/SSE paths, record
index group + total count, graph widget, calendar date range, context
store filters via three callers, parent view query variables).
2026-05-15 14:59:00 +02:00
Félix MalfaitandGitHub 4f707bb9f1 refactor(twenty-shared): split turnRecordFilterIntoGqlOperationFilter into dispatcher + direct builder (#20600)
Stacked on top of #20533 — merge that one first.

## Summary

Splits `turnRecordFilterIntoRecordGqlOperationFilter` into a thin
dispatcher and a private `buildDirectFieldGqlOperationFilter`. Same
observable behaviour, but:

- The two ordering-constraint comments that #20533 introduced go away
(\"must run before the emptiness shortcut\", \"drop rather than fall
through to legacy relation-by-record\"). They were both flagging a real
code smell: dispatch and direct-filter logic were interleaved in the
same function body and depended on a flag inside the input.
- Removes the self-recursion + \"inject target into fieldMetadataItems\"
hack.
- Removes three duplicated `fieldMetadataItems.find(...)` lookups that
only existed to compose error-message labels — the resolved
`fieldMetadataItem` was already in scope.
- Renames `correspondingFieldMetadataItem` → `fieldMetadataItem` inside
the extracted function (the variable IS the field; the longer prefix
predated the split).

## Why

Discussed in #20533: the conditional ordering between the
relation-traversal branch and the per-type switch was easy to get wrong,
and the legacy `case 'RELATION':` foot-gun (parsing the filter value as
a UUID list when it's actually a text value) was guarded by a defensive
`return` rather than by structure.

After this PR the dispatcher picks exactly one branch up front and
`buildDirectFieldGqlOperationFilter` only ever runs against the field
the filter operates on — no \"is this the source field or the target
field?\" ambiguity inside the switch.

## Test plan
- [x] `npx nx build twenty-shared` succeeds (no type regressions).
- [x] `jest packages/twenty-shared` — 1212 tests pass.
- [x] `jest view-query-params.service.spec.ts` — 7 tests pass, including
the relation-traversal round-trip case.
- [ ] CI green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 14:04:46 +02:00
Félix Malfait e0e36a8f00 chore: prettier format 2026-05-15 13:46:34 +02:00
Félix Malfait 4a78b092a2 refactor(advanced-filter): drop dead composite fallback + extract focus-push helper
- useApplyAdvancedFilterSourceField is only reached on leaf source fields
  (callers branch to the composite sub-menu hook or relation target hook
  before calling it). Removed the now-unreachable composite "any
  subfield" fallback and its three imports.
- The three callers that push the focus stack for a leaf RELATION/SELECT
  field (AdvancedFilterFieldSelectMenu, AdvancedFilterRelationTargetFieldSelectMenu,
  SettingsRolePermissions...FieldSelectFieldMenu) shared the same 8-line
  block — extracted into usePushFocusForLeafFieldValuePicker.
2026-05-15 13:42:20 +02:00
Félix MalfaitandGitHub 0a18423149 Merge branch 'main' into feat/graphql-relation-traversal-filters-frontend 2026-05-15 13:24:21 +02:00
Félix Malfait e3638925a2 fix(filters): drop relation-traversal view filter when target field cannot be resolved
mapViewFiltersToFilters used to fall back to the source field's filterType
and label when the relation target couldn't be found in
fieldMetadataItems. The resulting RecordFilter still carried the
unresolved relationTargetFieldMetadataId, and the dispatcher would later
drop the GraphQL filter anyway — leaving a chip in the UI that didn't
match the query.

Treat an unresolvable target the same as an unresolvable source: drop
the filter at mapping time. Race conditions during view change (the same
race the source-field handling documents) are the expected reason for a
transient miss and don't warrant a thrown error.
2026-05-15 13:14:49 +02:00
Félix Malfait 88d0476da0 fix(advanced-filter): reset relation-target-selecting state on dropdown dismiss + typecheck
- useResetFilterDropdown now clears
  objectFilterDropdownIsSelectingRelationTargetField, mirroring the
  composite-field reset. Without this, dismissing the dropdown mid-pick
  (Escape, click-away) left the flag true and the next open jumped
  straight to the relation target sub-menu.
- useApplyAdvancedFilterSourceField: tighten the defaultSubFieldName
  type. getDefaultSubFieldNameForCompositeFilterableFieldType can
  return undefined when the composite type isn't filterable by any
  subfield path, so coalesce to null to satisfy the
  CompositeFieldSubFieldName | null type expected by RecordFilter.
2026-05-15 13:10:20 +02:00
0c20b8bc88 i18n - translations (#20605)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-15 13:08:34 +02:00
Félix Malfait 203f72116a fix(advanced-filter): exclude many-to-one relations from relation target picker
The backend supports a single-hop relation traversal. If the user picked
a many-to-one relation field as the target, the dispatcher would
substitute the target id into fieldMetadataId and recurse — the inner
call has relationTargetFieldMetadataId set to null, so it falls through
to the per-type RELATION case which expects a UUID list, not a primitive
value. The UI offered a traversal that the backend silently demoted to
filter-by-id.

Filter many-to-one fields out of the target list so only one-hop
traversals can be composed.
2026-05-15 13:05:21 +02:00
Félix Malfait a23ab663bc chore: prettier format 2026-05-15 12:59:40 +02:00
Félix Malfait 2bd03cab91 fix(view-widget-upsert): resolve relationTargetFieldMetadataUniversalIdentifier on filter create
The CREATE path hardcoded relationTargetFieldMetadataUniversalIdentifier
to null while passing the FK id through. The migration system's
resolveUniversalRelationIdentifiersToIds then renormalises by treating
the null universal identifier as authoritative and nullifying the FK id,
silently dropping the relation traversal on save.

Mirror the UPDATE path: include relationTargetFieldMetadataId in the
foreignKeyValues passed to resolveEntityRelationUniversalIdentifiers and
read the resolved universal identifier from its result.
2026-05-15 12:55:04 +02:00
Félix Malfait 50bd2a78a7 fix(advanced-filter): defer record filter upsert until sub-menu choice is final
When the user picked a relation or composite source field and then backed
out of the sub-menu, the immediate upsertRecordFilter call left an
orphaned partial filter in the chip list (a relation-by-id filter with no
UUIDs, or a composite filter with the "any subfield" fallback). The chip
appeared functional but couldn't actually filter anything.

The source-field handler now only stages the source choice in dropdown
state for the sub-menu paths and lets the sub-menu's hook do the upsert
on the final pick. The leaf path keeps the immediate upsert.
2026-05-15 12:52:17 +02:00
Charles BochetandGitHub 75b9b2fe5d feat(admin-panel): signing keys management tab with usage tracking (#20586)
## Summary
- Adds a new admin-only **Security** tab to the Admin Panel (alongside
General/Apps/AI/Config/Health) containing a **Signing Keys** section.
The tab is intentionally introduced now so the upcoming **Encryption
rotation** work can land as a sibling section.
- Lists every JWT signing key with key id, `createdAt`, `revokedAt`,
current/active/revoked status, and a **7-day verification count** read
from Redis. A trailing row aggregates **legacy HS256** verifications so
it is clear when the deprecated path is still in use.
- Lets an admin **revoke** a public key. Revoking the current key drops
`isCurrent`, sets `revokedAt`, nulls the encrypted `privateKey` and
clears the in-process cached current key; the existing lazy path in
`JwtKeyManagerService.getCurrentSigningKey()` then mints a fresh current
key on the next sign.

## Backend
- `SigningKeyVerifyCounterService` — bucketed Redis counter under the
existing `EngineMetrics` namespace. 1-day UTC-aligned buckets, 8-day TTL
refreshed on every increment, batched read via `mget`. Failures are
swallowed and logged at `warn` so a Redis hiccup cannot break auth.
- `JwtWrapperService.verifyJwtToken` records verifies **after success**
for both ES256 (`kid` as identifier) and HS256 (the literal `legacy`
identifier).
- `JwtKeyManagerService.listSigningKeys()` and `revokeSigningKey(id)`:
list ordered by `isCurrent DESC, createdAt DESC`; revoke is idempotent,
validates the UUID, invalidates the public-key cache, and resets the
cached current-key promise.
- `AdminPanelResolver.getSigningKeys` (query) and `revokeSigningKey`
(mutation) are both decorated with `@UseGuards(AdminPanelGuard)` so they
are admin-only, like the 35 existing admin-only methods on this
resolver. `privateKey` is never returned over GraphQL.

## Frontend
- New `SECURITY` tab id wired into `SettingsAdminContent` and
`SettingsAdminTabContent` (gated by `canAccessFullAdminPanel`).
- `SettingsAdminSecurity` / `SettingsAdminSigningKeysTable` strictly
reuse existing admin-panel components: `Section`, `H2Title`,
`Table`/`TableRow`/`TableCell`/`TableHeader` from `@/ui/layout/table`,
`Tag`/`Button` from `twenty-ui`, and `ConfirmationModal` mirroring the
queue retry/delete modals. Only one minimal styled helper for the
monospaced UUID rendering.
- `useRevokeSigningKey` uses `useApolloAdminClient`, refetches
`GetSigningKeys`, shows success/error snackbars (same pattern as
`useRetryJobs`/`useDeleteJobs`).

<img width="1293" height="881" alt="image"
src="https://github.com/user-attachments/assets/7cf98664-950b-4451-af85-27781a8e9a9c"
/>
2026-05-15 10:49:18 +00:00
Félix Malfait 54894044b2 refactor(advanced-filter): split useSelectFieldUsedInAdvancedFilterDropdown into 3 specialized hooks
- useApplyAdvancedFilterSourceField for the source field choice
  (handles composite "any subfield" fallback for the initial pick)
- useApplyAdvancedFilterCompositeSubField for the composite subfield
  selection (no relation-target branching)
- useApplyAdvancedFilterRelationTargetField for the relation traversal
  target selection (derives filterType + label from target)

Each menu component calls its own dedicated hook — no shared
isRelationTraversal branching, no shared subFieldName fallback path.
The old useSelectFieldUsedInAdvancedFilterDropdown hook is removed.
2026-05-15 12:47:29 +02:00
Félix Malfait 7158fab9fa fix(filters): include relation target fields in fields list, fix deleted-target crash
- view-query-params and common-group-by include relation target field metadata
  in the fields list passed to the dispatcher so the traversal branch can
  resolve the target field by id
- ObjectFilterDropdownInnerSelectOperandDropdown swaps the throwing lookup
  for the non-throwing getFieldMetadataItemById so a deleted target field
  renders nothing instead of crashing
- lint: rename setIsSelectingRelationTargetField and isSelectingCompositeField
  to match their underlying component state names
2026-05-15 12:42:04 +02:00
AriqhermawanandGitHub 218799636f fix(docs): replace removed Mintlify build command (#20578)
## Summary
Closes #20565.

The Twenty docs package still pointed contributors at the removed
`mintlify build` command. This switches the docs workflow to a
`validate` command, which matches the supported Mintlify CLI command for
validating the documentation build, and updates the README wording to
match.

## Changes
- Replaced the `twenty-docs` package `build` script with a `validate`
script.
- Renamed the Nx docs target from `build` to `validate` and kept it
wired to `mintlify validate`.
- Updated the README validation command to `npx nx run
twenty-docs:validate`.

## Verification
```bash
$ npx -y mintlify validate --help
usage: mintlify validate [options]

Options:
  -t, --telemetry        Enable or disable anonymous usage telemetry   [boolean]
      --groups           Mock user groups for validation                 [array]
      --disable-openapi  Disable OpenAPI file generation
                                                      [boolean] [default: false]
  -h, --help             Show help                                     [boolean]
  -v, --version          Show version number                           [boolean]

Examples:
  mintlify validate  validate the build
```

```bash
$ npx -y mintlify build
Unknown command: build
```

I also started `npx -y mintlify validate --disable-openapi`; the CLI
recognized the command and began validating, but this Windows
environment could not finish Mintlify framework extraction because it
hit an EPERM symlink error inside the local `.mintlify` cache.
2026-05-15 09:40:11 +00:00
Charles BochetandGitHub 14acd77626 fix(docker): pin node:24-alpine to 24.15.0-alpine3.23 digest (#20603)
## Summary

- ECR Inspector flagged 9 CVEs on the `prod-twenty` image — 8 PostgreSQL
CVEs on `postgresql18-18.3-r0` (pulled in transitively by `apk add
postgresql-client`) and CVE-2026-27135 on `nghttp2-1.68.0-r0` (pulled in
by `curl` / `aws-cli`).
- Alpine 3.23 already ships patched `postgresql18-18.4-r0` and
`nghttp2-1.69.0-r0`, but the GHA buildx cache was reusing the stale `apk
add` layer because `FROM node:24-alpine` had not moved.
- Pinning the base image to `node:24.15.0-alpine3.23@sha256:8e2c930f…`
forces a layer cache miss, picks up the patched apk packages, and gives
Dependabot/Renovate a stable target for future digest bumps.

Applied to both
[packages/twenty-docker/twenty/Dockerfile](https://github.com/twentyhq/twenty/blob/charles/trusting-solomon-259ec8/packages/twenty-docker/twenty/Dockerfile)
(4 stages → ECR `prod-twenty`) and
[packages/twenty-docker/twenty-website-new/Dockerfile](https://github.com/twentyhq/twenty/blob/charles/trusting-solomon-259ec8/packages/twenty-docker/twenty-website-new/Dockerfile)
(2 stages).

## Test plan

- [ ] CI builds both images successfully on amd64 + arm64
- [ ] After merge + deploy, re-run ECR Inspector on the new
`prod-twenty` image and confirm the 9 CVEs
(CVE-2026-6473/6474/6475/6476/6477/6478/6479/6637 + CVE-2026-27135) are
gone
- [ ] Smoke-test the staging deployment (server boot, DB migrations via
`psql` in the entrypoint)
2026-05-15 09:27:23 +00:00
Félix Malfait 858ae62b67 refactor(filters): split advanced-filter sub-menu, derive relation target on demand
- RecordFilter only carries relationTargetFieldMetadataId; derive label/type at render
- Split AdvancedFilterSubFieldSelectMenu into Composite + RelationTargetField components
- Dedicated objectFilterDropdownIsSelectingRelationTargetField signal (no RELATION sentinel)
- mapViewFiltersToFilters takes a single fieldMetadataItems list
- Server view-query-params and common-group-by stop embedding resolved relation target
- Lift focus-stack push out of useSelectFieldUsedInAdvancedFilterDropdown
2026-05-15 11:09:10 +02:00
Abdullah.andGitHub d94d2eb67c [Website] Make product stepper visuals interactive. (#20602)
We had low-res screenshots for each step in the stepper. Replaced them
with interactive components.


https://github.com/user-attachments/assets/d03ff924-a1dd-467f-ba19-cece0ecb3486
2026-05-15 08:58:22 +00:00
Charles BochetandGitHub 45bea6f991 feat(secret-encryption): drop APP_SECRET from approved-access-domain validation and session cookies (#20580)
## Summary

Continues retiring `APP_SECRET` as a hot signing secret (after the TOTP
migration in #20577). This PR moves the last two cryptographic uses of
`APP_SECRET` off it:

1. **Approved-access-domain validation tokens** — was a one-shot
`sha256(JSON.stringify({id, domain, key: APP_SECRET}))` HMAC with no
built-in expiry. Now a JWT signed by the workspace `signingKey` with a
7-day expiry and claims bound to `approvedAccessDomainId`,
`workspaceId`, and `domain`.
2. **Express-session cookie signing** — was `sha256(APP_SECRET ||
'SESSION_STORE_SECRET')`. Now `HKDF(ENCRYPTION_KEY,
info='twenty:hmac:v1:session-cookie')` with `FALLBACK_ENCRYPTION_KEY`
supported for rotation.

### Approved-access-domain — strict cutover

- `ApprovedAccessDomainService.mintValidationToken` issues a JWT via
`JwtWrapperService.signAsyncOrThrow` (workspace `signingKey`, asymmetric
ES256 with kid-based rotation built in).
- `validateApprovedAccessDomain` verifies the JWT, asserts `type ===
APPROVED_ACCESS_DOMAIN`, cross-checks `claim.approvedAccessDomainId`
against the URL's `approvedAccessDomainId`, then re-checks `domain` and
`workspaceId` against the stored row. Any failure maps to
`APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID`.
- **No legacy fallback:** any pending invitation link minted with the
old SHA hash will fail validation and must be re-sent. Volume is small
and admins can re-issue from settings — this is the cleanest cutover.

### Session cookies — bridged cutover

- `resolveSessionCookieSecretsOrThrow` returns an array
`[HKDF(ENCRYPTION_KEY), HKDF(FALLBACK_ENCRYPTION_KEY)?,
sha256(APP_SECRET || 'SESSION_STORE_SECRET')?]`.
- `express-session` signs new cookies with the first secret and verifies
against any entry, so in-flight cookies signed under the legacy SHA keep
verifying until `maxAge` (30 min) expires.
- New `deriveInstanceHmacKey` HKDF utility uses a dedicated
`twenty:hmac:v1:` info prefix — distinct from the AEAD subkey prefix
`twenty:enc:v2:` — so HMAC and encryption subkeys can never collide for
the same raw `ENCRYPTION_KEY`.
- TODO comment marks the legacy slot for removal post-2.5.

### Notes on rotation behaviour

- Rotating `ENCRYPTION_KEY` while keeping the old value in
`FALLBACK_ENCRYPTION_KEY` keeps cookies signed under either key
verifying. New cookies sign under the new key. After all in-flight
cookies expire (≤30 min), the fallback slot can be dropped from env.
- Rotating the workspace `signingKey` (already supported by
`JwtKeyManagerService`) keeps already-issued approved-access-domain JWTs
verifying via `kid` until their 7-day expiry.

## Test plan

- [x] Unit tests for `ApprovedAccessDomainService` cover: happy path,
JWT verify failure, wrong token type, JWT id ≠ input id, JWT-claimed
domain ≠ row, missing row, already-validated row.
- [x] Unit tests for `resolveSessionCookieSecretsOrThrow` cover: throws
without keys, primary order (`ENCRYPTION_KEY` → APP_SECRET fallback),
`FALLBACK_ENCRYPTION_KEY` placement, empty-string vars treated as unset,
legacy slot omitted when `APP_SECRET` missing, HKDF domain separation
across purposes.
- [x] `nx lint:diff-with-main twenty-server` — clean.
- [x] Full test surface across approved-access-domain,
secret-encryption, session-storage — 78/78 pass.
- [ ] CI green.
- [ ] Manual smoke: boot with a dummy `ENCRYPTION_KEY`, confirm sign-in
succeeds (session cookie works), create + validate an
approved-access-domain end-to-end through the UI.
2026-05-15 08:47:46 +00:00
Félix Malfait 41239c9f0e refactor(twenty-front): move sub-menu type / constant out of state, extract relation sub-menu
Two review nits:

1. `RELATION_SUB_MENU_FIELD_TYPE` and `ObjectFilterDropdownSubMenuFieldType`
   lived inside the state file. They aren't state — moved to
   `object-filter-dropdown/constants/` and `record-filter/types/`
   respectively, alongside the rest of the constants and types.

2. The relation sub-menu branch in `AdvancedFilterSubFieldSelectMenu`
   computed `targetObjectMetadataId` with an empty-string sentinel so
   the always-running `useFilterableFieldMetadataItems` hook could
   accept it. Fragile (the `''` sentinel is unnamed, untyped, and
   relies on the selector silently returning `[]`). Extracted the
   relation branch into its own `AdvancedFilterRelationSubMenu`
   component that only mounts when actually in relation mode — the
   hook now always receives a real object id, no sentinel.

Also: turned `isManyToOneRelationField` into a generic type guard so
callers can read `field.relation.targetObjectMetadata.id` after the
check without a non-null assertion.
2026-05-15 10:32:20 +02:00
Félix Malfait 49633515d2 fix(twenty-front): treat composite and relation sub-menus symmetrically
Per review feedback: the relation sub-menu case wasn't actually different
from the composite sub-menu case — they're both "another dropdown that
manages its own focus, source-field push would shadow it." The previous
implementation skipped the push only for relation traversal because that
was the only case where the smell was visible (composite source fields
aren't RELATION/SELECT so the original conditional push didn't fire for
them). Generalising:

- AdvancedFilterFieldSelectMenu: skipFocusPush = subMenuType !== null
- AdvancedFilterSubFieldSelectMenu: skipFocusPush = true (always —
  the next dropdown is for a sub-field or target field, never the source).
2026-05-15 10:19:28 +02:00
dd9027680e chore: sync AI model catalog from models.dev (#20601)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-05-15 09:00:29 +02:00
Félix Malfait 90a1381c86 fix(twenty-front): skip focus-stack push when relation traversal opens sub-menu
useSelectFieldUsedInAdvancedFilterDropdown was pushing fieldMetadataItem.id
onto the focus stack whenever the source field was RELATION or SELECT —
originally for the entity / option picker that opens next. With relation
traversal, a RELATION source now opens the sub-menu instead, and the
sub-menu's SelectableList uses advancedFilterFieldSelectDropdownId as its
focusId. The mismatched push at the top of the stack shadowed the
sub-menu's hotkey scope and disabled keyboard navigation.

Add a skipFocusPush opt-out, set by:
- AdvancedFilterFieldSelectMenu when the next step is the relation
  sub-menu (case 1, entering)
- AdvancedFilterSubFieldSelectMenu when committing with a resolved
  relationTargetFieldMetadataItem (case 2, leaving — the target's value
  picker will set up its own focus when opened)

Role-permission callers keep the original push behaviour (they go
directly to a value picker, no sub-menu).
2026-05-15 07:31:39 +02:00
Félix Malfait d947fed809 fix(twenty-server): align relationTargetFieldMetadata ON DELETE to CASCADE
The other foreign keys on viewFilter referencing fieldMetadata / view /
viewFilterGroup all use ON DELETE CASCADE. The new column was using
SET NULL, which required a runtime "drop filter when target gone" path
and left a half-broken row in the table. Align to CASCADE so a deleted
field cleans up its filters atomically, matching the pre-existing
convention on this entity.
2026-05-15 07:11:38 +02:00
Félix Malfait d39a2d00c1 fix(twenty-server): apply relationTargetFieldMetadataId on widget filter update
In ViewWidgetUpsertService.computeViewFilterOperations the hasChanged
branch correctly detected a change to relationTargetFieldMetadataId but
the entry pushed to filtersToUpdate only spread existingFilter and never
applied the new value (or recomputed its universal identifier). The user's
change was silently ignored. Update path now mirrors the create branch:
includes relationTargetFieldMetadataId and resolves the matching
universal identifier alongside the source field and view-filter-group.
2026-05-14 22:49:10 +02:00
Félix Malfait 68d513662c test(twenty-server): integration test for relation-traversal view filter round-trip
Asserts that the metadata GraphQL Create + Update mutations correctly
persist and surface `relationTargetFieldMetadataId` on view filters:

- create with relationTargetFieldMetadataId set → returns it on the DTO
- create without it → defaults to null
- update relationTargetFieldMetadataId → new value is reflected on the DTO
- update an unrelated property (value) → relationTargetFieldMetadataId
  is preserved (regression test for the universal-identifier-recompute
  fix in fromUpdateViewFilterInputToFlatViewFilterToUpdateOrThrow)

Uses the standard Person.company MANY_TO_ONE relation pointing at
Company.name / Company.domainName so the test runs against seed data.
2026-05-14 22:44:23 +02:00
Félix Malfait 24884e9a27 fix: handle relation traversal in update flow, group-by runner, value reopen
- from-update-view-filter-input-to-flat-view-filter-to-update-or-throw:
  recompute relationTargetFieldMetadataUniversalIdentifier when the
  editable property changes. Without this, workspace migration sync
  diffs would miss or mishandle relation-target changes on existing
  filters (Create already handled this; Update did not).

- common-group-by-query-runner.service: when converting saved view
  filters to record filters for dashboard widget aggregates, look up
  the relation target field and embed it on the RecordFilter (same
  pattern as view-query-params.service). Without this, saved relation
  traversal filters are silently dropped from group-by queries.

- AdvancedFilterValueInput: when reopening a filter chip's value
  dropdown, restore relationTargetFieldMetadataIdUsedInDropdown from
  the filter so the operand picker reflects the target's type (not
  the relation field's RELATION type).

- useGetRecordFilterDisplayValue: read SELECT / MULTI_SELECT options
  from the target field (when relation traversal is in play) so chip
  labels show the target field's option labels, not the source's.
2026-05-14 22:33:05 +02:00
Félix Malfait 3040e75e97 refactor: embed resolved relation target on RecordFilter
Replace the cross-object field lookup that bled into ~9 callers with a
self-contained filter shape: RecordFilter now carries an optional
relationTargetField: { id, name, type, label } resolved at construction
time. The shared turnRecordFilterIntoRecordGqlOperationFilter reads the
target directly off the filter and injects it into the recursive lookup
array, so downstream callers no longer need to pass the flattened
cross-object metadata.

Effect on the diff:
- RecordFilter shape gains relationTargetField (shared + frontend types).
- turnRecordFilterIntoRecordGqlOperationFilter uses recordFilter.
  relationTargetField directly; no caller-supplied lookup needed.
- Filter construction sites embed the resolved target:
    mapViewFiltersToFilters (frontend),
    useSelectFieldUsedInAdvancedFilterDropdown (frontend),
    view-query-params.service.ts (server).
- Revert 9 consumer callers to objectMetadataItem.fields:
    useFindManyRecordIndexTableParams, useGraphWidgetQueryCommon,
    useRecordIndexGroupCommonQueryVariables,
    useRecordIndexGroupsAggregatesGroupBy,
    RecordTableEmptyHasNewRecordEffect,
    useAggregateRecordsForRecordTableColumnFooter,
    RecordTableVirtualizedSSESubscribeEffect,
    useGetRecordIndexTotalCount, useRecordCalendarQueryDateRangeFilter.
- computeContextStoreFilters drops its optional flattened-fields param;
  reverts 5 callers (useUpdateMultipleRecordsActions,
  useRecordIndexLazyFetchRecords, useFindManyRecordsSelectedInContextStore,
  RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect,
  buildHeadlessCommandContextApi).
- getQueryVariablesFromFiltersAndSorts reverts to single-object fields.
- mapRecordFilterToViewFilter derives the persisted id from
  recordFilter.relationTargetField?.id.

Net −79 LOC and the filter is now self-contained.
2026-05-14 22:27:27 +02:00
Félix Malfait 4a37f654f8 fix: prevent silent fallback when relation-traversal filter target missing
- turnRecordFilterIntoRecordGqlOperationFilter now drops the filter instead
  of falling through to the legacy relation-by-record path when
  relationTargetFieldMetadataId is set but the target field isn't in the
  provided fieldMetadataItems. The legacy path parses the value as a UUID
  list, which would silently mishandle target-field values like "Acme" and
  could broaden destructive operations to every record.
- Update remaining callers of computeRecordGqlOperationFilter /
  computeContextStoreFilters to pass the flattened cross-object field list
  so the target field is resolvable: useGetRecordIndexTotalCount,
  getQueryVariablesFromFiltersAndSorts, useUpdateMultipleRecordsActions,
  useRecordIndexLazyFetchRecords, useFindManyRecordsSelectedInContextStore,
  RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect,
  buildHeadlessCommandContextApi, useRecordCalendarQueryDateRangeFilter.
- computeContextStoreFilters accepts an optional flattenedFieldMetadataItems
  param that falls back to objectMetadataItem.fields.
2026-05-14 21:54:59 +02:00
Félix Malfait 2dc23464e7 fix: propagate relationTargetFieldMetadataId server-side, fix prettier
- ViewQueryParamsService now carries relationTargetFieldMetadataId onto
  RecordFilter and includes the target field in the fields array passed to
  computeRecordGqlOperationFilter so server-side flows (dashboard widgets,
  workflows, etc.) execute the relation traversal branch instead of falling
  back to legacy relation-by-record matching. Add an integration-style spec
  asserting the nested filter shape.
- Prettier fix in filterSortableFieldMetadataItems.
2026-05-14 21:41:45 +02:00
Félix Malfait 06f8c8b5a7 refactor: address review feedback and simplify
Address PR review comments:
- Move isManyToOneRelationField to @/object-metadata/utils (global helper).
  Refactor prefillRecord, sanitizeRecordInput, filterSortableFieldMetadataItems
  and spreadsheetImportHasNestedFields to reuse it.
- Drop narrative/redundant comments on RecordFilter, ViewFilter shape,
  RELATION_SUB_MENU_FIELD_TYPE sentinel and relationTargetFieldMetadataIdUsed
  state — names are self-describing.

Simplifications:
- Unify the two select-filter handlers in AdvancedFilterSubFieldSelectMenu
  behind a single keyword-args entry point.
- Collapse isRelationDrillDown/isCompositeDrillDown flags in
  AdvancedFilterFieldSelectMenu into a single computed sub-menu type.
- Drop the redundant `'relationTargetFieldMetadataId' in viewFilter` check
  in mapViewFiltersToFilters now that ViewFilter shape carries the field.
- Drop the over-engineered useMemo around effectiveFieldMetadataItem in
  ObjectFilterDropdownInnerSelectOperandDropdown — the underlying lookup
  is a cheap dictionary read.
- Reuse the same relationTargetFieldMetadataId for both the filter object
  and the dropdown state setter in useSelectFieldUsedInAdvancedFilterDropdown.
2026-05-14 21:28:13 +02:00
Félix Malfait fd1b51ac03 fix(twenty-server): relocate relation-target-field migration to 2-6 directory
Main bumped current version to 2.6.0; the upgrade-mutation guard requires
new commands to live in the current version's directory. Move the file,
rename the timestamp prefix, and bump the @RegisteredInstanceCommand version.
2026-05-14 20:43:04 +02:00
Félix MalfaitandGitHub b805b962f1 Merge branch 'main' into feat/graphql-relation-traversal-filters-frontend 2026-05-14 20:37:53 +02:00
Charles BochetandGitHub ca1571676c fix(server): treat plaintext-under-isSecret rows as plaintext in app variable encryption migration (#20590)
## Summary

Prod 2.5 upgrade failed on the slow instance command
`EncryptApplicationVariableSlowInstanceCommand`:

```
[Nest] LOG  [InstanceCommandRunnerService] 2.5.0_EncryptApplicationVariableSlowInstanceCommand_1798000005000 starting data migration...
[Nest] WARN [SecretEncryptionService] Decrypted a legacy unprefixed AES-CTR ciphertext...
[Nest] ERROR [InstanceCommandRunnerService] data migration failed
TypeError: Invalid initialization vector
```

### Root cause

The migration assumes every row matching `isSecret = true AND value <>
'' AND value NOT LIKE 'enc:v2:%'` is legacy AES-CTR ciphertext. In prod
we found multiple `isSecret = true` rows whose `value` is plaintext
(e.g. `SLACK_HOOK_URL = 'https://hooks.slack.com/services/...'`) — most
likely the result of `isSecret` being flipped to true on a row that
already held a plaintext value, or a write path that bypassed
`ApplicationVariableEntityService.update`. Those values can't decode
into the 16-byte IV that AES-CTR needs, so `Buffer.from(value,
'base64')` truncates at the first non-base64 char (`:`), the buffer is <
16 bytes, and `createDecipheriv` throws.

### Fix

Follow the same policy as
`EncryptConnectedAccountTokensSlowInstanceCommand`: anything that isn't
already in the `enc:v2:` envelope is plaintext. Concretely:

1. Try `decryptVersioned` — legacy CTR rows decrypt fine.
2. If it throws (mis-classified plaintext), log a warning naming the row
id and fall back to treating `row.value` as plaintext.
3. Encrypt the resulting plaintext into the `enc:v2:` envelope and
update the row.

In-loop `isSecret` guard is kept (alongside the SQL filter) so
non-secret rows are never touched even if the SQL filter is ever
loosened.

### Integration test coverage

Added one new case alongside the existing ones in
`…encrypt-application-variable.integration-spec.ts`:

- `treats plaintext-under-isSecret=true as plaintext and re-encrypts as
v2` — seeds a row with `isSecret = true` and a URL value (`:` and `/`
are not base64, so this is the exact failure shape from prod), runs the
migration, and asserts the value is now `enc:v2:...` and decrypts back
to the original URL.

Existing cases unchanged: legacy CTR happy path, non-secret rows
untouched, idempotent across re-runs, `up()` adds the CHECK constraint,
`down()` removes it.

### Why this is a 2-5 edit

`TWENTY_CURRENT_VERSION` is now 2.6.0, so editing a 2-5 file trips the
`server-previous-version-upgrade-mutation-guard` —
`ci:allow-previous-version-upgrade-mutation` label is on the PR. `up()`
and `down()` are unchanged; only `runDataMigration` is modified.

## Test plan

- [ ] Re-deploy 2.5 to prod and confirm
`EncryptApplicationVariableSlowInstanceCommand` completes
- [ ] Inspect warning log to count rows that went through the plaintext
fallback
- [ ] Verify resulting secret rows all satisfy `value = '' OR value LIKE
'enc:v2:%'` and the CHECK constraint is in place
v2.5.1
2026-05-14 18:40:41 +02:00
Charles BochetandGitHub a5880bd8d0 fix(server): drop correlated subquery in getWorkspaceLastAttemptedCommandName (#20591)
## Summary
- The upgrade runner calls `getWorkspaceLastAttemptedCommandName` twice
per workspace step. Grafana showed it averaging ~4.4s and trending
upward as the `core.upgradeMigration` table grows during an in-flight
upgrade.
- The old query joined every outer row against a correlated subquery
(`attempt = (SELECT MAX(sub.attempt) ... WHERE sub.name = m.name AND
sub."workspaceId" = m."workspaceId")`). Even with the `(workspaceId,
name, attempt)` index added in 2.3, each outer row triggers an index
lookup — fine for a few rows, painful at production scale.
- Replaced with a two-level `DISTINCT ON`:
- Inner `DISTINCT ON ("workspaceId", name) ORDER BY "workspaceId", name,
attempt DESC` walks `IDX_UPGRADE_MIGRATION_WORKSPACE_ID_NAME_ATTEMPT`
directly and yields one row per `(workspaceId, name)` at max attempt.
- Outer `DISTINCT ON ("workspaceId") ORDER BY "workspaceId", "createdAt"
DESC` picks the most recent row per workspace.
- Semantically identical; planner now does a single index walk + one
sort instead of N correlated lookups.

The same correlated-subquery shape exists in
`getLastAttemptedCommandNameOrThrow`, `areAllWorkspacesAtCommand`, and
`getLastAttemptedInstanceCommand`. They run far less often during an
upgrade (per instance step, not per workspace step), so they're out of
scope for this hotfix — happy to follow up if we want them too.

## Benchmark (prod)

Run over all distinct workspaceIds in `core."upgradeMigration"`:

| Variant | Execution Time |
| --- | --- |
| Before (correlated subquery) | **2979.659 ms** |
| After (two-level DISTINCT ON) | **1225.690 ms** |

~2.4× faster, and the gap widens as the table grows over the course of
an upgrade.

Equivalence confirmed: the diff query below returned `0` divergent
workspaces on prod.

### Variant A — original (correlated subquery)

```sql
SELECT DISTINCT ON (m."workspaceId")
  m."workspaceId", m.name, m.status, m."executedByVersion",
  m."errorMessage", m."createdAt", m."isInitial"
FROM core."upgradeMigration" m
WHERE m."workspaceId" IN ($1, $2, ...)
  AND m.attempt = (
    SELECT MAX(sub.attempt)
    FROM core."upgradeMigration" sub
    WHERE sub.name = m.name
      AND sub."workspaceId" = m."workspaceId"
  )
ORDER BY m."workspaceId", m."createdAt" DESC;
```

### Variant B — new (two-level DISTINCT ON)

```sql
SELECT DISTINCT ON (latest_per_name."workspaceId")
  latest_per_name."workspaceId",
  latest_per_name.name,
  latest_per_name.status,
  latest_per_name."executedByVersion",
  latest_per_name."errorMessage",
  latest_per_name."createdAt",
  latest_per_name."isInitial"
FROM (
  SELECT DISTINCT ON ("workspaceId", name)
    "workspaceId", name, status, "executedByVersion",
    "errorMessage", "createdAt", "isInitial"
  FROM core."upgradeMigration"
  WHERE "workspaceId" = ANY($1)
  ORDER BY "workspaceId", name, attempt DESC
) latest_per_name
ORDER BY latest_per_name."workspaceId", latest_per_name."createdAt" DESC;
```

### Equivalence check (returned 0 on prod)

```sql
WITH target_ids AS (
  SELECT DISTINCT "workspaceId"
  FROM core."upgradeMigration"
  WHERE "workspaceId" IS NOT NULL
),
old_result AS (
  SELECT DISTINCT ON (m."workspaceId")
    m."workspaceId", m.name, m.status, m."executedByVersion",
    m."errorMessage", m."createdAt", m."isInitial"
  FROM core."upgradeMigration" m
  WHERE m."workspaceId" IN (SELECT "workspaceId" FROM target_ids)
    AND m.attempt = (
      SELECT MAX(sub.attempt)
      FROM core."upgradeMigration" sub
      WHERE sub.name = m.name
        AND sub."workspaceId" = m."workspaceId"
    )
  ORDER BY m."workspaceId", m."createdAt" DESC
),
new_result AS (
  SELECT DISTINCT ON (latest_per_name."workspaceId")
    latest_per_name."workspaceId", latest_per_name.name, latest_per_name.status,
    latest_per_name."executedByVersion", latest_per_name."errorMessage",
    latest_per_name."createdAt", latest_per_name."isInitial"
  FROM (
    SELECT DISTINCT ON ("workspaceId", name)
      "workspaceId", name, status, "executedByVersion",
      "errorMessage", "createdAt", "isInitial"
    FROM core."upgradeMigration"
    WHERE "workspaceId" IN (SELECT "workspaceId" FROM target_ids)
    ORDER BY "workspaceId", name, attempt DESC
  ) latest_per_name
  ORDER BY latest_per_name."workspaceId", latest_per_name."createdAt" DESC
),
diffs AS (
  SELECT 'only_in_old' AS bucket, o."workspaceId", o.name, o.status, o."createdAt"
  FROM old_result o
  LEFT JOIN new_result n ON n."workspaceId" = o."workspaceId"
  WHERE n."workspaceId" IS NULL OR n.name <> o.name OR n.status <> o.status
  UNION ALL
  SELECT 'only_in_new', n."workspaceId", n.name, n.status, n."createdAt"
  FROM new_result n
  LEFT JOIN old_result o ON o."workspaceId" = n."workspaceId"
  WHERE o."workspaceId" IS NULL OR o.name <> n.name OR o.status <> n.status
)
SELECT COUNT(*) AS divergent_workspaces FROM diffs;
```

## Test plan
- [ ] `npx nx test twenty-server --testPathPattern upgrade-migration`
- [ ] Integration tests: `npx nx run
twenty-server:test:integration:with-db-reset --testPathPattern
sequence-runner`
- [ ] Verify on staging that the slow query disappears from the
PostgreSQL Grafana board during the next upgrade run
2026-05-14 18:39:34 +02:00
Charles BochetandGitHub 78b3092886 fix(server): batch upgrade migration inserts to stay under PG param limit (#20588)
## Summary

Prod deploy of v2.5.0 fails with a query failure inserting into
`core.upgradeMigration`:

```
query failed: INSERT INTO "core"."upgradeMigration" ("id", "name", "status", "attempt", "executedByVersion", "errorMessage", "isInitial", "workspaceId", "createdAt")
VALUES (DEFAULT, $1, $2, $3, $4, $5, DEFAULT, $6, DEFAULT),
       (DEFAULT, $7, $8, $9, $10, $11, DEFAULT, $12, DEFAULT),
       ... (continues past $2515) ...
```

### Root cause

`UpgradeMigrationService.recordUpgradeMigration` writes one row per
workspace via a single `repository.save([...rows])` call.
`UpgradeMigrationEntity` has **6 user-provided columns** per row
(`name`, `status`, `attempt`, `executedByVersion`, `errorMessage`,
`workspaceId`), so the multi-row INSERT binds `6 * (1 + N_workspaces)`
parameters.

Postgres' wire protocol caps a single statement at **65,535 bind
parameters** (16-bit count). That gives a hard ceiling of ~10,920 rows
per call. Production has enough workspaces to overflow.
2026-05-14 18:30:38 +02:00
Charles BochetandGitHub 5a1d3841f4 Bump twenty-sdk, twenty-client-sdk, create-twenty-app to 2.5.0 (#20587)
## Summary

- Bumps `twenty-sdk` from `2.4.2` to `2.5.0`.
- Bumps `twenty-client-sdk` from `2.4.2` to `2.5.0`.
- Bumps `create-twenty-app` from `2.4.2` to `2.5.0`.
2026-05-14 17:14:31 +02:00
Félix Malfait 96a6a08a51 fix(twenty-server): bump viewFilter in children-first sort snapshot
Adding the relationTargetFieldMetadata many-to-one relation gives viewFilter
one more parent-pointing edge, which shifts it ahead of commandMenuItem/
fieldPermission/viewField in the children-first ordering.
2026-05-14 17:11:07 +02:00
94748b7042 chore: bump version to 2.6.0 (#20585)
## Summary

- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version

## Checklist

- [ ] Verify version constants are correct

Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
2026-05-14 15:02:38 +00:00
Félix MalfaitandGitHub 663ef332ad feat(auth): resume workspace selection on /welcome with valid tokenPair cookie (#20575)
## Summary

After a user completes a multi-workspace social-SSO sign-in,
[auth.service.ts:988-1011](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts#L988-L1011)
issues a **workspace-agnostic** access + refresh token pair and lands
them on `app.twenty.com/welcome?tokenPair=…`.
[SignInUpGlobalScopeFormEffect.tsx](packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx)
reads the URL param, writes the cookie, pushes them to
`SignInUpStep.WorkspaceSelection`.

The problem: if the user revisits `app.twenty.com/welcome` later (e.g.
ChatGPT pings `/authorize` and the global page-change effect redirects
them to `/welcome` with `returnToPath=/authorize?…`), the existing
branch is a no-op — the URL param is gone. The user sees the regular
email/SSO form and has to re-authenticate, even though the
workspace-agnostic cookie is still valid.

This PR adds a second branch in the same `useEffect` that handles the
"valid cookie, no URL param" case:

```ts
if (signInUpStep !== SignInUpStep.Init) return;
if (!hasAccessTokenPair) return;
loadCurrentUser();
setSignInUpStep(SignInUpStep.WorkspaceSelection);
```

Single `useEffect`, no `useRef`, no async then/catch. The synchronous
`setSignInUpStep(WorkspaceSelection)` is the gate — once the step
transitions, subsequent effect runs early-return. Mirrors the existing
URL-param branch's pattern exactly.

If the cookie is stale, `loadCurrentUser` triggers Apollo's renewal
middleware. Renewal of a workspace-agnostic refresh token is supported
end-to-end (verified in audit, see below) — if it succeeds the user sees
their workspaces; if both tokens are expired, `onUnauthenticatedError`
clears the cookie and the next render lands them on the regular sign-in
form. Same fallback as if the cookie had never been there.

## Behavior matrix

| State on /welcome mount | Before | After |
|---|---|---|
| No tokenPair anywhere | Show sign-in form | Show sign-in form |
| tokenPair in URL (just bounced from SSO) | Set tokens →
WorkspaceSelection | (unchanged) Set tokens → WorkspaceSelection |
| tokenPair in cookie, access valid | Show sign-in form  | **→
WorkspaceSelection ✓** |
| tokenPair in cookie, access expired, refresh valid | Show sign-in form
(Apollo eventually 401s on a query) | Renewal succeeds silently →
WorkspaceSelection ✓ |
| tokenPair in cookie, both expired | Show sign-in form |
`onUnauthenticatedError` clears cookie → fall back to sign-in form |

## Workspace-agnostic renewal: confirmed working end-to-end

Audit summary:
- **Refresh token carries the type**:
[refresh-token.service.ts:104](packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts)
preserves `targetedTokenType` in the JWT payload and returns it from
`verifyRefreshToken`.
- **Renewal branches on type**
([renew-token.service.ts:70-87](packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts)):
  ```ts
  const accessToken =
    isDefined(authProvider) &&
    targetedTokenType === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC &&
    !isDefined(workspaceId)
? await
this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken({...})
      : await this.accessTokenService.generateAccessToken({...});
  ```
  Renewed refresh token preserves `targetedTokenType` (line 93).
- **Resolver is workspace-agnostic**: `@UseGuards(PublicEndpointGuard,
NoPermissionGuard)` on `renewToken`
([auth.resolver.ts:796-804](packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts))
— no `@AuthWorkspace()` requirement, callable from `app.twenty.com`.
- **Frontend middleware is type-agnostic**:
[apollo.factory.ts:180-209](packages/twenty-front/src/modules/apollo/services/apollo.factory.ts)
just passes the refresh token blob.

Net: no backend change needed. The full workspace-agnostic lifecycle
(issue → cookie → renew → re-issue) already works.

## Test plan

- [x] `npx oxlint` + `prettier --check` — clean.
- [x] `npx nx typecheck twenty-front` — clean.
- [ ] Manual: complete one full SSO flow ending on a workspace
subdomain. Visit `https://app.twenty.com/welcome` directly — expect the
workspace picker, not the sign-in form.
- [ ] Manual: same but with tokenPair cookie cleared — expect the
regular sign-in form (no regression).
- [ ] Manual: sign-out from a workspace, then visit
`app.twenty.com/welcome` — expect the regular form (sign-out clears the
cookie via full page reload).
- [ ] Manual: stale/expired tokenPair cookie — Apollo renewal kicks in
transparently; if renewal fails, regular form (no infinite loop, no
crash).
- [ ] Manual: pair with #20572 — visit `app.twenty.com/authorize?…` with
a stale workspace-agnostic cookie. Expected chain: `/authorize` renders
→ `PageChangeEffect` redirects to `/welcome?returnToPath=/authorize?…` →
this effect lands the user on WorkspaceSelection → picking a workspace
bounces to `<workspace>/authorize?…` where consent renders.

## Out of scope

- Fixing `lastAuthenticatedWorkspaceDomain` for custom-domain users
(separate cookie-scoping issue, tracked separately).
2026-05-14 15:02:07 +00:00
Félix Malfait 0153ea3887 fix: align generated graphql DocumentNodes, view-filter test, and properties snapshot
- Add relationTargetFieldMetadataId to 17 ViewFilter DocumentNode constants
  (codegen was missing these AST entries even though the TS shapes had the
  field).
- Update useApplyCurrentViewFiltersToCurrentRecordFilters expectation to
  include relationTargetFieldMetadataId: null in the produced RecordFilter.
- Add relationTargetFieldMetadataUniversalIdentifier to viewFilter
  propertiesToCompare snapshot.
2026-05-14 16:53:15 +02:00
Félix Malfait f3211f1d52 fix(twenty-front): remove relationTargetFieldMetadataId from ViewSort generated shapes
Earlier patch used an overly broad sed that also matched ViewSort inline
shapes (since ViewSort also has subFieldName, viewId). ViewSort doesn't
have this column - only ViewFilter does.
2026-05-14 16:26:08 +02:00
Félix MalfaitandGitHub e97f466fe6 Merge branch 'main' into feat/graphql-relation-traversal-filters-frontend 2026-05-14 16:11:47 +02:00
Félix Malfait 6076a79bda fix: regen generated graphql + SDK metadata client for relationTargetFieldMetadataId
The ViewFilter DTO / inputs gained `relationTargetFieldMetadataId` in
this PR but I had forgotten to re-run the schema generators that mirror
the GraphQL surface into typed clients:

- twenty-front/src/generated-metadata/graphql.ts — add the column to
  CreateViewFilterInput, UpdateViewFilterInputUpdates,
  UpsertViewWidgetViewFilterInput, the ViewFilter type, and the 22+
  inline `Array<{ __typename: 'ViewFilter', ... }>` shapes used by
  query/mutation result types and fragments.
- twenty-client-sdk/src/metadata/generated/{schema.graphql,schema.ts,
  types.ts} — same field on each of the four shapes (type +
  Create / UpdateUpdates / UpsertViewWidget inputs) plus the
  ViewFilterGenqlSelection helper and the four introspection-data
  entries in types.ts.

Diff matches what the codegen+sdk generators would emit (validated
field-by-field against the diff CI's server-validation step
produced).
2026-05-14 16:06:44 +02:00
Charles BochetandGitHub 09daccc3f9 fix(server): add subFieldName column early in upgrade sequence (#20584)
## Summary

Cross-version upgrades from pre-2.3 still fail after #20581 / #20583 —
different column, structurally similar problem:

```
column ViewSortEntity.subFieldName does not exist
  at WorkspaceFlatViewSortMapCacheService.computeForCache (...flat-view-sort/services/workspace-flat-view-sort-map-cache.service.js:40)
  ... triggered indirectly by DropMessageDirectionFieldCommand (2.3 workspace command)
```

(see
https://github.com/twentyhq/twenty-infra/actions/runs/25862573418/job/75997337604)

### Why narrowing the `select` doesn't fit here

In the previous two PRs the offender was a bare `findOne` on
`WorkspaceEntity` — easy to narrow. Here the chain is:

1. The 2.3 `DropMessageDirectionFieldCommand` builds a workspace
migration that deletes a `fieldMetadata` (the `direction` field).
2. `WorkspaceMigrationRunnerService.run` walks the metadata cascade
graph (`getMetadataRelatedMetadataNames`) and pulls `viewSort` into the
dependency set because `viewSort` is the inverse one-to-many of
`fieldMetadata` (deleting a field cascades to view sorts that reference
it).
3. That maps to cache keys → `flatViewSortMaps` gets requested →
`WorkspaceFlatViewSortMapCacheService.computeForCache` runs.
4. `computeForCache` does `viewSortRepository.find({ where: {
workspaceId }, withDeleted: true })` with no `select`, so TypeORM emits
a SELECT that includes `subFieldName` — the column doesn't exist in DB
yet (added by a 2.5 instance command much later in the sequence). 💥

Narrowing the cache provider's select would silently drop `subFieldName`
from the cache for runtime use too, until something invalidates it.
Brittle, and would re-break the next time anyone adds a `viewSort`
column.

### Structural fix

Ensure the column exists in DB before any 2.3 workspace command can
trigger that cascade. Within a version, the upgrade runner sorts: fast
instance → slow instance → workspace, so a new 2.3 fast instance command
lands before `DropMessageDirectionFieldCommand`.

- **Add**
`2-3/2-3-instance-command-fast-1747234200000-add-sub-field-name-to-view-sort.ts`
— `ALTER TABLE ... ADD COLUMN IF NOT EXISTS "subFieldName"`. Comment in
the file explains the cascade and why this lives in 2.3 instead of 2.5.
- **Make idempotent** the existing
`2-5/...-add-sub-field-name-to-view-sort.ts` — switched to `ADD COLUMN
IF NOT EXISTS` / `DROP COLUMN IF EXISTS` so it's a no-op on
cross-upgrade paths while still creating the column on fresh-from-2.5
installs.
- Register the new command in `instance-commands.constant.ts`.

The 2.5 command body change is semantically preserving (idempotent), and
v2.5.0 hasn't shipped to any production DB yet — so this doesn't violate
the "never rewrite committed instance commands" rule in spirit.

### Note on the previous two PRs

#20581 and #20583 narrowed `select` on `WorkspaceEntity` for
`isInternalMessagesImportEnabled`. That's a band-aid that works because
there's a small, enumerable set of bare `workspaceRepository.findOne`
call sites. It could in principle be replaced with the same pattern as
this PR (early 2.x instance command that adds the workspace column). Not
doing that here to keep the diff tight, but happy to follow up if
preferred.

## Test plan

- [ ] Re-run twenty-infra cross-version-upgrade CI and confirm 2.3
workspace commands complete
- [ ] Verify the new 2.3 instance command and the modified 2.5 instance
command are both idempotent (running upgrade twice should not error)
- [ ] Verify a fresh install path still ends with `subFieldName` present
on `core.viewSort`
v2.5.0
2026-05-14 16:02:53 +02:00
Félix Malfait 1f4225fb83 fix(twenty-server): align relation-target-field migration with TypeORM's expected SQL
The server-validation step (`database:migrate:generate`) emits a pending
migration whenever the entity-derived schema differs from what existing
migrations produced. The original migration drifted in three ways that
TypeORM's generator flagged:

- FK constraint name was a human-readable
  `FK_VIEW_FILTER_RELATION_TARGET_FIELD_METADATA_ID` instead of the
  hash that `DefaultNamingStrategy.foreignKeyName` produces (sha1 of
  `<tableName>_<columns>` truncated to 27 chars) →
  `FK_dbe259395cbd9a54c1c17d12b0b`.
- The FK lacked `ON UPDATE NO ACTION`, which TypeORM emits by default.
- Operation order was column → FK → index; TypeORM emits
  column → index → FK.

Also dropped the `IF NOT EXISTS` guards on column / index / constraint
since TypeORM-generated migrations don't use them.
2026-05-14 15:43:49 +02:00
Félix MalfaitandGitHub 36aadfa649 Merge branch 'main' into feat/graphql-relation-traversal-filters-frontend 2026-05-14 15:33:33 +02:00