Commit Graph

13710 Commits

Author SHA1 Message Date
Rashad Karanouh e6c6cccafa v1.3.0 — Partner workspace self-service (glowup app) (#22929)
## Glowup — app · v1.3.0 (Release ② of the brief + glowup rollout)

Partner **workspace self-service**: partners manage their own profile,
links, services, and case studies from inside the CRM (new objects +
record-page views + a "My Profile" self-service front-component).
Evolved superset of the closed #22470 (v1.3.0). App-only — **0 website
files**.

Version **1.3.0** (prod is currently 1.2.10). SDK **2.19.0**.

Supersedes **#22470** (closed).

### Verified locally
Provisioned a throwaway workspace, synced the schema, seeded, and
exercised the full surface end-to-end: marketplace + public profiles
render live; **partner self-service pages** (My Profile / My Case
Studies / links / services) load and save when acting as a partner user;
both intake forms (partner application + client brief) submit
successfully. `oxlint` 0/0, typecheck clean.

### Notes
- Committed `APPLICATION_UNIVERSAL_IDENTIFIER` is the **canonical** prod
id `e662fc1f-02c1-41ff-b8ba-c95a447b3965` (local bundle rewrites it to a
throwaway that stays uncommitted).
- New views reference app-owned fields only — no hardcoded system-field
ids.

### Remaining before merge
- CI lint / typecheck / tests (green locally).
- Refresh the partners-doc (new objects/views change the app surface).

---

## 🚦 Release order — do not break

```
① BRIEF WEB — #22291   MERGED (website deploy pending prod CLIENT_BRIEF_* env vars)
        │
        ▼
② GLOWUP APP — THIS PR  (rk-partner-profile-page v1.3.0 → main)   ⟵ replaces #22470
   merge → DEPLOY TO PROD  (verify canonical id first,
                            yarn twenty deploy && install -r partner-twenty-com)
         → set new app variables on prod → refresh partners-doc
        │   ⟵⟵ GATE for ③ ⟵⟵
        ▼
③ GLOWUP WEB — rk-glowup-web-stacked  (reopen ONE PR, base main; was #22471 / #22402)
   ONLY after ② is LIVE on prod (the site reads the new links / services / case-study objects)
```

- ② gates only ③. After ② deploys, reconcile **#22637**
(partners-traffic-web) with ③ — both touch `partners-marketplace/*`.
2026-07-20 15:25:07 +02:00
Paul Rastoin 6b55a6b51c Fix duplicate searchFieldMetadata inserts in the 2.16 backfill upgrade command (#23060)
## Context

A self-hosted instance upgrading from 2.0.3 to v2.22.0 got stuck with
one workspace failing at `2.16.0_BackfillSearchFieldMetadataCommand`:

```
[QueryFailedError] duplicate key value violates unique constraint "IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE"
Detail: Key ("objectMetadataId", "fieldMetadataId")=(...) already exists.
```

The failure happened on a retry after a previous partial run, and
reproduced even though the command already recomputes
`flatSearchFieldMetadataMaps` before deriving the create-set (#22884).

## Root cause

The idempotency dedupe compares `(objectMetadataId, fieldMetadataId)`
pairs across two differently-fresh caches:

- The **existing rows** side comes from `flatSearchFieldMetadataMaps`,
which is recomputed from the database (real current ids).
- The **candidate** side resolves ids through `flatObjectMetadataMaps` /
`flatFieldMetadataMaps`, which are **not** invalidated. During a
cross-version upgrade these can be stale, since the migration runner
only invalidates the cache keys a migration touched.

When a stale map resolves a candidate to an outdated id, the dedupe key
doesn't match the existing row and the row is re-emitted. The migration
runner then re-resolves the universal identifiers against fresh maps at
execution time and inserts with the real current ids — exactly the pair
already committed by the earlier partial run (each per-application
migration commits independently) — tripping the unique constraint and
failing the upgrade.

## Fix

Two independent layers, either of which would have prevented the
failure:

1. **Consistent snapshot for the build phase**: the command now
invalidates and recomputes all three maps the dedupe depends on
(`flatObjectMetadataMaps`, `flatFieldMetadataMaps`,
`flatSearchFieldMetadataMaps`), so candidate resolution, existing-row
keys, and the runner all see the same database state.
2. **Id-churn-proof dedupe**: every row this command creates carries a
deterministic universal identifier (`getSearchFieldUniversalIdentifier`,
derived from application + field universal identifiers, no database ids
involved) and `(workspaceId, universalIdentifier)` is unique. The build
util now also skips any candidate whose deterministic universal
identifier already exists, catching leftovers from a previous partial
run even if objects/fields were recreated under new ids in between.

Deliberately **not** done: `ON CONFLICT DO NOTHING` in the create action
handler — it is shared by all runtime `searchFieldMetadata` creation,
and swallowing a conflict would leave the flat-entity cache holding an
entity id that differs from the row actually in the database.

## Test

Added a regression test reproducing the failure shape: an existing row
with the same deterministic universal identifier but stale metadata ids
must not be re-emitted by the backfill.

Note: `ReconcileSearchFieldMetadataCommand` (2.20) has the same
stale-cache exposure; hardening it is left to a follow-up.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23060?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 13:23:26 +00:00
Thomas Trompette 98c71d7b3d fix(server): recover workflow runs whose queue job was lost - monitoring only (#22995)
## Context

A workflow run can get permanently stuck in RUNNING when the queue job
executing a step dies without failing (worker crash/restart, lost BullMQ
job). The step stays `RUNNING` in the persisted state, nothing ever
recomputes the run status, and the run never terminates. If a user
clicks Stop, it wedges in STOPPING instead (the stuck-STOPPING sweeper
from #22900 then catches it after 1h, but only because of the manual
stop). Self-hosters also have no way to tell that a job was lost, or
when.

## What this PR does

### Detect runs stuck in RUNNING (monitoring only, no finalization yet)

New `handleStuckRunningRunsForWorkspace` in the staled-runs sweeper
(same cron/job/CLI wiring as the stuck-STOPPING recovery):

- Targets RUNNING runs with `updatedAt` older than 1h (`updatedAt`
refreshes on every step-info write, so staleness means zero progress).
- Skips any run that still has a job in the queue: new `getInFlightJobs`
on the message queue driver
(active/waiting/waiting-children/paused/prioritized/delayed), matched by
run-id-prefixed job id with a `job.data.workflowRunId` fallback for jobs
enqueued before this deploys.
- A truly orphaned run (orphaned RUNNING step, lost between two steps,
failed branch, or finished-but-never-finalized) is **flagged, not
finalized**: warn log + `WorkflowRunStuckRunningDetected` metric + entry
in a per-workspace cache.
- On every subsequent sweep, flagged runs are re-checked. One that ended
or got a new queue job on its own is recorded as
`WorkflowRunStuckRunningFalsePositive` (warn log with the status it
reached) and unflagged. The cron keeps sweeping a workspace as long as
it has flagged runs.

This validates the detection before it is allowed to act: if flagged
runs never resolve on their own (no false positives) while `Detected`
counts real incidents, a follow-up PR can turn the flag into an actual
finalization (fail with a clear "job lost" error so Retry works). Runs
waiting on PENDING steps (delay, form) are never flagged.

### Make queue jobs traceable to their run

All RunWorkflowJob dispatches now set the job id prefix to the workflow
run id, so BullMQ job ids become `<workflowRunId>-<uuid>`. Worker logs
(`Processing job <id>` / `processed`) and Redis job keys are now
greppable by run id. A new opt-in `allowDuplicatedPrefixes` queue option
bypasses the one-waiting-job-per-id dedup (which would otherwise drop
parallel-branch continuations); existing `id` users keep dedup by
default.

### Observability

- `stalled` worker event listener: warn log + new `JobStalled` metric —
emitted when BullMQ detects a job whose worker stopped renewing its lock
(i.e. died mid-job).
- `WorkflowRunStuckRunningDetected` /
`WorkflowRunStuckRunningFalsePositive` metrics as described above.

## Out of scope (follow-ups)

- Actually finalizing flagged runs once monitoring shows no false
positives.
- Recovering lost *delayed* resume jobs (PENDING delay step whose
scheduled job vanished).
- Persisting job ids on the run entity — unnecessary given derived ids.

## Testing

- 11 unit tests for the monitoring sweeper (flagging, id-prefix + data
fallback in-flight guards, pending skip, failed-branch precedence,
false-positive tracking, still-stuck retention, error isolation,
never-finalizes) plus find-options specs; 613 tests pass across workflow
and message-queue modules.
- Not covered: end-to-end kill-the-worker scenario against a real queue.
2026-07-20 14:21:33 +02:00
Thomas Trompette 87729a2822 feat(workflow): backfill + dual-write for core workflow entity (#22776)
Workflow-side soft-ref sync — the `coreWorkflowId` mirror of the merged
version side (#22821 / #22940 / #22944 / #22961). Rebased onto current
main; supersedes the original shared-UUID version of this PR.

## What
Gives `core.workflow` a per-workspace copy of each workflow (`name`,
`lastPublishedVersionId`), soft-reffed from the workspace record via
`coreWorkflowId`, so app-shipped workflows have a core home. Does not
touch reads/dispatch (that's Phase B).

- **Sync service** (`WorkflowCoreSyncService`): core rows get their own
id (`uuidv5(workspaceId:recordId)`), the workspace record links via
`coreWorkflowId` (written back after the upsert), and the write-back is
**guarded** on the `coreWorkflowId` field being present (skips with a
warning otherwise — mirrors #22940). Injected repo renamed
`coreWorkflowRepository`.
- **Dual-write listener** on the `workflow` object:
CREATED/UPDATED/RESTORED upsert, DELETED/DESTROYED delete by
`coreWorkflowId`. Always-on; failures routed to Sentry so they never
break the user write.
- **2-20 backfill** (`backfill-workflow-to-core`): reads via the
provided `RunOnWorkspaceArgs.dataSource`, upserts each workspace
workflow into core.
- **2-22 provisioning** (mirrors #22944/#22961):
- `add-workflow-core-soft-ref-field`: adds the `coreWorkflowId` system
field on existing workspaces (flat-entity legacy migration).
- `backfill-workflow-core-links`: full rebuild — per workspace, in one
raw-SQL transaction, wipes all `core.workflow` rows, inserts a fresh
own-id row per workflow, and re-links every record. (No trigger-map
cache to invalidate on `core.workflow`.)

Simpler than the version side: `core.workflow` has no
one-active-per-workflow index and no trigger-map cache, and it was never
backfilled in prod, so there are no legacy shared-id rows.

## Test
Fresh `database:reset` + full sequence (2-20 backfill → 2-22 add-field →
2-22 rebuild link): 4/4 workspace records linked via `coreWorkflowId`
(id != coreWorkflowId), links resolve, **0 dangling**, no duplicate core
rows, names populated. Typecheck + lint + oxfmt clean.
2026-07-20 14:04:32 +02:00
neo773 c04714b9e0 fix(messaging): register and harden webhook subscription renewal cron (#23006)
The renewal cron was never wired into cron:register:all, so
Gmail/Calendar/Graph watches were never renewed and went dark ~7 days
after connect (the max watch lifetime all three providers allow).
Register it, fan renewals out as per-channel queue jobs scoped to active
workspaces, retry FAILED channels, and recreate Google Calendar watches
before stopping the old one.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23006?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:26:47 +05:30
martmull fa720358d9 Ignore call-recorder bots for unsupported meeting platforms (#23050)
## What

The call-recorder scheduled a Recall bot for any calendar event that had
a conference link, even when the link pointed to a platform Recall
cannot join (e.g. ro.am, Daily, Whereby, or a plain dial-in). Those
requests could never produce a recording.

This adds a supported-platform check to the recording policy so
unsupported links are ignored, with a dedicated reason, and documents
the supported platforms in the app README.

## Changes

- Add `SUPPORTED_MEETING_PLATFORM_URL_PATTERNS` constant (Zoom, Google
Meet, Microsoft Teams, Webex, GoTo Meeting), extracted from the existing
link-extraction patterns so extraction and validation share one source
of truth.
- Add `isSupportedMeetingPlatformUrl` util.
- `resolveCallRecorderPolicyResult` now returns
`UNSUPPORTED_MEETING_PLATFORM` (bot not required) when the resolved
conference link is not a supported platform.
- Document supported platforms and the ignore behavior in the
call-recorder README.

## Tests

- New unit tests for `isSupportedMeetingPlatformUrl`.
- New policy test for the unsupported-platform case; updated existing
policy tests to use real supported URLs.
- All call-recorder unit tests pass; typecheck and lint clean.

Closes twentyhq/core-team-issues#2705


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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23050?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 13:49:21 +02:00
Raphaël Bosi 70e4d8d36e Skip install-apps onboarding step for invited users (#22823)
## What

The onboarding "install apps" step was proposed to every new user,
including those joining an existing workspace through an invitation
link. Now it is only proposed to the user who creates the workspace.

## Why

App installation only makes sense for the workspace creator. Invited
members should go straight to profile creation.

## How

`activateOnboardingForUser` set the `ONBOARDING_INSTALL_APPS_PENDING`
flag unconditionally, and that flag is the sole driver of the
`APPS_INSTALLATION` status (the frontend just follows the backend
status). Gated the setter behind a new `shouldShowInstallAppsStep` flag,
mirroring the existing `shouldShowConnectAccountStep` flag: creator path
passes `true`, join path (personal invitation, public invite link, SSO
into an existing workspace) passes `false`.

Backend-only change, no migration needed. Covered by unit tests for both
branches.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22823?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 09:25:52 +00:00
martmull c90057178c Bump app version (#23049)
as title

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23049?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 10:52:27 +02:00
martmull 89609c520c Reduce call-recorder Recall API load and harden bot scheduling recovery (#23014)
## Context

We receive Recall rate limit alerts on `/api/v1/bot`. Recall's List Bots
endpoint allows only 60 requests/min per Recall workspace (vs 300/min
for Retrieve and 120/min for Create), and that budget is shared by every
Twenty workspace on the instance since `RECALL_API_KEY` is a
server-level variable. The call-recorder recovery crons fanned out one
list call per stuck recording, fired at the same wall-clock minute for
every workspace, and never resolved dead rows, so the pending set only
grew.

This PR reworks the bot-scheduling recovery mechanism so that
crash-recovery work is rare, cheap, and mostly event-driven. One commit
per change:

## Changes

1. **Fail never-scheduled recordings once their meeting ends**
(`bot_never_scheduled` failure reason). Previously these rows stayed
`REQUESTED+SCHEDULED` forever and were re-fetched by every recovery run.
Rows with an unresolved creation attempt keep their recovery chance
until the 7-day convergence lookback passes (a bot may have recorded
before the id write-back was lost), then fail as
`bot_schedule_outcome_unknown`.

2. **Batch bot lookups into one list call per run.** The pending-bot
sweep and the failed-cancellation retry each issue at most one
workspace-wide `GET /api/v1/bot/` (filtered by `twentyWorkspaceId` +
active statuses) and match bots to recordings in memory via
`twentyCallRecordingId` metadata, instead of one list call per stuck
row. Truncated lists count as failed lookups so an incomplete map never
authorizes a duplicate creation.

3. **Record a `botScheduleAttemptedAt` marker before POSTing a bot.**
Recovery can now distinguish rows that never reached Recall (re-schedule
directly, zero Recall reads) from rows whose creation outcome is unknown
(only these join the lookup).

4. **Store the bot-creation `Idempotency-Key` on the row and recover by
re-sending.** When a stuck row's stored key still hashes from the
current scheduling inputs, recovery re-sends the creation: Recall either
returns the existing bot or creates the intended one, all on the Create
budget (120/min) without touching the List budget (60/min). Drifted
inputs still fall back to the lookup. Re-sends preserve the first
attempt's timestamp and are only trusted within a 12-hour window, so
repeated unknown outcomes age into the lookup path rather than risking a
twin bot after Recall's key retention expires.

5. **Resume pending rows on `callRecording.updated` events and slow the
cron.** A new database-event trigger resumes scheduling within seconds
when a row transitions back to pending (bot vanished at Recall, canceled
request re-requested, failed row reset by reconciliation), with queue
retries. It skips creations (the inserting run schedules inline), skips
its own progress writes, uses slim-payload diffs to skip cheaply, and
defers ambiguous rows to the cron so event bursts cannot fan out list
calls. The pending-requests cron becomes a backstop and drops from every
5 minutes to every 15.

Follow-up commits harden edge cases raised in review (status
revalidation before POST, per-row cancellation recovery window,
future-timestamp guard, attempt-state cleanup when a bot is confirmed
gone at Recall) and add a lifecycle integration test.

## Notes

- Two new app fields on `callRecording`: `botScheduleAttemptedAt`
(DATE_TIME) and `botScheduleIdempotencyKey` (TEXT), both nullable and
not UI-editable.
- A tight race between the event trigger and the cron converges on one
bot via the deterministic idempotency key.
- Not addressed here (needs a server-side change): per-workspace jitter
when dispatching logic-function cron triggers, so identical patterns
don't fire for every workspace on the same minute.

## Test

- New `call-recorder-lifecycle.integration-test.ts` on the app's
integration harness: the global setup installs the app on a live test
server, all reads and writes go through the real API into the test
database, and only externals are mocked — the Recall API (a fetch
interceptor that replays the same bot for a repeated `Idempotency-Key`,
like the real API) and the trigger transports (webhook payloads invoke
the webhook logic function handler; cron and database-event triggers run
their flows). Thirteen scenarios assert the resulting CallRecording rows
in the DB: scheduling from calendar reconciliation (events attached to a
seeded `SHARE_EVERYTHING` calendar channel, since unassociated events
are invisible), webhook status progression with artifact-import route
calls, transcript completion, out-of-order delivery protection, fatal
failure, unknown bots, cancellation with retried Recall delete, and
every crash recovery path. Verified locally against a live server: 15
integration tests pass (including the existing schema contract test).
- `yarn test:unit`: 488 tests pass. `yarn typecheck` and `yarn lint`
clean.

---------

Co-authored-by: martmull <martin@twenty.com>
2026-07-20 10:37:04 +02:00
Félix Malfait 6e1e98f4ab fix(server): shard the server-test unit job to stop the intermittent crash (#23009)
## Problem

`server-test` fails intermittently: exit 1 with **no `FAIL` line and no
`Test Suites:/Tests:` summary** — the jest run is aborted mid-way,
before the reporter's `onRunComplete`.

## Root cause

The `test` target runs the entire unit suite (~6,600 tests) in **one
in-band jest process on a single runner VM** (`nx.json` sets
`maxWorkers: 1` for the `ci` configuration). A few minutes in, that
process is killed by an **external `SIGKILL`** — confirmed *not* OOM
(~15 GB free at kill time, no cgroup `oom_kill`) and *not* an in-process
crash (a Node diagnostic report armed with `--report-on-fatalerror` +
`--report-uncaught-exception` writes nothing). The whole-run kill is why
it fails intermittently with no summary. `maxWorkers=2` on one VM still
dies, so the threshold is **per-VM**, not per-process.

## Fix

Shard the unit suite across VMs, the same way `server-integration-test`
already does:

- A `twenty-server` `test:ci` target runs jest directly (so `--shard`
forwards) with `dependsOn: ["^build"]` so the workspace deps are built.
- `server-test` becomes a 4-way matrix; each shard runs a quarter of the
suite, well under the kill threshold.
- `ci-server-status-check` already aggregates `server-test`, so required
checks are unchanged.

Also provides two mocks a completed run needs but the SIGKILL had been
masking in `ApplicationRegistrationService.upsertFromCatalog` unit
tests: the `MetricsService` provider and
`applicationRegistrationRepository.createQueryBuilder`.
2026-07-20 06:39:13 +02:00
github-actions[bot] cdb7e56720 chore: sync AI model catalog from models.dev (#23015)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-07-19 08:48:44 +02:00
github-actions[bot] dcd6683cac i18n - translations (#23007)
Created by Github action

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-17 22:48:12 +02:00
neo773 5bedd5b8cc feat(email-group): communications UX, per-record DNS status (#23002)
- Rename Communications label to singular, remove docs-home banner
- Provision unsubscribe Cloudflare records at domain creation and
surface per-record status badges; skip Cloudflare when not configured
- Move sending-domain status into the section header and only show the
records table when a record is unverified
- Reply to the original recipients when replying to your own message
- Fix DNS records table column/badge alignment; emit synthetic records
in the log driver for local testing

<img width="1496" height="849" alt="Screenshot 2026-07-17 at 7 47 24 PM"
src="https://github.com/user-attachments/assets/a5a59adb-2df4-4154-98b2-acf87a8008da"
/>
2026-07-17 22:41:25 +02:00
github-actions[bot] 80ff1716e5 i18n - website translations (#23005)
Created by Github action

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-17 21:20:33 +02:00
Félix Malfait 7e133a4930 Converge email recipient fields on existing patterns: shared parser/formatter, search-index members, one display-name rule (#22997)
# Why

Follow-up to #22668, addressing @charlesBochet's five post-merge review
comments. They all point the same direction: the recipient fields
rebuilt things the codebase already had. This PR converges on the
existing patterns where that holds up, and answers on the threads where
it deliberately does not.

# What changed, per comment

**Parser duplication
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064242))**:
`parseEmailAddressList` now lives in twenty-shared (addressparser, group
flattening, try/catch). The server's `safeParseEmailAddresses` delegates
to it, the front wrapper keeps only paste normalization (newlines to
commas) and invalid-token preservation for red chips. The
`addressparser` dependency moves from twenty-front to twenty-shared.
Side effect worth knowing: RFC 5322 group members in inbound To/Cc
headers were previously dropped entirely (group entries have no
top-level address, so the filter removed them); flattening now imports
those participants. Covered by a new regression test.

**Formatter duplication
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064243))**:
`formatEmailAddress` (quote only when specials require it) lives in
twenty-shared. The composer chips and the server's
`formatMessageFromHeader` both delegate to it. The Gmail From header
output is byte-identical: the name is mime-encoded first and encoded
words never contain characters that trigger quoting. CodeQL then caught
that the quoting (ported from the original front util) escaped quotes
but not backslashes, letting a crafted name close the quoted string
early; escaping now covers both as RFC 5322 quoted-pairs, with a
containment test proving a hostile name cannot split into extra
recipients on reparse.

**Member search divergence
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064231))**:
suggestions now search WorkspaceMember through the search index in the
same `useObjectRecordSearchRecords` call as Person (one ranked query),
and enrich hits from `currentWorkspaceMembersState`, exactly like
`SettingsRoleAssignmentWorkspaceMemberPickerDropdown`. The client-side
`filterBySearchQuery` pass is gone. The hook is now what the comment
described: the merge of context people, searched people, and members
into one ranked list, rendered with the same
`SelectableList`/`MenuItemAvatar` primitives the pickers use. Also fixed
while in there: searched person ids are sliced to the suggestion limit
before hydration, so top-ranked people can no longer be crowded out of
the hydration page.

**Chip resolution duplication
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064236))**:
the display-name preference is now one rule,
`getEmailIdentityDisplayName`, used by both
`getDisplayNameFromParticipant` (threads) and the composer chip/menu, so
the same address renders identically everywhere. The order is workspace
member, then person, then display name, then handle: when an address
belongs to both a teammate and a Person record, the internal identity
wins (product call from Felix). `BaseChip.maxLabelWidth` is renamed
`maxWidth` to match the twenty-ui `Chip` API. `ParticipantChip` itself
is not used inside the field: it renders a navigating `RecordChip` when
a person is linked, and navigation from the composer destroys the draft
(no draft persistence yet), plus the field chips need
remove/selected/danger/edit affordances it does not have.

**Rebuilding on MultiItemFieldInput
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064221))**:
answered on the thread rather than in code, deliberately.
`MultiItemFieldInput` is a dropdown-panel list editor (vertical rows,
one input at a time, bound to record-field contexts and
`FieldMetadataType`), and its own TODO says the API should be refactored
into a hook before growing. The inline wrapping chip row commits batches
(paste), dedupes with a flash, and keeps a persistent inline input with
suggestions; layering that through `renderItem`/`renderInput` would
strain both components. On the menu overlap: after comparing side by
side, the shared surface between `MultiItemFieldMenuItem`'s dropdown and
the chip menu is three `MenuItem` rows with different copy, order, and
neighbors; `MenuItem` is already the shared primitive, and a
config-driven fragment would be indirection without deduplication. If
deeper convergence is wanted, the honest path is the existing TODO
(extract the multi-item state machine into a hook, rebase both editors
on it); that touches the Links/Phones/Emails/Array/Files cell editors
and deserves its own PR.

# Verification

- New twenty-shared suites for the parser and formatter (16 tests),
including parse/format round-trips, the encoded-word case, and the
backslash-escaping containment case.
- Server messaging util specs all pass (70 tests), including new
group-flattening regression tests; From-header spec output unchanged.
- Front email module suites all pass (59 tests) with the slimmed
wrappers.
- Typecheck and lint green on twenty-shared, twenty-front,
twenty-server; oxfmt clean on all three.
- Playwright smoke against the seeded dev stack passes end to end:
context suggestions on the Google company, typed search showing people
and the workspace member row (now served by the search index), Enter
picking the top suggestion, duplicate merge, keyboard delete, chip menu
with clipboard copy, Ctrl+Enter committing the buffer then triggering
send.
2026-07-17 21:20:14 +02:00
Rashad Karanouh ccbd3b6c46 Client brief wizard — /partners/brief (#22291)
## Brief — website (Release ① of the brief + glowup rollout)

Public client-brief wizard at `/partners/brief`, plus the marketplace
entry points (match-me card, brief prompt/link, brief CTAs across
partner surfaces).

**Backend already shipped:** the `submit-client-brief` logic function
merged in #22290 (v1.2.0) and is live in prod, so this PR is
**website-only** and needs no app deploy.

Rebased onto current `main` (was ~341 commits behind); lint + format
pass locally, typecheck/tests via CI.

### Release sequence (do not break)
1. **① Brief website — THIS PR.** Independent; backend already live in
prod. → merge → website deploy.
2. **② Glowup app → prod** (#22470). Rebase onto `main` (SDK 2.21),
apply deterministic-id handling, bump 1.2.10 → 1.3.0, `deploy` +
`install` on `partner-twenty-com`, set new app variables, refresh
partners-doc. **This is the gate for ③.**
3. **③ Glowup website** (#22471) — only **after ② is LIVE on prod** (it
reads the new partner links / services / case-study objects). → merge →
website deploy.
4. Reconcile #22637 (partners-traffic-web) with ③ — both touch
`partners-marketplace/*`.

Draft — do not merge until vetted.
2026-07-17 17:13:16 +00:00
nicoko93 0d13db1d9c fix(twenty-server): re-list marketplace registration when catalog serves an app first installed locally (#22877)
## Problem

Fixes #22872.

An app first installed from a **local/CLI source** (`yarn twenty dev` /
tarball upload) gets its `applicationRegistration` created with
`isListed: false` — sensible for a dev app. But when that same app (same
`universalIdentifier`) is later **published to the configured app
registry**, the marketplace catalog sync's update branch in
`upsertFromCatalog` spreads the existing entity and updates
name/sourceType/sourcePackage/version/manifest **without ever setting
`isListed` back to `true`** (only the create branch does).

Result: the app is permanently invisible in the Marketplace tab
(`findManyMarketplaceApps` → `findManyListed()`), while
`installApplication(universalIdentifier)` still works — a confusing
split-brain state with no error anywhere. Reproduced on a self-hosted
v2.18.5 with a private Verdaccio registry (details and repro steps in
the issue).

## Fix

In the `upsertFromCatalog` update branch, re-list the registration
**only when its previous source was local** (`TARBALL`/`LOCAL`):

```ts
const isRelistedFromLocalSource =
  existing.sourceType === ApplicationRegistrationSourceType.TARBALL ||
  existing.sourceType === ApplicationRegistrationSourceType.LOCAL;
...
isListed: existing.isListed || isRelistedFromLocalSource,
```

This deliberately does **not** blanket-set `isListed: true` on every
sync: an operator who delisted a registry-sourced app (via
`updateApplicationRegistration`) keeps their decision — the hourly sync
won't override it.

## Tests

New unit spec `application-registration-upsert-from-catalog.spec.ts`:

- re-lists a registration first created by a local install once the
catalog serves it;
- preserves an operator delisting of a registry-sourced registration;
- keeps an already-listed registration listed;
- still creates new catalog registrations as listed.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22877?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: Nicolas Chanal <nicolaschanal@MacBook-Pro-de-Nicolas.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:51:33 +02:00
Weiko 716e67a276 Copy application source files during build and install (#22991)
## Summary
This is a requirement for the 2-way sync feature

- Copy logic function and front component source files into the build
output during SDK app builds.
- Share application file list construction between install and build
paths so source and built artifacts stay aligned.
- Allow app installs to skip missing optional source files for backward
compatibility while still requiring built artifacts.
- Extend watcher handling to track source-file uploads and restart when
the source set changes.
- Update integration coverage to assert built outputs and copied source
files for minimal apps.

<img width="940" height="165" alt="Screenshot 2026-07-17 at 14 53 40"
src="https://github.com/user-attachments/assets/b9306990-5fc0-4866-a390-3bb8c21a88ad"
/>
<img width="579" height="181" alt="Screenshot 2026-07-17 at 14 53 57"
src="https://github.com/user-attachments/assets/af282318-76c3-49a9-b62a-833a0aadc688"
/>
2026-07-17 17:41:39 +02:00
admin-laicadev 62aa4f6dac docs(skills): use 'twenty apply' in codex-plugin skills (dev --once deprecated) (#22880)
The `twenty` CLI deprecated `yarn twenty dev --once` in favour of `yarn
twenty apply` (added in #22372). The developer docs were updated in
#22688, but the codex-plugin skills still tell agents to run the
deprecated command. This swaps `yarn twenty dev --once` -> `yarn twenty
apply` (including the `--verbose` variants) in the create-app,
develop-app and manage-app skills so agent guidance matches the current
CLI.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22880?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-17 17:16:32 +02:00
martmull 4e2f9e3416 Fix join at in the past (#23000)
as title, we floor the bot join at date 1second in the future

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23000?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-17 14:59:09 +00:00
Félix Malfait aafee63706 Count self-hosted billable seats per distinct user (#22986)
## Context

Self-hosted enterprise pricing is documented as per user ("Pricing is
per user and each user needs a licence"), but seat counts reported to
the enterprise API counted active `userWorkspace` rows. A user belonging
to two workspaces on the same instance was billed as two seats.

## What this does

- Adds `EnterprisePlanService.getBillableSeatCount()`, which counts
`DISTINCT userId` over non-deleted userWorkspaces, keeping the existing
floor of 1.
- Uses it at all four seat-reporting sites: the checkout session
quantity, the seat reports on key activation (`setEnterpriseKey`) and
server-binding release, and the recurring validation cron report.
- Removes the two duplicated private `getActiveUserWorkspaceCount()`
counters and their `UserWorkspaceEntity` repository injections from the
resolver and cron job.
- Adds unit tests for the new method (dedup across workspaces, floor of
1, missing row).

## Intentionally unchanged

- The website `/api/enterprise/seats` and `/checkout` routes apply
whatever quantity the instance reports, so no Stripe-side change is
needed.
- Instance telemetry still reports both `activeUserWorkspaceCount` and
`distinctUserCount`, so the delta stays observable.
- Existing subscriptions need no migration: the next cron seat report
prorates affected instances down automatically.

## Test

- `enterprise-plan.service.spec.ts`: 58 passed (3 new)
- `lint:diff-with-main` and `typecheck` for twenty-server pass

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22986?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-17 16:16:12 +02:00
nitin 19f0e3cad5 Reduce Recall bot lifecycle reconciliation traffic (#22908)
## Summary

- split pending Call Recording request maintenance from stale recording
convergence
- recover Recall bots by workspace and Call Recording metadata before
creating a replacement
- retry failed cancellations, including the canceled-plus-botless
write-back race
- move the broad orphaned-bot list sweep from every five minutes to a
dedicated daily job
- filter Recall bot lists by workspace metadata at the provider boundary

## Why

This is stack 1/3 extracted from #22739. Healthy installed workspaces
currently list Recall bots every five minutes even when no local state
has diverged. This layer removes that unconditional list sweep while
keeping pending request recovery at five-minute latency.

The cancellation recovery also closes a crash window where Recall
accepted a bot creation but the local bot ID write-back failed before
the user canceled the request. The maintenance job now rediscovers and
cancels that bot before it can join.

## Stack

1. **Recall bot lifecycle reconciliation** — this PR
2. Divergence-scoped recording synchronization — #22909
3. Artifact import offloading — #22910

## Validation

- `npm run typecheck`
- `npm run lint`
- `npm run test:unit` — 71 files, 454 tests

---------

Co-authored-by: Claude <martmull@hotmail.fr>
2026-07-17 15:49:28 +02:00
Félix Malfait dc0bb7760f fix(front): only sign out when token renewal is rejected by the server (#22983)
## Context

Users are frequently signed out when coming back to Twenty. The console
shows `Failed to renew token after retries, triggering unauthenticated
error`: the access token has expired and the `renewToken` call fails.
Today any renewal failure wipes the stored token pair and redirects to
sign-in, even when the refresh token is still valid, for example when
the renewal request hits a transient network failure (laptop waking up,
VPN reconnecting) or a server restart during a deploy. Since the token
pair state is synced across tabs, one failing tab signs out every tab.

## What this does

- Only triggers the unauthenticated flow when the server definitively
rejects the refresh token. The `renewToken` mutation maps those cases to
`UNAUTHENTICATED` (expired or invalid JWT), `FORBIDDEN` (revoked) and
`BAD_USER_INPUT` (unknown or malformed token).
- Keeps the session on any other renewal failure (network errors after
retries, server errors): the token pair stays in place and the next
request triggers a fresh renewal attempt, so the session recovers once
the server is reachable again.
- Signs out immediately when the stored pair has no refresh token
instead of attempting a renewal that cannot succeed.
- Logs the renewal error, which was previously swallowed and made this
class of logouts hard to diagnose.

## Tests

- renews and replays the operation after an access token rejection
- signs out when the server rejects the refresh token
- keeps the session on a network error (asserts all retry attempts ran)
and on a server error
- signs out without attempting renewal when the stored pair has no
refresh token

Test mocks now reset between tests so per-test overrides cannot leak
into other tests.

[[Review in
cubic](https://www.cubic.dev/buttons/review-in-cubic-dark.svg)](https://cubic.dev/pr/twentyhq/twenty/pull/22983?utm_source=github)
2026-07-17 15:45:54 +02:00
neo773 f6612e5a85 fix(server): stop treating Microsoft Graph 401 as a permanent failure (#22989)
In production we are seeing some accounts being occasionally marked as
permanent failure even though when you check with their refresh token it
never actually failed this PR stops treating 401 as permanent failure
and treats them as Transient error.

We already have token refresh stage that runs before the actual import
stage, so if it's an actual revoke token error, it should catch it.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22989?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-17 19:13:50 +05:30
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
Charles Bochet 3303bdd258 fix(docker): bump curl pin to 8.20.0-r0 (2 high + 6 medium CVEs) (#22994)
## Context

AWS Inspector flags `curl 8.19.0` in every `prod-twenty` image,
including current builds, with 2 high and 6 medium findings:

- **High**: CVE-2026-6276, CVE-2026-5773
- **Medium**: CVE-2026-4873, CVE-2026-5545, CVE-2026-6253,
CVE-2026-6429, CVE-2026-7009, CVE-2026-7168

These drive the Oneleet monitor "AWS ECR repository image
vulnerabilities are remediated" (high severity SLA window currently
open).

## Fix

All 8 CVEs are fixed in Alpine 3.23's `curl 8.20.0-r0`, now available in
the v3.23 main repository:

- `twenty-server` runtime stage: raise the pin from `curl>=8.19.0-r0` to
`curl>=8.20.0-r0`
- `twenty-app-dev` stage: pin the previously unpinned `curl` to the same
floor

## Verification

Built the runtime apk layer locally against the pinned
`node:24.18.0-alpine3.23` base:

```
curl-8.20.0-r0
libcurl-8.20.0-r0
curl 8.20.0 (aarch64-alpine-linux-musl) libcurl/8.20.0 OpenSSL/3.5.7 ...
```

Next deployed image will carry the patched curl, and the remaining
findings on old images age out via the 14-day ECR lifecycle.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22994?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-17 15:23:56 +02:00
github-actions[bot] c8f0b86316 i18n - translations (#22988)
Created by Github action

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

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-17 14:11:31 +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
Weiko 20e74d0553 Revert "Remove calendar week view feature flag from public flags" (#22987)
Reverts twentyhq/twenty#22950

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22987?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-17 13:41:05 +02:00
Charles Bochet 2e671342f5 [2/2] Write CREATED at activation, clean stale onboarding workspaces (#22915)
## Context

Follow-up to #22904 (merged), which introduced the `CREATED` activation
status (schema provisioned, onboarding incomplete — no billing
subscription), its enum migration, and the read path. This PR turns the
status on and closes the zombie-workspace leak (~60–110
subscription-less ACTIVE workspaces per day since v2 onboarding, 935+
total).

 **Deploy gating satisfied**: #22904's slow enum migration shipped with
the release deployed to prod on 2026-07-17, so writing `CREATED` is now
safe. Rebased on main (clean, no conflicts) — main's #22943/#22955
guarded-transition rework already handles `CREATED` correctly: the
webhook suspend switch only suspends `ACTIVE` workspaces, and
`reactivateWorkspace` promotes `CREATED`→`ACTIVE`.

## What this PR does

1. **Write path** — `activateWorkspace` sets `CREATED` instead of
`ACTIVE` when the workspace has no billing subscription.
`hasWorkspaceAnySubscription` returns true when billing is disabled, so
self-hosted workspaces keep going straight to `ACTIVE` — no behavior
change outside cloud.
2. **Cleanup** — `CREATED` joins `PENDING_CREATION`/`ONGOING_CREATION`
in the existing onboarding cleaning flow (cron +
`workspace:clean:onboarding` with `--dry-run`): workspaces older than
the same seven-day threshold are soft-deleted, then hard-deleted on a
later run. **No suspension step and no emails** — an abandoned
onboarding is treated as never having completed, exactly like a
workspace stuck in creation. A workspace that subscribes before cleanup
exits the flow (`CREATED`→`ACTIVE` synchronously via checkout).
3. **Backfill** — slow instance command moving `ACTIVE` workspaces with
no `billingSubscription` row, created since v2 onboarding shipped
(2026-07-01), to `CREATED`. Gated on `IS_BILLING_ENABLED` so self-hosted
instances are untouched.
4. **Resolves #22904's text-cast TODO on the billing activation update**
— this PR only deploys after the enum migration, so the
`CREATED`→`ACTIVE` promotion is a plain status-scoped update again. The
upgrade-path filters (`activationStatusIn`) keep the `::text` cast:
upgrade tooling has to run against databases coming from pre-2.22
versions, so its TODO now points at the real removal trigger (dropping
pre-2.22 upgrade support).

## Ops note before deploying

The backfilled zombies are all older than seven days, so the first cron
run after the backfill **soft-deletes them and the next run destroys
them (schema and data), with no user-facing communication**. The
backfill also catches any post-July-1 cloud workspace that is ACTIVE
without a subscription — including intentionally comped/demo/internal
ones if any were created since then (verified locally: the seeded demo
workspaces matched). **Run the backfill's SELECT as a dry-run against
prod and review the list before deploying.**

## CI note

~~`cross-version-upgrade` (and its `ci-server-status-check` aggregate)
is red due to a pre-existing regression on main — `Field metadata
"coreWorkflowVersionId" is missing in object metadata workflowVersion`
on the seed workspaces.~~ Resolved: the rebase picks up main's
#22944/#22961 which fixed that regression.

## Verification

Server-side (billing-enabled local instance, Stripe test mode) and
through the full onboarding UI in both billing modes:

- **Billing enabled, UI**: signup → workspace creation →
**`activationStatus: CREATED`** in DB mid-onboarding → profile/invite
steps work on the CREATED workspace → plan-required page → no-card trial
→ app loads, workspace **`ACTIVE`** with a `trialing` subscription
(exercises #22904's synchronous promotion).
- **Billing disabled, UI**: signup → workspace creation → **`ACTIVE`
directly**, no plan step anywhere, app loads — self-hosted behavior
unchanged.
- **Cleanup**: a `CREATED` workspace backdated 8 days is listed by
`workspace:clean:onboarding --dry-run`; the real run soft-deletes it
silently (no suspension, no email) and the next run hard-deletes it
(workspace row and schema gone).
- **Backfill**: synthetic `ACTIVE` no-sub workspaces — created
2026-07-05 flips to `CREATED`, created 2026-06-15 stays `ACTIVE`,
subscribed workspaces stay `ACTIVE`; billing-disabled short-circuit
returns without touching anything.
- Lint + typecheck green.
2026-07-17 11:26:46 +00:00
Joshua Freedman 4a7324c0c8 fix(serverless): flush IPC message before exiting local function runner (#22920)
Closes #22925

## Problem

`LocalChildProcessRunnerService.writeBootstrapRunner` generates a
child-process runner that returns the function result to the parent over
the Node IPC channel:

```js
const out = await handlerFn(msg.payload);
process.send && process.send({ ok: true, result: out });
process.exit(0);
```

`process.send()` is **asynchronous**. When the serialized payload is
larger than the OS pipe buffer (~64 KB on Linux), it can't be written in
a single synchronous step, and the `process.exit(0)` on the next line
tears the child down before the message is flushed.

On the parent side (`runChildWithEnv`), the lost message means the
`'message'` handler never fires — only `'exit'` with `code === 0` does,
which resolves:

```js
resolve({ ok: true, stdout, stderr }); // no `result`
```

`LocalDriver.execute` then returns `data: result ?? null` → **`null`**,
so the function's return value is silently discarded while the step is
reported as a *success* with an empty result.

## Symptom

Larger serverless / workflow **Code** step results intermittently come
back empty (`{}` / `null`). It is size- and load-dependent, so it
presents as flakiness. A common downstream failure is a workflow
**Iterator** fed the now-missing array:

```
Iterator input items must be an array
```

Results smaller than the pipe buffer always flush synchronously and
never reproduce it — which is why only larger payloads are affected.

## Root cause

`process.exit()` runs before the asynchronous `process.send()` (and the
stdout fallback `process.stdout.write()`) has flushed — the classic Node
footgun of exiting before pending async writes drain.

## Fix

Wait for the `send()` / `write()` flush callback before exiting, on
every exit path (success, error, stdout fallback, outer catch):

```js
if (process.send) {
  process.send({ ok: true, result: out }, () => process.exit(0));
} else {
  process.exit(0);
}
```

Behavior-preserving: it never delivers *less* than before; it only
closes the window where a large result is dropped. No change to the
small-payload happy path.

## Deterministic reproduction

Reproduces the dropped IPC message under back-pressure (parent stalls
before draining), comparing the current pattern vs the fix:

```js
const { spawn } = require('node:child_process');
const fs = require('fs');

const SIZE = 3_000_000; // beyond any pipe buffer
const child = (mode) => `
  process.on('message', () => {
    const out = 'z'.repeat(${SIZE});
    ${mode === 'fixed'
      ? 'process.send({ ok: true, result: out }, () => process.exit(0));'
      : 'process.send({ ok: true, result: out }); process.exit(0);'}
  });`;

const busy = (ms) => { const e = Date.now() + ms; while (Date.now() < e) {} };

function trial(mode) {
  return new Promise((resolve) => {
    const f = `/tmp/child_${mode}.cjs`;
    fs.writeFileSync(f, child(mode));
    const c = spawn(process.execPath, [f], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] });
    let got = false;
    c.on('message', (m) => { got = m?.result?.length === SIZE; });
    c.on('exit', () => resolve(got));
    c.send({ type: 'run' });
    busy(30); // stall parent so it doesn't drain the IPC pipe promptly
  });
}

(async () => {
  for (const mode of ['current', 'fixed']) {
    let ok = 0; const N = 25;
    for (let i = 0; i < N; i += 5) {
      ok += (await Promise.all([...Array(5)].map(() => trial(mode)))).filter(Boolean).length;
    }
    console.log(`${mode}: result delivered ${ok}/${N}`);
  }
})();
```

Output:

```
current: result delivered 0/25
fixed:   result delivered 25/25
```


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22920?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-17 10:09:28 +02:00
twenty-pr[bot] d99f57bc5e chore: bump version to 2.23.0 (#22975)
## Summary

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

## Checklist

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

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

Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
2026-07-17 09:54:30 +02: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 c6f0380070 Reuse onboarding container width for workspace selection (#22974)
Fix in https://github.com/twentyhq/twenty/pull/22965 was wrong, 440px
wide is the new intended width for both signup forms.
This PR reverts + does the correct fix.

See figma as source of truth
https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=1633-94880&p=f&m=dev

## Before
<img width="431" height="450" alt="Screenshot 2026-07-16 at 18 14 26"
src="https://github.com/user-attachments/assets/3f8788e1-3764-4326-867a-973a98e48007"
/>

## After
<img width="1030" height="898" alt="Screenshot 2026-07-17 at 08 24 38"
src="https://github.com/user-attachments/assets/42e03215-f32c-4f61-8f2d-1b8ff966d31c"
/>
<img width="1028" height="900" alt="Screenshot 2026-07-17 at 08 24 25"
src="https://github.com/user-attachments/assets/3a49142a-dc52-4097-96d2-05a4e641f574"
/>
2026-07-17 08:45:11 +02:00
neo773 52b7aebddf fix(server): key connected-account lookup on handle and provider (#22964)
Connect flows (Google, Microsoft, IMAP/SMTP/CalDAV) looked up an
existing connectedAccount by `handle` alone, so connecting a second
account with the same handle but a different provider overwrote the
first instead of inserting a new row (e.g. IMAP inbox clobbering a
calendar-only Google account).

Fix: add the `provider` discriminator to the lookup. Same
provider+handle still updates; a different provider gets its own row.
Integration test covers the Google-then-IMAP case.
2026-07-17 00:28:23 +05:30
Weiko 6a1de47a17 Fix signup visual regression in workspace selection layout (#22965)
## Summary
- Split the sign-in/up onboarding container styles so workspace
selection can keep its wider layout without affecting the other auth
states.
- Reuse the base onboarding container for the non-selection flow to
restore the intended visual structure.


https://github.com/twentyhq/twenty/commit/566c3b662954de932677a3fefe69735a45fe55ae
commit accidentally reused the 440px workspace-selection container for
the global credential form

## Before
<img width="643" height="496" alt="Screenshot 2026-07-16 at 18 14 35"
src="https://github.com/user-attachments/assets/abff0779-a236-424f-9503-9182dab5fa3f"
/>

## After
<img width="510" height="509" alt="Screenshot 2026-07-16 at 18 11 58"
src="https://github.com/user-attachments/assets/8c4693b2-827f-4bc8-a9ce-10171bbb7d0b"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22965?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-16 20:25:39 +02:00
Thomas Trompette f8b7ecf680 fix(workflow): rebuild core workflowVersion rows in the 2-22 backfill (#22961)
## Problem
Syncing workflowVersion to core fails with `duplicate key value violates
unique constraint "IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW"`. The
sync's `INSERT ... ON CONFLICT ("id")` only dedupes the primary key — it
can't dedupe `IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW`
(`(workspaceId, workflowId) WHERE status='ACTIVE'`). When a leftover
ACTIVE core row exists for a `(workspaceId, workflowId)` with a stale id
(accumulated across the sync's earlier id schemes), inserting the new
active row collides, and the per-id purge misses it.

## Fix
Rewrite the 2-22 `backfill-workflow-version-core-links` command as a
**full rebuild**. Per workspace, in one raw-SQL transaction (core and
workspace schemas are the same database):
1. `DELETE` all `core.workflowVersion` for the workspace — clears every
stale/leftover row.
2. Insert a fresh own-id core row for **every** workspace version.
3. `UPDATE` `coreWorkflowVersionId` on **every** workspace record to its
new core id.

Because it wipes first and re-links all records, leftover ACTIVE rows
can't collide and no record is left pointing at a deleted core row — so
the dual-write's `linked → update` path stays correct afterward.

## Test (local)
Fresh reset, and a reproduced dirty state (leftover ACTIVE core row with
a stale id + a stale link + an unlinked record):
- rebuild runs with no `ONE_ACTIVE` / duplicate-key error,
- leftover wiped, stale link replaced, every record re-linked to a fresh
own-id row,
- 0 dangling/unlinked, no duplicate core rows, exactly one ACTIVE core
version per workflow.
Typecheck + lint + oxfmt clean.

## Note
The 2-20 `backfill-workflow-version-to-core` can still log per-workspace
conflicts on already-dirty instances, but they're non-fatal (the
iterator continues) and this rebuild corrects the end state. The
dual-write (`upsertToCore`) is unchanged and works on the clean data
this produces.
2026-07-16 16:12:50 +00:00
Paul Rastoin 17d34a6fe3 [Front-comp-renderer] Host componentSource caching (#22958)
## Context

The front component source cache introduced in the sandbox refactor was
a silent no-op: it ran inside the sandboxed worker (opaque-origin
`allow-scripts` iframe), where the `caches` global does not exist. Every
render re-fetched the component JS from the network — nothing ever
appeared in Cache Storage.

## Change
<img width="1580" height="622" alt="image"
src="https://github.com/user-attachments/assets/06903abf-b313-4d15-8db4-80950d4bf5ba"
/>

Moves component source resolution and caching from the worker to the
host, where Cache Storage works:

- `fetchComponentSource`, `fetchComponentSourceFromNetwork`,
`frontComponentCacheStorageService` and
`extractComponentChecksumFromUrl` relocated from `remote/worker/utils/`
to `host/utils/` (`buildAuthorizationHeadersFromAccessToken` to shared
`utils/`, still used by the worker for SDK module fetches)
- `FrontComponentWorkerEffect` resolves the source before
`thread.imports.render(...)` (with a cancellation guard) and passes
`componentSource` in the render payload
- `loadFrontComponentModule` no longer fetches: it keeps only
sandbox-side work (SDK import rewrite, blob URL creation, `import()`)
- New: stale-entry eviction — writing a new checksummed entry deletes
older entries of the same `front-components/{id}/` prefix

## Security invariant

The host only fetches, hashes and caches the source string — it never
executes it. Execution stays exclusively in the opaque-origin worker via
blob URL import. SHA-256 checksum verification is kept on both cache
read (poisoned-entry guard: any same-origin code can write to Cache
Storage) and cache write.

## Out of scope

SDK client module caching — follow-up tracked in
[twentyhq/core-team-issues#2688](https://github.com/twentyhq/core-team-issues/issues/2688),
requires content-addressed URLs (server-side checksum at SDK generation
time, exposed via GraphQL and embedded in the `/rest/sdk-client/...`
URL), then reuses this same host-side cache path.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22958?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-16 15:49:44 +00:00
Thomas Trompette 2a94736ece fix(metadata): resync metadata store via collection hashes on SSE reconnect (#22956)
## Context

Follow-up to #22562 (merged), which fixed the SSE-gap durability hole
from #22504 by calling `invalidateMetadataStore()` on SSE reconnect.

In review, @Weiko and a second reviewer flagged a performance concern:
firing a full invalidation on every reconnect can spam the backend, and
reconnect frequency is unbounded (`retryAttempts: Infinity`). The steer
was to lean on the per-collection hashes that `FindMinimalMetadata`
already returns and refetch only what actually changed.

## Problem

`invalidateMetadataStore()` sets `currentCollectionHash: undefined` for
every entity key. The staleness check in `useLoadMinimalMetadata` is
`entry.currentCollectionHash !== hash`, so nulling the hash makes
**every** collection compare as stale. Result: each reconnect forces a
full refetch of every metadata collection (objects, fields, views, ...),
even when nothing changed during the gap. That defeats the
collection-hash mechanism built to avoid exactly this.

## Change

Add `useResyncMetadataStore`, which only bumps
`metadataLoadedVersionState` without clearing collection hashes.
`MinimalMetadataLoadEffect` already re-runs on a version change, so this
triggers one `FindMinimalMetadata` query; the existing hash comparison
then marks only genuinely-changed collections stale.

`SSEClientEffect` now calls `resyncMetadataStore()` instead of
`invalidateMetadataStore()` on reconnect.

Net: same durability guarantee (changes missed during a disconnect are
caught on reconnect), but cost per reconnect drops from "refetch
everything" to "one lightweight hash query + refetch only what changed."

## Testing

1. Open a record page in a workspace.
2. Create a field / page-layout tab via the metadata API while the SSE
stream is dropped (background the tab, kill the network briefly, or
restart the server).
3. On reconnect the new metadata appears without a manual reload.
4. Reconnect with no metadata change triggers only a
`FindMinimalMetadata` query and no collection refetch.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22956?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-16 15:14:26 +00:00
Paul Rastoin fe10975927 fix(billing): guard workspace suspend against concurrent soft-delete (#22955)
## Context

Follow-up to #22943, addressing the cubic review comments left on that
PR (handled in a follow-up as agreed in the thread).

In `BillingWebhookSubscriptionService.processStripeEvent`, the
suspend/reactivate decision re-reads the workspace and then acts on it.
A concurrent soft-delete landing in that window could transition an
already soft-deleted workspace to `SUSPENDED`, contrary to the
guarded-transition behavior (cubic P2).

## Fix

Added `deletedAt: IsNull()` to the `suspendWorkspace` compare-and-swap
WHERE clause, matching the guard already present in
`reactivateWorkspace`. A concurrent soft-delete now blocks the
suspension instead of transitioning a deleted workspace to `SUSPENDED`.

## On the delete guard (cubic P1)

Cubic also flagged that the `PENDING_CREATION` hard-delete path could
hard-delete a concurrently soft-deleted workspace. On review this is not
worth guarding:

- A `PENDING_CREATION` workspace has no DB schema and no records
(activation is what creates them), so there is no data to lose.
- The cleaner already hard-deletes soft-deleted workspaces by design
(`cleaner.workspace-service.ts` soft-deletes a pending workspace, then
hard-deletes it on a later run), so "hard delete an already soft-deleted
workspace" is a supported transition, not corruption.

So P1 is intentionally left out to keep `deleteWorkspace` and all its
callers unchanged.

## Tests

- `suspendWorkspace` update includes `deletedAt IS NULL` and reports
whether the guarded update applied.

Typecheck, lint, and format pass on the changed files.
2026-07-16 14:26:23 +00:00
Weiko 8e3ec5b43d Propagate record card background to inline hover content (#22957)
## Summary
- Introduce a shared `--record-card-background-color` CSS variable on
record cards
- Reuse that variable for hovered inline cell content so the hover
portal matches the card background state
- Preserve selected, focused, and active background transitions without
duplicating background logic

### Before

<img width="196" height="337" alt="Screenshot 2026-07-16 at 16 03 40"
src="https://github.com/user-attachments/assets/b74bfb24-0144-4a8c-b8a4-b56768e84d66"
/>
<img width="219" height="357" alt="Screenshot 2026-07-16 at 16 03 25"
src="https://github.com/user-attachments/assets/19d052cb-8c8c-49c9-b3af-0178c53c0c0a"
/>


### After

<img width="189" height="372" alt="Screenshot 2026-07-16 at 16 03 52"
src="https://github.com/user-attachments/assets/289e4186-d418-44dc-93d4-70fa47e50cf2"
/>
<img width="180" height="333" alt="Screenshot 2026-07-16 at 16 03 00"
src="https://github.com/user-attachments/assets/b874e0c4-5840-4b7e-918c-d441c50fa487"
/>



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22957?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-16 14:17:54 +00:00
Charles Bochet 5588ddf829 Fix delete/destroy/restore record commands on pages without a record index (#22952)
## Bug

On a standalone page (`/page/:pageLayoutId` — a custom app page or
standalone page layout), opening a record in the side panel and running
**Delete** from the Options menu fails with an error toast:

> Record index ID and object metadata are required to delete records

The record is not deleted. The same guard breaks **Destroy** and
**Restore**.

## Root cause

`buildHeadlessCommandContextApi` only derives `recordIndexId` when the
context store holds a `currentViewId`. On standalone pages there is no
view, and `useOpenRecordInSidePanel` copies that null view id into the
side panel context, so the delete/destroy/restore commands throw at
mount — before executing anything. The throw is caught by
`CommandMenuItemErrorBoundary` and surfaces as the toast (also reported
to Sentry).

The commands only use `recordIndexId` to reset table row selection and
remove records from the record board — cleanup that is meaningless when
no record index is on screen. The mutation itself only needs
`objectMetadataItem` and the graphql filter, which are both available.

## Fix

- Keep throwing when `objectMetadataItem` is missing (genuinely
required).
- Make `recordIndexId` optional: pass the existing
`PLACEHOLDER_RECORD_INDEX_ID` to the selection hooks (they must be
called unconditionally) and skip the selection cleanup at execute time
when there is no record index — same pattern
`useResetRecordIndexSelection` already uses. The constant is extracted
to a shared file.

## Verified

- **Bug path**: on a standalone page, opened a record in the side panel
via search, ran Delete Task from the Options menu → record soft-deleted
(checked `deletedAt` in DB), side panel closed, no error toast, no
console error.
- **Regression**: on the tasks index table, selected a row and ran
Delete Task from the command menu → record deleted, row removed, table
selection reset, no errors.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22952?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-16 16:13:15 +02:00
Weiko a7de3ce3a5 Allow non-compact all-day calendar cards to show full content (#22953)
## Summary
- Render all-day calendar items as full `RecordCalendarCard` content in
non-compact views, while keeping compact cards clickable as a whole.
- Rework the all-day time grid layout so the label and day cells align
cleanly in a dedicated grid row.
- Add coverage for the new card behavior and for filtering out
`DATE_TIME` records from the all-day lane.

### Week (compact)
<img width="1308" height="812" alt="Screenshot 2026-07-16 at 15 30 02"
src="https://github.com/user-attachments/assets/24f74f22-86c1-4326-8c65-92ee2c3e8c92"
/>

### Week (non compact)
**NEW**
<img width="1311" height="789" alt="Screenshot 2026-07-16 at 15 29 52"
src="https://github.com/user-attachments/assets/8445c8c5-c952-47e9-ba24-c21d63352e79"
/>

### Month
<img width="1310" height="822" alt="Screenshot 2026-07-16 at 15 29 41"
src="https://github.com/user-attachments/assets/aa33cf48-c602-49e6-bfb1-b9ab1c798bcb"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22953?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-16 15:59:12 +02:00
Paul Rastoin 7939cd8684 feat(server): app lifecycle metrics (install/uninstall/upgrade + marketplace publish) (#22656)
## What

Adds product metrics for the app/marketplace lifecycle so they can be
graphed in Grafana. No app-lifecycle metrics existed before; the flows
only logged.

### New counters (`MetricsKeys`)
- `app-install/succeeded` · `app-install/failed`
- `app-upgrade/succeeded` · `app-upgrade/failed`
- `app-uninstall/succeeded` · `app-uninstall/failed`
- `app-registration/created` (new app published) ·
`app-registration/version-published` (new version available)

All carry `universalIdentifier`, `appName`, `sourceType` attributes
(plus `version`, and `errorCode` on failures).

### New gauge
- `twenty_app_installed_workspaces_total` — observable gauge emitting
the top 100 external apps by installed-workspace count (excludes
built-in LOCAL apps). Powers a "most installed apps" leaderboard;
combine with the 24h install/uninstall event counters for recent
activity.

## Where metrics are emitted
- **Install / upgrade**
(`ApplicationInstallService.doInstallApplication`): success + failure
branches, distinguished by the existing `isVersionUpgrade` flag.
- **Uninstall** (`ApplicationInstallResolver.uninstallApplication`): at
the resolver, deliberately *not* in the sync service, so
rollback-triggered internal uninstalls (fired from the install catch
block) don't pollute uninstall counts.
- **Publish / new version**: `upsertFromCatalog` (npm marketplace sync),
`checkForUpdates` (npm version poll), and the tarball CLI publish path.

### Exactly-once version-published
Both the catalog-sync and version-check crons converge
`latestAvailableVersion`. Each emission point is **change-guarded**
(`stored !== incoming`), so whichever cron observes the change first
emits, and the other becomes a no-op. No double counting, no
race-dependent misses.

## Pipeline
Metrics flow through the existing OTel -> ClickHouse path and can be
graphed from the `twenty-product-metrics` dashboard (dashboard changes
live in infra-twenty, not this PR).

## Test plan
- [x] `nx typecheck twenty-server`
- [x] oxlint + oxfmt on changed files
- [x] `oauth-discovery.controller.spec` (the one existing spec touching
these services) passes
- [ ] Reviewer: sanity-check metric names/attributes and cardinality
choices (no `workspaceId` attribute, LOCAL apps excluded from the gauge)


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22656?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 <martmull@hotmail.fr>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-07-16 15:53:59 +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
Thomas Trompette 988f8ff900 feat(workflow): provision coreWorkflowVersionId on existing workspaces and link them (#22944)
Stacked on the write-back guard hotfix (#22940). Completes the version
soft-ref for workspaces that predate the `coreWorkflowVersionId` field.

## Why
New standard fields aren't auto-synced to existing workspaces; they're
only built at workspace creation or added by an explicit upgrade
command. Deployed 2.20/2.21 instances also carry **legacy core rows with
`id = record.id`** (the pre-soft-ref shared-UUID model, from the
already-run #22663 backfill). The original backfill has already run
there and won't re-run (tracking is by command name), so the migration
to soft-ref has to be **new appended commands**.

## What (two appended 2-22 workspace commands)
1. `add-workflow-version-core-soft-ref-field` (`1784193206000`): adds
the `coreWorkflowVersionId` system field to existing workspaces missing
it (flat-entity migration, `add-message-campaign-stat-fields` pattern).
Idempotent, dry-run aware, skips workspaces without the
`workflowVersion` object.
2. `backfill-workflow-version-core-links` (`1784193207000`): re-runs the
sync (`upsertToCore`). For each version, `upsertToCore` **purges the
legacy shared-id core row** (`id === record id`) then upserts a
deterministic own-id row and writes the link back onto the workspace
record. Targeted per-id delete — it does not wipe unrelated core rows.

Ordering: both run after the original 2-20 backfill. On instances that
run 2-20 fresh (e.g. 2.19 → 2.22) that backfill creates core rows and —
via the hotfix guard — skips the write-back until the field exists;
command 1 provisions the field; command 2 purges + relinks. On
already-migrated 2.21 instances the 2-20 backfill won't re-run, so
command 2 is what clears their shared-id rows.

The same per-id purge in `upsertToCore` also covers the dual-write path:
between the 2.22 deploy and command 2 running, an edited version would
otherwise collide with its shared-id row on the one-active-per-workflow
index.

Scope: version side only. The workflow-side equivalent
(`coreWorkflowId`) ships with the workflow-side sync PR; `core.workflow`
was never backfilled in prod, so it has no legacy shared-id rows.

## Test
- Unit: guard skips write-back when the field is absent; runs it when
present.
- Happy path (fresh reset): original backfill → add-field no-op → link →
4/4 linked, own ids, 0 duplicates.
- Deployed migration (simulated 2.21: shared-id ACTIVE core rows + field
removed): add-field re-provisions → link purges the shared-id rows and
rebuilds → records linked to own-id rows, 0 duplicates, exactly one
ACTIVE core version per workflow (index intact), no collision.
- Idempotent re-run: still 4 core rows, links resolve.
- Typecheck + lint + oxfmt clean.
2026-07-16 13:18:37 +00: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
Thomas Trompette e9bc06a830 fix(workflow): skip core version id write-back when the field is missing (#22940)
## What
The version soft-ref sync (#22821, on main / the 2.22 line, not yet
released) added a **write-back** step: after copying a `workflowVersion`
into `core.workflowVersion`, it sets `coreWorkflowVersionId` on the
workspace record. On workspaces that predate that field (new standard
fields aren't auto-provisioned onto existing workspaces), the write-back
throws:

`Field metadata for field "coreWorkflowVersionId" is missing in object
metadata workflowVersion` (from `formatData`).

This breaks the sync wherever it runs on an unprovisioned workspace —
the backfill (when it runs under the new code) and the dual-write (on
any workflow-version create/update). Observed on staging while upgrading
to 2.22: 2 of 70 workspaces. **2.20 itself was fine** — it ran the old
shared-UUID sync, which had no write-back.

## Fix
Guard the write-back: check the workspace's `workflowVersion` object for
the `coreWorkflowVersionId` field (via `workspaceCacheService` flat
field maps) and skip it with a warning if absent, instead of throwing.
The core row is still upserted; the workspace gets linked later once the
field is provisioned.

## Follow-up
Provisioning `coreWorkflowVersionId` onto existing workspaces and
linking them is #22944 (version side). The workflow-side equivalent
follows with the workflow-side sync PR.

## Test
Unit test covers both branches: field absent → write-back skipped, no
throw, core upsert still runs; field present → write-back runs.
Typecheck + lint clean.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22940?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-16 14:25:11 +02:00
Akash! 18b746d525 fix(metadata): invalidate metadata store on SSE reconnect (#22504) (#22562)
## Fixes

Fixes #22504

## Description

This PR fixes a bug where metadata updates (like creating a new field or
a page layout) were missed if an open tab was temporarily disconnected
from the server (e.g., tab backgrounded or a network blip).

Because the frontend's metadata store relies on live SSE deltas for
freshness, any gap in the connection meant the new metadata would never
reach the client unless a full reload occurred. This often resulted in
"No Data" states for newly created layouts or widgets.

**Changes:**
- Updated `SSEClientEffect.tsx` to call `invalidateMetadataStore()` upon
a successful SSE reconnection.
- Re-syncing the metadata store on reconnect ensures that any events
missed during the disconnected gap are retrieved durably without
requiring a manual page refresh.

## Testing

1. Open a record page tab in a workspace.
2. From an app front component or DevTools, trigger a field creation via
the metadata API (`createOneField` / `page-layout` mutations).
3. Briefly disconnect the network or restart the server so the SSE
stream drops during the mutation.
4. Re-establish the connection.
5. The tab should automatically refetch the metadata and reflect the new
field/layout without needing a manual reload, instead of getting stuck
in a "No Data" state.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22562?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-16 14:23:20 +02:00
github-actions[bot] 5d9d33b513 i18n - translations (#22951)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-07-16 14:17:19 +02:00