2e671342f51fc9349e92aa2a7ebc3cc005fe94ef
13681 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. --> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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" /> |
||
|
|
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. |
||
|
|
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. --> |
||
|
|
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.
|
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
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> |
||
|
|
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%"
}
}
}
}
```
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. --> |
||
|
|
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. --> |
||
|
|
5d9d33b513 |
i18n - translations (#22951)
Created by Github action Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
2a7e87125f |
Remove calendar week view feature flag from public flags (#22950)
## Summary - Remove `IS_CALENDAR_WEEK_VIEW_ENABLED` from the public feature flag registry - Keep the remaining public feature flags unchanged ## Note Needs more polish before being released in the Lab |
||
|
|
217cf650aa |
Decide workspace destiny from live data and all sub (#22943)
## Context A production customer was stuck on the billing settings page: their workspace was `SUSPENDED` (with `suspendedAt` set) while their subscription was `active` in the database. ## Problem Stripe webhook events can be delivered out of order or processed concurrently ([Stripe explicitly does not guarantee ordering](https://docs.stripe.com/webhooks#events-ordering)), and `BillingWebhookSubscriptionService.processStripeEvent` made its suspend/reactivate decision from stale and incomplete data: - The decision used the **event payload's** subscription status, while the subscription row was upserted from a **live Stripe fetch** — so the two could diverge. Around trial end, Stripe emits `active → past_due` then (once the customer pays) `past_due → active` within a short window. If the stale `past_due` event is processed last, it suspends the workspace while writing an `active` subscription to the DB. Nothing self-heals from that state: the workspace stays suspended while the cleanup cron warns and eventually soft-deletes it. - The decision only looked at the **event's own subscription**, but suspension is a workspace-level decision and a Stripe customer can hold several subscriptions (plan switch, cancel-then-resubscribe). A `customer.subscription.deleted` event for the old subscription is *genuinely* canceled — only the sibling subscription proves the customer is still paying. - The workspace snapshot was read at the top of the handler, before several slow awaits, so a concurrent event could change it mid-flight. ## Fix Every event now converges the workspace to the current Stripe state, regardless of delivery order: - **Live input**: fetch the customer's not-ended subscriptions from Stripe (`subscriptions.list` without a `status` param excludes the unbounded canceled history server-side; an explicit `NOT_ENDED_SUBSCRIPTION_STATUSES` filter additionally drops `incomplete_expired`, which would otherwise block suspension forever since it is neither suspend-worthy nor activating). The event's subscription is taken from that list, or fetched directly by id when absent (deletion events, deleted customers) — same retrieve the code used before. The DB upsert uses this live object, never the payload. - **Workspace-level decision over all live subscriptions**: suspend only when **every** subscription warrants it, reactivate as soon as **one** is activating (`active`/`trialing`), and deliberately do nothing in between — e.g. a `past_due` subscription in its payment-retry grace period blocks suspension without triggering reactivation. - **Guarded transitions (compare-and-swap)**: `WorkspaceService.suspendWorkspace`/`reactivateWorkspace` now apply their UPDATE only when the workspace is still in a state the transition is valid from, and return whether they applied. Repeated suspensions keep the first `suspendedAt` so the cleanup countdown stays anchored to the original suspension date; the deletion-warning cleanup job is only enqueued when a reactivation actually applied. The suspend path re-reads the workspace right before deciding and switches exhaustively on `activationStatus` (`assertUnreachable` in `default`), preserving the previous behavior including suspend-over-reactivate precedence. ## Tests Unit tests cover the incident scenario and its neighbors: a stale `past_due` event after payment reactivates instead of suspending; a live `unpaid` state suspends even when the payload says `active`; a canceled subscription event does not suspend (and reactivates) when a sibling `active`/`trialing` subscription exists; a sibling in `past_due` grace blocks suspension without reactivating; the direct-retrieve fallback handles subscriptions absent from the customer list; the guarded reactivation skips the cleanup job when it did not apply; and soft-deleted workspaces are never transitioned. |
||
|
|
7ec7774087 |
Fix infinite redirect loop when logging out of a suspended workspace (#22949)
## Problem
Clicking **Log out** on a workspace suspended for a past-due
subscription locks the tab into an infinite redirect loop that hammers
the server with unauthenticated GraphQL requests until the tab is
closed.
Reproduced on cloud: workspace with `Pro plan • Past due` (activation
status `SUSPENDED`), user forced onto `/settings/billing`, click Log out
→ tab freezes, URL flip-flops between `/welcome` and
`/settings/billing`, requests stream out continuously.
## Root cause
`clearSession` nulls the `tokenPairState` atom and removes the persisted
session localStorage keys, but leaves the **in-memory**
`currentWorkspaceState`/`currentUserState` atoms populated, relying on
the subsequent `window.location.assign('/welcome')` reload to reset
them.
`PageChangeEffect` keeps running until that reload commits, and the
intermediate state (no token + suspended workspace) makes
`usePageChangeEffectNavigateLocation` ping-pong:
- on `/settings/billing`: no token → navigate to `/welcome`
- on `/welcome`: the no-token guard is skipped (`SignInUp` is in
`ONGOING_USER_CREATION_PATHS`), then `isWorkspaceSuspended` reads the
**stale** workspace atom → navigate back to `/settings/billing`
The synchronous navigation loop pegs the main thread, so the pending
full-page navigation never commits and the loop never resets. Every
bounce remounts pages whose queries refire without a token (each one
erroring `UNAUTHENTICATED`), plus Sentry envelopes — the server spam.
Verified during repro: mid-loop the tab had `tokenPairState="null"` and
no `currentWorkspaceState` in localStorage (the loop runs fully
unauthenticated off the in-memory atom), and an injected
`localStorage.setItem` wrapper survived the whole loop, proving the page
never reloaded.
## Fix
Clear the same in-memory auth atoms in `clearSession` that
`onUnauthenticatedError` (useApolloFactory) already clears:
`currentUserState`, `currentWorkspaceState`,
`currentWorkspaceMemberState`, `currentUserWorkspaceState`. With the
workspace atom gone, the suspended guard can't fire after logout, the
ping-pong never starts, and the redirect to `/welcome` commits normally.
## Test
Extended the `useAuth` sign-out test: seeds a suspended
`currentWorkspaceState` and a `currentUserState` before `signOut()` and
asserts both are null afterwards.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22949?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. -->
|
||
|
|
9860871eac |
perf(server): lower workspace local cache cap to 6,000 entries (#22946)
## Context #21954 raised `MAX_LOCAL_CACHE_ENTRIES` from 1,000 to 7,500 to stop the per-pod workspace metadata cache from thrashing against Redis. That worked: the cache node's egress dropped from ~1.1-1.8 TB/day to ~0.5-0.7 TB/day and has stayed there. The memory side of the trade turned out tighter than the sizing assumed. Prod-eu metrics over the past month: - Average server pod working set rose from ~1.9-2.4 GiB (before the rollout on 06/26) to ~3.0-3.4 GiB, with individual pods peaking at 3.5-3.6 GiB, right at the `--max-old-space-size=3500` heap ceiling on 4 GiB pods. - The `workspace-metadata-cache/local-eviction` counter added in #21954 stayed at zero for three weeks, then on 07/15 between 08:30 and 09:30 UTC all 7 server pods hit the 7,500-entry cap within an hour (no deploy that morning; organic growth crossed the threshold during the European morning peak). Since then the fleet evicts continuously (~167k entries/day). - Server pods were terminated with reason OOMKilled on 8 days of the past month (1-2 pods each time). The per-entry payload also grew since the cap was sized: search field metadata backfills, two new per-workspace cache components (`flatSearchFieldMetadataMaps`, `workflowAutomatedTriggerMaps`), and the heap-only `ORMEntityMetadatas` entries. So the byte ceiling implied by 7,500 entries now sits exactly at the heap limit instead of comfortably under it. ## What this does Lower `MAX_LOCAL_CACHE_ENTRIES` 7,500 → 6,000. At the measured ~170-200 KB average entry size this frees roughly 300-500 MB of steady-state heap per pod, restoring headroom under the 3.5 GB old-space limit. Traffic cost should be small: eviction churn at the 7,500 cap only moved cache egress from ~0.55 to ~0.65-0.68 TB/day, so a 20% smaller cap should keep the bulk of the #21954 reduction. The eviction counter gives us the feedback loop; if egress climbs materially we can split the difference, and if pods still run hot the next lever is byte-aware eviction for the few known-heavy keys. |
||
|
|
1b9152d4c5 |
chore: sync AI model catalog from models.dev (#22939)
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev). This PR updates pricing, context windows, and model availability based on the latest data. New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically. Deprecated models are detected based on cost-efficiency within the same model family. **Please review before merging** — verify no critical models were incorrectly deprecated. Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com> |
||
|
|
5f8baa9761 |
i18n - docs translations (#22933)
Created by Github action Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
9c2d87a846 |
Fix role lookup query and align calendar layout behavior (#22928)
## Summary Fixes slow getRoles requests caused by loading several one-to-many role relations in a single TypeORM query. The previous query produced a Cartesian product across role targets, permission flags, object permissions, and field permissions. In production, a workspace with 9 roles expanded into more than 32,000 database rows before TypeORM hydration. This change: - Reads roles and their related permissions from the existing flat-map caches. - Hydrates relations using the same findManyWithRelationsFromCache pattern as ViewService. - Removes the expensive joined query from the getRoles path. - Preserves the existing GraphQL response shape. ## Performance ### Before: 9 roles 32,257 SQL result rows Approximately 10 seconds observed request latency ### After: No Cartesian SQL query Cache-backed lookups and in-memory relation hydration <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22928?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. --> |
||
|
|
79f3a5243a |
Add callAppRoute to RestApiClient (#22863)
Adds a `callAppRoute` method to `RestApiClient` in `twenty-client-sdk/rest`. It calls one of the app's own HTTP routes using the injected `TWENTY_FUNCTIONS_URL`, resolved internally the same way the client already resolves `TWENTY_API_URL`, so app code no longer reads env vars or knows how function routes are hosted. Both app runtimes already go through `RestApiClient` for route calls (logic functions and front components), so both get this in one place; front components keep the existing 401 token-refresh flow. Pairs with #22825, which makes the injected `TWENTY_FUNCTIONS_URL` callable in every topology (app custom domain -> workspace isolated functions domain -> `SERVER_URL/s`). Once this ships in an SDK release, Call Recorder's own-route plumbing (logic-function and front-component utils) drops its URL resolution and calls `client.callAppRoute(path, body)`. --------- Co-authored-by: martmull <martmull@hotmail.fr> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> |
||
|
|
67ed2689ce |
Unify manifest apply pipeline between application install and dev sync (#22921)
First step of unifying application behaviors across sources (NPM, TARBALL, LOCAL), following up on #22868. No storage layout changes. ## Problem Marketplace/tarball installs (`ApplicationInstallService`) and CLI dev sync (`ApplicationDevelopmentService`) each implemented the second half of application delivery — metadata sync, SDK client generation, registration refresh — with quietly diverging behavior: - Install decided SDK regeneration on `!isVersionUpgrade` (application row exists), dev sync on `!application.version`. The version-based check is the robust one: a failed first install leaves an application row without a version, and the row-based check then skipped SDK generation on retry unless the schema changed. - Install refreshed the registration manifest (`updateFromManifest`) without syncing its variable schemas, while catalog sync, tarball upload, and dev sync all do. An NPM install that bumped `latestAvailableVersion` with new `serverVariables` left the registration's variable schemas stale until the next catalog cron. - The guards protecting shared registrations from workspace writes (npm-sourced or not owned by the workspace) lived only inside dev sync's `syncRegistrationMetadata`. ## Change New `ApplicationManifestApplyService` (application-manifest module) owns those steps for both flows: - `applyManifestToWorkspace`: `synchronizeFromManifest` + SDK client generation when it's the first successful apply (no persisted version) or the schema changed. Used by install and dev sync. - `refreshRegistrationFromManifest`: guarded registration update + variable schema sync. Callers acting for a workspace (dev sync) pass `onlyIfOwnedByWorkspaceId`, moving the npm/ownership guard into the shared service; installs pass `latestAvailableVersion` + `preventVersionDowngrade` as before. Returns whether anything was written so dependent side effects (registration asset store in dev sync) skip together with it. - `ApplicationRegistrationService.updateFromManifest` now returns whether it wrote, so variable schemas are only synced from manifests that were actually applied — previously a downgraded install would still have synced variables from the older manifest if we had naively added the sync there. Install and dev services lose the duplicated logic (and their direct `SdkClientGenerationService` / variable-service dependencies); hooks and file writes stay where they were. ## Behavior deltas (all deliberate) - NPM/TARBALL installs now sync registration variable schemas when they refresh the registration. - Retrying a failed first install regenerates the SDK client even when the schema diff is empty. ## Follow-ups (agreed plan, separate PRs) Single semver version gate; one registration-metadata writer with lock coverage for dev-sync/tarball; unified upgrade incl. TARBALL; recoverable upgrades + stale file cleanup; tarball storage decoupled from the owner workspace; orphan cleanup cron; faster public-asset serving (ETag/304); parallel install file writes; PREBUILT execution mode for app logic functions. ## Validation - New spec `application-manifest-apply.service.spec.ts` (9 tests: SDK generation matrix, ownership/npm guards, downgrade-skip short-circuits variable sync) - All 26 application suites pass; typecheck, oxlint (type-aware) and oxfmt green on twenty-server --- _Generated by [Claude Code](https://claude.ai/code/session_015L4zvAL9bsk7azYkbhcZSg)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22921?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. --> |
||
|
|
a69852c1b0 |
i18n - translations (#22927)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
8cf462d5f7 |
Add day view support to record calendar (#22922)
## Summary - Add a calendar day view and wire it into the record calendar layout selection - Update the top bar, time grid, and week/day drag and drop handling to support the new view - Extend supported layout logic and public feature flags for calendar day view access - Add coverage for calendar view content, calendar container behavior, top bar behavior, day view rendering, supported layout resolution, and week event drop handling <img width="1276" height="852" alt="Screenshot 2026-07-15 at 17 48 33" src="https://github.com/user-attachments/assets/b1d9d255-2d64-4adb-82b9-3e500cb0d561" /> <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22922?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. --> |
||
|
|
2dcf53619f |
fix(front): keep kanban header columns full-width when board overflows viewport (#22926)
## Problem Before <img width="1340" height="542" alt="Capture d’écran 2026-07-15 à 18 26 22" src="https://github.com/user-attachments/assets/472dfe13-336a-43c7-8986-6cdcb04e5217" /> After <img width="1340" height="542" alt="Capture d’écran 2026-07-15 à 18 26 12" src="https://github.com/user-attachments/assets/e393a3fa-2245-42cc-b965-f16f3feaeff6" /> The record board (Kanban) header breaks when the screen is narrower than the total column width. The stage header columns squish together to fit the viewport while the cards below keep their fixed 220px width and scroll horizontally, so the header labels no longer line up with their columns. Regression from #22323. ## Cause #22323 wrapped each column header in `DragDropColumnSortableCell`. In `fill` mode its `StyledSortableRoot` uses `min-width: 0` with the default `flex-shrink: 1`, so the header cells collapse below their column width when the board is wider than the viewport, instead of overflowing into horizontal scroll like the body. ## Fix Pin `flex-shrink: 0` in `fill` mode so header cells keep their column width and overflow in step with the body. `fill` is board-only; the record table header uses non-fill mode and is unaffected. ## Testing Opportunities → "By Stage" Kanban, narrow the window below the total column width: header stays aligned with the cards and scrolls horizontally with them. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22926?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. --> |
||
|
|
dd9763a7a0 |
Allow workflow run control mutations to be called with API key auth (#22924)
## Context A customer wants to control workflow runs from an external system / their own automation calling the API. Today the workflow mutations are gated by `UserAuthGuard`, which requires an interactive logged-in user (`request.user`). API-key requests set `request.workspace` but never `request.user`, so they can't call them. ## Change `UserAuthGuard` was applied at the class level on `WorkflowTriggerResolver`, blanketing all five mutations even though only `runWorkflowVersion` actually consumes the user (it looks up the workspace member to stamp `createdBy`). This drops `UserAuthGuard` from the class and keeps it only on `runWorkflowVersion`. As a result, these become callable with API-key auth: - `stopWorkflowRun` - `retryWorkflowRun` - `activateWorkflowVersion` - `deactivateWorkflowVersion` None of these ever referenced the user, so no logic depends on it. `runWorkflowVersion` stays user-only because it needs a workspace member to attribute `createdBy`. Permissioning is unchanged: `SettingsPermissionGuard(WORKFLOWS)` stays at the class level and already resolves the permission for API keys via `apiKeyId`, so a key still needs the WORKFLOWS permission. ## Notes / open questions - No actor is recorded for these operations today (they only change run/version state), so exposing them to API keys doesn't drop any audit that existed. Attributing API-key-initiated actions would be a follow-up. - If there's a deliberate product stance that workflow control should stay user-only, this is a policy change worth confirming. |
||
|
|
f4b2968a74 |
fix(server): finalize workflow runs stuck in STOPPING (#22900)
## Context A workflow run only reaches STOPPED via the in-flight worker execution: `stopWorkflowRun` just flips a RUNNING run to `STOPPING` (records intent), and the `STOPPING -> STOPPED` transition is done later inside `computeWorkflowRunStatus`, which only runs while a worker is executing the run's steps. If no worker is executing the run at that point, nothing ever finalizes it: - the worker that owned the run crashed / was killed mid-step (e.g. under heavy load), or - a step legitimately sits in RUNNING awaiting an external event that never arrives (the user stopped it). Only `ENQUEUED` runs had a staleness sweep, so `STOPPING` (and `RUNNING`) had no recovery path and would stay stuck indefinitely. This has been observed in production (~150 runs stuck in `STOPPING` after manual stops during a migration). ## Change Extend the existing staled-runs machinery to also finalize runs left in `STOPPING`: - New `stuck-stopping-runs-threshold` (1h) + `getStuckStoppingRunsFindOptions` matching `status = STOPPING AND updatedAt < now - 1h`. `updatedAt` is a TypeORM update-date column, so it reliably marks when the run entered `STOPPING`, and 1h stays above any legitimate in-flight step. - `handleStuckStoppingRunsForWorkspace` finalizes each match to `STOPPED` via `endWorkflowRun`, so `endedAt`, step infos and the `WorkflowRunStopped` metric stay consistent. It pages the backlog with keyset pagination on `(createdAt, id)`, so a page whose finalizations all fail can't stay at the front of the query and starve later runs (failed ones are retried on the next sweep). - Wired into the same cron (`WorkflowHandleStaledRunsCronJob`, every 10 min), per-workspace job, and the manual `workflow:handle-staled-runs` command — so ops can also clear an existing backlog immediately. The staled-ENQUEUED and stuck-STOPPING handlers run independently (`Promise.allSettled` in the job, separate try/catch in the command), so a failure in one doesn't block the other. Stop remains manual and unchanged; this only guarantees a stopped run eventually reaches `STOPPED`. ## Notes / scope - No schema change (reuses `updatedAt`), so no migration. - `RUNNING` runs orphaned by a worker crash have the same missing-net problem; left out of scope here (this covers the user-triggered STOPPING case). - The new detection query scans `status`/`updatedAt` like the existing ENQUEUED sweep; at very high `workflowRun` volumes an index on `(status, updatedAt)` would help — same pre-existing consideration as the ENQUEUED path. ## Tests Unit tests for `handleStuckStoppingRunsForWorkspace`: no-op when none, finalizes each match to STOPPED, pages through a multi-page backlog, and advances past a fully-failed page instead of starving later runs. Plus a unit test for the `(createdAt, id)` keyset condition in `getStuckStoppingRunsFindOptions`. Full suite green, lint + typecheck clean on changed files. Manually verified on a real instance (Postgres): seeded a `STOPPING` run aged 2h and ran `workflow:handle-staled-runs` -> transitioned to `STOPPED` with `endedAt` set; a freshly-`STOPPING` run (updatedAt now) was correctly left untouched by the 1h threshold. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22900?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. --> |
||
|
|
541c67d222 |
i18n - translations (#22923)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
f67eb60c57 |
feat(workflow): soft-ref core workflow/version (backfill + dual-write) (#22821)
Replaces the shared-UUID model (core row reuses the workspace record id) with a **soft-ref**: the workspace `workflow`/`workflowVersion` records carry a nullable `coreWorkflowId`/`coreWorkflowVersionId` pointing to their **own-id** core rows. This removes the assumption that workspace record ids are globally unique - which is false, since prefilled/seeded workflows share ids across workspaces. Supersedes #22776. ## In this PR **Soft-ref columns (foundation):** - **twenty-shared** `STANDARD_OBJECTS`: `workflowVersion.coreWorkflowVersionId` + `workflow.coreWorkflowId` (+ snapshot test). - **compute utils**: both as system, nullable UUID fields. - **entity classes**: the bare fields. **Version soft-ref sync:** - Core `workflowVersion` rows get their own id, derived deterministically from `workspaceId + record id` (uuidv5). Deterministic so the upsert is idempotent: a failed write-back re-derives the same id and self-heals instead of orphaning rows or colliding on the one-active-per-workflow index. - Sync = find-or-create keyed on the workspace record's `coreWorkflowVersionId`, then write the core id back onto the workspace record. - Migrating over pre-soft-ref data: purges any core row whose id equals the workspace record id before recreating, so old shared-UUID rows aren't orphaned. - Version dual-write listener reworked: delete is keyed by the core id read off `before.coreWorkflowVersionId`. Verified on a fresh `database:reset` (columns materialize, backfill produces deterministic own-id rows linked back, idempotent re-run), a simulated old shared-UUID state (stale rows purged, records re-linked), and a simulated write-back failure (retry re-links to the same id, no orphan, active-version index intact). ## Next steps (follow-up work, not in this PR) 1. Workflow-side soft-ref sync mirroring the version side (service, module, dual-write listener, backfill command). 2. Workspace command to add the two columns to existing workspaces. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22821?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. --> |
||
|
|
edd35c79d9 |
i18n - docs translations (#22919)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22919?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> |
||
|
|
8e03921372 |
Add CREATED workspace activation status (read path + enum migration) (#22904)
## Context Since v2 onboarding (#22303), workspaces are activated **before** the billing plan step (now the last onboarding step). Users abandoning at the plan step leave ACTIVE workspaces with a Stripe customer but no subscription (~60–110/day on cloud, 935+ so far), and no cleanup mechanism ever touches them: billing webhooks never fire (no subscription), the suspended-workspaces cron only handles SUSPENDED, the onboarding cron only handles PENDING_CREATION/ONGOING_CREATION. Target lifecycle (across two PRs): `PENDING_CREATION → ONGOING_CREATION → CREATED → ACTIVE → SUSPENDED → deleted`. **`CREATED`** = the workspace schema is provisioned but onboarding is not complete — no billing subscription yet. It is **not** considered active: | Concern | CREATED behavior | |---|---| | Sign-in / invited teammates joining | allowed (invite-team step precedes the plan step) | | Member + metadata loading (app shell) | allowed (user must finish onboarding) | | Permissions | real permission checks (no PENDING-style bypass) | | Version upgrades / workspace migrations | **included** (schema must not drift) | | Messaging/calendar/workflow/etc. crons | **excluded** — no background processing until a plan is chosen | | PLAN_REQUIRED onboarding lock | unchanged (still derived from subscription existence) | ## What this PR does (read path only) The enum addition ships as a **slow** instance command, which can run after deploy — so nothing in this PR ever **writes** `CREATED`. The write path (setting it at activation, the cleanup sweep, the backfill of the existing zombie cohort) is a follow-up PR that ships once this migration has run everywhere. - **twenty-shared**: `CREATED` enum value; `PROVISIONED_WORKSPACE_ACTIVATION_STATUSES` + `isWorkspaceProvisioned` ("schema exists": CREATED | ACTIVE | SUSPENDED), replacing `isWorkspaceActiveOrSuspended` — all call sites (server member loading, access-token workspace-member lookup, front metadata-store gates) meant "has schema/members". - **Slow instance command** (2.22.0): swaps `core.workspace_activationStatus_enum` using the rename→recreate→alter-column idiom. The CHECK constraints on `core.workspace` embed casts to the enum type and would break the swap — the command captures them from `pg_constraint`, drops them, swaps the type, and restores them. - **Pre-migration-safe queries**: Postgres rejects `IN ('CREATED', ...)` when the enum value does not exist yet — even for reads, and the instance-command runner itself queries provisioned workspaces before migrating (a fresh database could never initialize). All provisioned-status filters go through a new `activationStatusIn` util comparing on `"activationStatus"::text`, valid before and after the migration. - **Upgrade path**: workspace iterator, command runner, upgrade-status and workspace-version services iterate CREATED workspaces. Since they now cover more than ACTIVE/SUSPENDED, the stale names were renamed to `ProvisionedWorkspaceCommandRunner`, `hasProvisionedWorkspaces`, `getProvisionedWorkspaceIds`, `loadProvisionedWorkspaces` (the mechanical import rename in old version-command dirs is why this PR carries the `ci:allow-previous-version-upgrade-mutation` label). - **Sign-in**: `throwIfWorkspaceIsNotReadyForSignInUp` accepts CREATED so invited members can join during onboarding (join authorization itself is unchanged — enforced upstream in `checkAccessForSignIn`); `activateWorkspace` idempotent-retry accepts CREATED as a terminal state. - **Transitions out of CREATED** (only write ACTIVE — safe to ship now, dead until the write path lands): the Stripe webhook reactivation branch also promotes CREATED, and `syncSubscriptionToDatabase` promotes synchronously; both gated on `WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES` (Active/Trialing — extracted from `shouldReactivateWorkspace`, behavior-preserving) so an `incomplete` subscription created by the payment-intent flow before payment never promotes the workspace. - Deliberately untouched: all background crons, permission guards, JWT strategy, PLAN_REQUIRED logic, admin panel (renders the raw status string). ## Follow-up PR (after this migration has run) 1. `activateWorkspace` sets `hasWorkspaceAnySubscription ? ACTIVE : CREATED` (billing disabled → always ACTIVE, self-hosted unchanged). 2. Cleanup: suspend CREATED workspaces older than N days (config var), handing them to the existing suspended pipeline (warn → soft-delete → destroy). 3. Backfill: cloud-only slow command moving ACTIVE workspaces with no billingSubscription row (created since Jul 1) to CREATED. ## Verification - Migration exercised against a real database via the command class: up → down → up; `enum_range` and `pg_get_constraintdef` checked after each step (constraints restored against the new type, `DEFAULT 'INACTIVE'` preserved). - Pre-migration safety exercised for real: with the migration rolled back (enum without CREATED), `run-instance-commands` — the exact fresh-database CI path that failed before the `::text` fix — completes cleanly. - End-to-end with a workspace manually set to CREATED and the branch server+front running: sign-in issues tokens, `currentUser` loads workspaceMember(s), the full app loads with no console errors; GraphQL returns `activationStatus: CREATED`. - Workspace creation ran end-to-end locally in **both billing modes** on this branch: - billing disabled: signup → workspace creation → ACTIVE immediately → onboarding completes with no plan step → app loads (unchanged behavior); - billing enabled (Stripe test mode): signup creates the Stripe customer eagerly → activation ends ACTIVE → subscription-less workspace is pinned to the plan-required page → no-card trial checkout creates a `trialing` subscription via `createDirectSubscription`/`syncSubscriptionToDatabase` → app loads. - `twenty-shared` unit tests, server specs on touched services, `lint:diff-with-main` and `typecheck` for shared/server/front all green; full CI green. |
||
|
|
cdb2590355 |
Migrate call-recorder tests off own-code vi.mock (#22902)
<!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22902?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. --> |
||
|
|
36a14478ae |
i18n - translations (#22916)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
25bd2897a3 |
Add weekly layout to record calendar (#22819)
## Summary - Add a week layout to record calendar views and persist the selected layout. - Render `DATE` calendars as an all-day week and `DATE_TIME` calendars as an hourly week. - Add an optional end date field across calendar configuration, metadata, persistence, and complete-view upserts. - Use configured end values for ranged and multi-day events, with a one-hour fallback when a `DATE_TIME` end is absent or invalid. - Keep calendar cards consistent with the existing compact view, including checkbox selection and whole-card record opening. - Gate the weekly layout and end-date behavior behind the public Labs `IS_CALENDAR_WEEK_VIEW_ENABLED` workspace feature flag. ## Week interactions - Show overlapping timed events side by side and cap the visible records at two per day. - Display start and end times on timed cards, enforce a readable 30-minute minimum height, and keep today’s text contrast stronger. - Drag timed events between days and times with 30-minute snapping while preserving their duration, including zero-duration events. - Show a create button when hovering a 30-minute slot; keyboard users can focus a day, move the slot with the arrow keys, and reach the same contextual action. - Initialize new records with the selected slot time and a compatible writable end value one hour later. - Show the workspace time zone and current-time indicator in timed weeks; date-only weeks keep the all-day section without an hourly grid. ## Configuration and data loading - Only allow end fields that match the start field type, and prevent selecting the same field for both boundaries. - Load records whose ranges overlap the visible period so month and week layouts display the same relevant records. - Resolve and persist calendar end fields when updating existing views through `upsert_complete_view`. - Fall back to Month and ignore the configured end field while the flag is disabled, without overwriting either persisted setting, so re-enabling restores the previous configuration. - Expose the flag in Labs and keep it default-off for workspaces without a stored value; enable it in the development seeder. <img width="1285" height="808" alt="Screenshot 2026-07-15 at 15 50 17" src="https://github.com/user-attachments/assets/b7e3f7f1-ca77-492f-8cce-cca186ebca0b" /> |
||
|
|
0dbae2eda3 |
Address #22827 review comments and converge application file endpoints (#22868)
Follow-up to #22827, addressing the review comments left around merge time and applying the endpoint convergence discussed afterwards. ## Review comments from #22827 - **Swallowed error in dev sync asset read** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571513265)): the swallow is intentional (a missing public asset must not fail the whole dev sync) but it now logs a warning with the asset path and error, and the registration keeps its previously stored file for that path instead of losing it. - **`isAbsoluteUrl` location** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571524234)): moved to `twenty-shared/utils/url`. The server, and now also `twenty-sdk`'s `normalize-application-assets`, use the shared util. - **Soft delete vs file cleanup** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571589558)): per review, deleting a registration is now a hard delete. Stored assets (bytes + rows) are deleted with it, dependent rows are removed by their existing FK cascades, and installed applications keep working with their registration link nulled. No soft-delete/cron mechanism. - **Asset cap too generous** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571595745)): lowered to 10MB per review and documented in the publishing and public-assets docs pages. - **One missing image retriggers a full asset sync** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571646243)): `storeRegistrationAssets` now takes `skipAlreadyStoredPaths`; the catalog sync passes it when the package version is unchanged, so only assets missing a stored file are fetched instead of re-downloading everything. - **`existing.logo` already contains the new logo** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571667847)): correct, `updateFromManifest` runs first, so the previous "keep fileId when the path did not change" guard compared the new logo against itself. The fileId preservation is now keyed on the stored server file for the exact path (files are unique per `(applicationRegistrationId, path)`): a changed logo path no longer inherits the old file's id, and a transient download failure on an unchanged path still keeps the working file. This also removed the fileId-preservation bookkeeping from `storeRegistrationAssets`. ## Endpoint convergence - **Path-addressed public route for registration assets**: `GET /file/server/application-registration/:fileId` is replaced by `GET /files/application-registrations/:registrationId/*path`, mirroring the manifest's public-folder paths and leaving room for a future `:version` segment. Assets stay addressable by stable ids server-side; the fileId now only marks a path as stored. No URL is ever persisted (all are built at query time), and the old route never shipped in a release, so there is nothing to migrate. - **`Application.logoUrl` resolved server-side**: new `ResolveField` on the `Application` type builds the `/public-assets/...` display URL (or passes absolute URLs through). `useApplicationChipData` now reads it from `currentWorkspace.installedApplications`, and the frontend `buildApplicationLogoUrl` util is deleted, so clients no longer construct file URLs themselves. ## Validation - Unit: `file.controller.spec` (route renamed, traversal case added), `server-file-storage.service.spec` (`findServerFile`, `deleteByApplicationRegistrationId`), `application-registration-asset-url.service.spec` (new URL shape, url-encoding), new `isAbsoluteUrl` test; all application/file suites pass. - Live against a local server: new route serves tarball and rehosted npm assets with `public, max-age=3600` (nested paths included), 404s on missing files, unknown registrations, traversal attempts, and the removed old route; `findManyApplicationRegistrations` returns path-addressed URLs for stored assets, CDN fallback for npm, absolute passthrough; `installedApplications.logoUrl` resolves the public-assets URL and stays null for logo-less apps. Registration hard delete verified against the DB: file rows cascade, application rows keep a nulled registration link. - Typecheck + lint on twenty-server, twenty-front, twenty-shared, twenty-sdk; metadata codegen and client-sdk regenerated. |
||
|
|
907c5cc39f |
i18n - translations (#22913)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
f4ff234db8 |
feat: make record avatar/icon resolution data-driven via a configurable image identifier field (#22644)
## Summary Today the avatar/icon shown for a record is hardcoded per object — Company pulls a favicon from its domain link, Person uses `avatarUrl`, etc. This PR replaces that hardcoding with a generic, data-driven abstraction based on a configurable **image identifier field** on each object's metadata (mirroring the existing **label identifier** concept). An object's image identifier can point to: - a **`FILES`** field → the uploaded image is used directly (rounded avatar), or - a **`LINKS`** field → a favicon is derived from the primary URL via the Twenty icons service (squared avatar), gated by `ALLOW_REQUESTS_TO_TWENTY_ICONS`. This lets any object type (Opportunity, a custom "Listing", etc.) define its own avatar/icon without code changes, and makes the field configurable/overridable for standard objects. ## ❓ Open question: also allow `TEXT` → direct image URL? Right now the image identifier is restricted to `FILES` (uploaded file) and `LINKS` (favicon). We deliberately left out `TEXT` → **direct image URL** (e.g. an imported/synced photo URL stored in a text field). There's precedent for it — Person's avatar was originally a `TEXT` `avatarUrl`, and WorkspaceMember still is — and it's unambiguous (a `TEXT` field has no favicon-vs-image ambiguity, and selecting it as the image identifier is itself the declaration of intent). It's a small, clean extension: - add `TEXT` to the allowed image-identifier types, - add an explicit `TEXT → raw URL` case - `getAvatarType`: `TEXT → rounded`. Caveats: it relies on admin assertion that the text values are image URLs (no data-level guarantee), and external image URLs load third-party content in the browser (IP-leak/hotlinking, same as favicons — a proxy/cache would be the more robust long-term answer). ### ✅ Resolution Decision: **we will not support `TEXT` as an image identifier.** Image identifiers stay restricted to `FILES` and `LINKS`, and any other type fails closed (returns no avatar) on both the frontend and backend. Instead, the legacy items that still rely on a `TEXT` avatar — Person's deprecated `avatarUrl` and WorkspaceMember's `avatarUrl` — will be migrated to `FILE` fields in a follow-up PR. Until then, WorkspaceMember remains an exception (its `avatarUrl` still resolves through the existing CorePicture path), and legacy Person `avatarUrl` values that haven't been migrated will show initials placeholders. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22644?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
e31e6c7794 |
i18n - docs translations (#22912)
Created by Github action Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
a28c3a905a |
Route pre-2.19 upgrade commands through a legacy validate-build path (#22884)
## Problem Since the centralized metadata side-effect engine landed in v2.19, `WorkspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigrationFromRecord` runs `metadataSideEffectEngineService.expandWithSideEffects(...)` before building. As a result every historical upgrade command (`upgrade-version-command/1-21/*` … `2-18/*`), authored before the engine existed, now flows through it. Their operation matrix is no longer applied literally: the engine injects/cascades companions (system fields, `searchVector` field + GIN index, `searchFieldMetadata` rows, unique backing indexes) and can hard-fail on reserved-identifier collisions (`RESERVED_SYSTEM_UNIVERSAL_IDENTIFIER`). Two hazards for already-shipped commands: 1. **Collision → hard failure**: a command declaring a companion the engine now owns collides with the engine's deterministic `universalIdentifier`. 2. **Silent drift**: on object/field create/delete the engine adds/cascades companions the command author never intended, so workspaces upgraded now differ structurally from those upgraded incrementally before 2.19. Suspected real-world impact: a self-hosted user upgrading v2.6.1 → v2.21.0 hit `duplicate key value violates unique constraint "IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE"` in `upgrade:2-16:backfill-search-field-metadata`, because object-creating commands now cascade and pre-create the deterministic `searchFieldMetadata` rows the standalone backfill then re-inserts. ## Changes - `workspace-migration-validate-build-and-run-service.ts`: extract the shared compute-and-run tail into a private method, and add `validateBuildAndRunLegacyWorkspaceMigration` (marked `@deprecated`) that skips `expandWithSideEffects` and applies the matrix literally. The existing side-effect entry points are unchanged (the live API and application manifests depend on them). - Repoint **all** pre-2.19 upgrade command call sites (1-21 … 2-18, including `2-10 sync-call-recording-standard-objects`) to the legacy method. Only the four `2-20/*` commands (target version ≥ 2.19) remain on the side-effect path. - `2-16 backfill-search-field-metadata`: recompute `flatSearchFieldMetadataMaps` from the database before building the existing-rows dedupe set. The migration runner only invalidates the flat-maps keys a migration touched, so during a cross-version upgrade earlier commands can leave this map stale; a stale map breaks the dedupe and re-inserts rows, tripping `IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE`. This is the direct fix for the reported failure. - Export `FlatEntityMapsBundle` so the shared tail can be typed. - Document the side-effect vs legacy path and the selection rule in `packages/twenty-server/docs/UPGRADE_COMMANDS.md`. Selection rule: target version **< 2.19** → legacy path; **≥ 2.19** → side-effect path (default). No exceptions. ## Known gap / merge ordering The static twenty-standard definition declares all of `callRecording`'s fields (including the `searchVector` system field) but **not** its `searchVector` GIN index — every other searchable standard object declares its GIN index statically. On the legacy path, workspaces upgrading through `2-10 sync-call-recording-standard-objects` therefore create the `searchVector` column unindexed (`searchFieldMetadata` rows are created later in the same pipeline by the 2-16 backfill). The static GIN index declaration plus a backfill for already-upgraded workspaces land in a follow-up (twentyhq/core-team-issues#2672), which must ship in the same release as this PR. ## Out of scope (separate follow-ups) - `UpgradeMigrationService.getLastAttemptedInstanceCommand()` ordering. - callRecording `searchVector` GIN index static declaration + backfill (twentyhq/core-team-issues#2672, same-release dependency, see above). ## Test plan - `nx typecheck twenty-server` passes. - `nx lint:diff-with-main twenty-server` (oxlint + oxfmt) clean on changed files. - 2-20 command specs (which exercise the unchanged side-effect path) pass. --------- Co-authored-by: twenty <noreply@twenty.com> |
||
|
|
4e83a64f81 |
fix(front): read fresh metadata in SSE update path to avoid false unknown-field warnings (#22897)
## Problem Sentry `warning`: *"SSE update event for person carried fields unknown to this tab's metadata: lastInboundAt, pdlCertifications, pdlBirthYear, …"* ([TWENTY-FRONT issue](https://twenty-v7.sentry.io/issues/7610855477)). The listed fields are all custom fields created by the **People Data Labs app** (`packages/twenty-apps/public/people-data-labs`). The flow that triggers this: 1. The app installs a batch of `person` fields (emits metadata SSE events). 2. Its enrichment logic-function updates a person record with all of those fields (emits a record-update SSE event). Field creation already emits metadata SSE events, and the front applies them **synchronously** to the Jotai metadata store (`MetadataStoreSSEEffect` → `applyChanges` → `store.set`). So the store converges. The bug is that the SSE **record-update** handler doesn't read the converged store. `useTriggerOptimisticEffectFromSseUpdateEvents` reads `objectMetadataItems` from a React/Jotai closure captured at render time. The long-lived SSE subscription in `useTriggerEventStreamCreation` holds a metadata snapshot that lags the store, so even after the field-create events have been applied, the record path still sees the old field set. It then: - flags the new fields as "unknown" and logs to Sentry, and - **drops those field values** from the optimistic update (`getUnknownRecordInputFields` filters them out), so already-loaded views miss the enriched data until a refetch. Metadata events are dispatched before record events within each SSE message (`useTriggerEventStreamCreation` lines 126-128), and the store update is synchronous, so a fresh store read at processing time sees fields that converged in the same or any earlier message. ## Fix Read `objectMetadataItems` fresh from the Jotai store at event-processing time instead of from the render closure, and re-resolve the object metadata item from that fresh list. This eliminates the false-positive warnings and stops dropping legitimately-known field values. Follows the design from #22474: the metadata-event pipeline owns schema convergence; the record pipeline just reads the converged store (now actually reading the current store rather than a stale snapshot). A genuine race where the record update truly precedes the field-create event is still tolerated and still warns. ## Testing - `nx typecheck twenty-front` passes - `nx lint:diff-with-main twenty-front` passes <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22897?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. --> |
||
|
|
055e8b5335 |
Add tab param to open a record side panel page on a specific tab (#22905)
## Context
`CommandOpenSidePanelPage` (and `openSidePanelPage` in the front
component SDK) could open a record in the side panel, but always landed
on the default tab. This adds an optional `tab` param to the
`ViewRecord` page params so an app command can open a record directly on
a specific tab.
## What changed
- **twenty-sdk**: `OpenSidePanelPageParams` `ViewRecord` variant accepts
an optional `tab` (a page layout tab id). Since
`CommandOpenSidePanelPage` props are `OpenSidePanelPageParams`, the
component picks it up automatically.
- **twenty-front**:
- New `setRecordPageActiveTabId` util resolves the record page layout
for the object (custom layout from the store, or the default layout id)
and presets `activeTabIdComponentState` on the tab list instance
(`${pageLayoutId}-tab-list-${recordId}`), which is shared by the side
panel and the full record page.
- `useOpenRecordInSidePanel` accepts `tab` and presets the active tab
before navigating; it also applies when the record is already open in
the side panel (tab switch only).
- `useFrontComponentExecutionContext` forwards `tab` to the side panel
open, and presets the tab when falling back to full-page navigation
(mobile, or objects that can't open in the side panel).
- **Docs**: mention the optional `tab` id in the
`CommandOpenSidePanelPage` description.
Unknown tab ids are harmless: `PageLayoutTabListEffect` falls back to
the layout's default tab when the preset id doesn't exist in the layout.
Dashboards are skipped since their layout id comes from record data, not
object metadata.
## Tests
- `useOpenRecordInSidePanel`: new test asserting the active tab atom is
preset on the correct tab list instance id.
- `useFrontComponentExecutionContext`: new tests for tab passthrough to
the side panel and tab preset on full-page fallback.
- `npx nx typecheck twenty-front`, `typecheck twenty-sdk`,
`lint:diff-with-main twenty-front`, `lint twenty-sdk` all green.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01VaWY1H9RZqgJkytqZakvZi)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22905?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. -->
|
||
|
|
14dacd8d35 |
[Slow db query] Resolve applicationId from cache in FileStorageService (#22870)
## Context Sentry flagged a recurring slow DB query (TWENTY-SERVER-HZJ): `SELECT ... FROM core.application WHERE workspaceId = $1 AND universalIdentifier = $2 AND deletedAt IS NULL LIMIT 1`, emitted on every file write under `POST /graphql` (record avatar/file fields) and `POST /metadata`. `FileStorageService` re-resolved the owning application row from `core.application` by `(workspaceId, universalIdentifier)` on every file write, uncached and synchronously in the request path. The row was only used to recover `application.id`. The workspace cache already exposes this mapping via `flatApplicationMaps.idByUniversalIdentifier`. Closes twentyhq/core-team-issues#2668. ## Changes - Injected `WorkspaceCacheService` into `FileStorageService` in place of the `ApplicationEntity` repository. - Added `resolveApplicationIdOrThrow`: resolves `applicationId` from `flatApplicationMaps.idByUniversalIdentifier` on the normal (already-committed) path, throwing `FileStorageException(FILE_NOT_FOUND)` on a cache miss. When a `queryRunner` is provided (application-creating transactions, where the freshly created row is not yet in cache), it keeps the DB read through `queryRunner.manager` so it can see uncommitted rows. - Added `resolveApplicationUniversalIdentifierOrThrow` for the by-id lookup in `deleteByFileId`, resolved from `flatApplicationMaps.byId`. - Applied the cache path to `writeFile`, `createPendingFile`, `deleteFile`, `deleteFolder`, and `deleteByFileId`. Only `writeFile` carries a `queryRunner`; the others never do. - Updated `FileStorageModule` to import `WorkspaceCacheModule` and drop the now-unused `ApplicationEntity` repository registration. No migration needed: a partial unique composite index on `(universalIdentifier, workspaceId) WHERE deletedAt IS NULL AND universalIdentifier IS NOT NULL` already exists on `ApplicationEntity` and covers the query. ## Tests Extended `file-storage.service.spec.ts`: - cache hit resolves `applicationId` without a DB call, - cache miss throws `FILE_NOT_FOUND`, - the `queryRunner` path still reads from the DB and skips the cache. All 95 file-storage unit tests pass; typecheck, oxlint, and oxfmt are clean on the touched files. --- _Generated by [Claude Code](https://claude.ai/code/session_018GUrJ26xvZpjtrGGev9jsk)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22870?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |