Compare commits

...
Author SHA1 Message Date
7f1814805d Fix backfill record page layout command (#19043)
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-27 16:56:30 +01:00
34b81adce8 i18n - translations (#19046)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-27 16:38:08 +01:00
Félix MalfaitandGitHub cd2e08b912 Enforce workspace count limit for multi-workspace setups (#19036)
## Summary
- Limits workspace creation to 5 workspaces per server when no valid
enterprise key is configured
- Enterprise key validity is checked first (synchronous, in-memory
cached) to avoid unnecessary DB queries on enterprise deployments
- Adds 4 unit tests covering: limit enforcement, enterprise key bypass,
below-limit creation, and performance (no enterprise check when
workspace count is zero)

## Test plan
- [x] All 11 unit tests pass (8 existing + 3 new behavioral + 1
performance assertion)
- [ ] Manual: verify workspace creation blocked at limit=5 without
enterprise key
- [ ] Manual: verify workspace creation allowed beyond limit with valid
enterprise key
- [ ] Manual: verify first workspace (bootstrap) creation is unaffected


Made with [Cursor](https://cursor.com)
2026-03-27 16:31:27 +01:00
Charles BochetandGitHub fb21f3ccf5 fix: resolve availabilityObjectMetadataUniversalIdentifier in backfill command (#19041)
## Summary

- Fixes workflow manual trigger command menu items losing their
`availabilityObjectMetadataId` during the 1-20 backfill upgrade command
- The migration runner's `transpileUniversalActionToFlatAction` resolves
`availabilityObjectMetadataId` **from**
`availabilityObjectMetadataUniversalIdentifier`, overwriting the
original value. The backfill was hardcoding the universal identifier to
`null`, causing all `RECORD_SELECTION` items to end up with `NULL`
`availabilityObjectMetadataId` after persistence.
- Now properly resolves `availabilityObjectMetadataUniversalIdentifier`
from `flatObjectMetadataMaps.universalIdentifierById` so the runner can
correctly derive the FK during INSERT.
2026-03-27 16:01:31 +01:00
Paul RastoinandGitHub 281bb6d783 Guard yarn database:migrate:prod (#19008)
## Motivations
A lot of self hosters hands up using the `yarn database:migrated:prod`
either manually or through AI assisted debug while they try to upgrade
an instance while their workspace is still blocked in a previous one
Leading to their whole database permanent corruption

## What happened
Replaced the direct call the the typeorm cli to a command calling it
programmatically, adding a layer of security in case a workspace seems
to be blocked in a previous version than the one just before the one
being installed ( e.g 1.0 when you try to upgrade from 1.1 to 1.2 )

For our cloud we still need a way to bypass this security explaining the
-f flag

## Remark
Centralized this logic and refactored creating new services
`WorkspaceVersionService` and `CoreEngineVersionService` that will
become useful for the upcoming upgrade refactor

Related to https://github.com/twentyhq/twenty-infra/pull/529
2026-03-27 14:39:18 +00:00
c96c034908 i18n - translations (#19040)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-27 14:53:33 +01:00
Abdul RahmanGitHubCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>Charles Bochet
5efe69f8d3 Migrate field permission to syncable entity (#18751)
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-27 14:47:27 +01:00
c2b058a6a7 fix: use workspace-generated id for core dual-write in message folder save (#19038)
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-27 13:53:30 +01:00
Abdullah.andGitHub 22c9693ce5 First PR to bring in the new twenty website. (#19035)
This PR contains Menu, Hero, TrustedBy, Problem, ThreeCards and Footer
sections of the new website.

Most components in there match the Figma designs, except for two things.
- Zoom levels on 3D illustrations from Endless Tools.
- Menu needs to have the same color as Hero - it's not happening at the
moment since Menu is in the layout, not nested inside pages or Hero.

Images are placeholders (same as Figma).
2026-03-27 13:48:03 +01:00
50ea560e57 Seed company workflow for email upserts (#18909)
@thomtrp

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-27 13:47:07 +01:00
EtienneandGitHub 56056b885d refacto - remove hello-pangea/dnd from navigation-menu-item module (#19033)
Removed all @hello-pangea/dnd imports from the navigation-menu-item/
module by replacing the type bridge layer with a native
NavigationMenuItemDropResult type. The dnd-kit events were already
powering all DnD — hello-pangea was only used as a type contract and one
dead <Droppable> component.
3 files deleted (dead <Droppable> wrapper, DROP_RESULT_OPTIONS shim,
toDropResult bridge), 4 files edited (handler signatures simplified,
bridge removed from orchestrator, utility type narrowed), 1 type
created. Zero behavioral changes, typecheck and tests pass.
2026-03-27 11:23:25 +00:00
Paul RastoinandGitHub db9da3194f Refactor client auto heal (#19032) 2026-03-27 09:23:26 +00:00
BugIsGodandGitHub 68f5e70ade fix: sign file URLs in timeline and file loss on click outside (#19001)
Fixes: #18943
## Problem

  Two bugs related to the Files field:

1. **File loss on click outside**: When `MultiItemFieldInput` was not in
edit mode (input hidden), clicking outside would still call
`validateInputAndComputeUpdatedItems()` with an empty `inputValue` and
`itemToEditIndex = 0`, causing the first file to be silently deleted.
<img width="624" height="332" alt="image"
src="https://github.com/user-attachments/assets/657aff2c-e497-4685-b8b7-fa22aba772f8"
/>


2. **Unsigned file URLs in timeline activity**: FileId, FileName stored
in `timelineActivity.properties.diff` (before/after values) were not
being signed (timeActivity.properties is stored as json in database),
making them inaccessible from the
frontend.

<img width="1092" height="103" alt="image"
src="https://github.com/user-attachments/assets/23d83ea3-4eb9-41ef-a99c-c4515d1cfb35"
/>


 ## Reproduction


https://github.com/user-attachments/assets/e75b842b-5cbb-46e8-a923-ac9df62deb98

## Changes
- `MultiItemFieldInput.tsx`: Wrap the validate + onChange logic in `if
(isInputDisplayed)` so it only runs when the input is actually open.
- `timeline-activity-query-result-getter.handler.ts`: New handler that
iterates over `properties.diff` fields and signs any file arrays found
in `before`/`after` values (call same method:
`fileUrlService.signFileByIdUrl` as table field view
`FilesFieldQueryResultGetterHandler`.
- `common-result-getters.service.ts`: Register the new handler for
`timelineActivity`.


## After 


https://github.com/user-attachments/assets/368c7be1-3101-43a2-ac93-fe1b0d8a7a37
2026-03-27 08:28:30 +00:00
Félix MalfaitandGitHub 08077476f3 fix: remove remaining direct cookie writes that make tokenPair a session cookie on renewal (#19031)
## Summary

- Removes two remaining direct `cookieStorage.setItem('tokenPair', ...)`
calls that were overwriting the Jotai-managed cookie (180-day expiry)
with a session cookie (no expiry) during **token renewal**
- Followup to #18795 which fixed the same issue in `handleSetAuthTokens`
but missed the renewal code paths

## Root cause

Two token renewal paths still had direct cookie writes without
`expires`:

1. **`apollo.factory.ts`** — `attemptTokenRenewal()` fires on every
`UNAUTHENTICATED` GraphQL error after a successful token refresh
2. **`useAgentChat.ts`** — `retryFetchWithRenewedToken()` fires on 401
from the AI chat endpoint

Both called `cookieStorage.setItem('tokenPair', JSON.stringify(tokens))`
without an `expires` attribute, creating a session cookie that overwrote
the Jotai-managed one. This is why the bug was **intermittent after
#18795**: it only appeared after a token renewal, not on fresh login.

The `onTokenPairChange` / `setTokenPair` calls already write through
Jotai's `atomWithStorage` → `createJotaiCookieStorage`, which always
sets `expires: 180 days`.

## Test plan

- Log in to the app
- Wait for a token renewal to occur (or force one by letting the access
token expire)
- Inspect the `tokenPair` cookie in DevTools → Application → Cookies
- Verify the cookie retains an expiration date ~180 days from now (not
"Session")
- Close and reopen the browser — confirm you remain logged in


Made with [Cursor](https://cursor.com)
2026-03-27 08:21:26 +00:00
6f0ac88e20 fix: batch viewGroup mutations sequentially to prevent race conditions (#19027)
## Bug Description

When reordering stages in the Kanban board, the frontend fires all
viewGroup update mutations concurrently via Promise.all, causing race
conditions in the workspace migration runner's cache invalidation,
database contention, and a thundering herd effect that stalls the
server.

## Changes

Changed `usePerformViewGroupAPIPersist` to execute viewGroup update
mutations sequentially instead of concurrently. The `Promise.all`
pattern fired all N mutations simultaneously, each triggering a full
workspace migration runner pipeline (transaction + cache invalidation).
The sequential `for...of` loop ensures each mutation completes
(including its cache invalidation) before the next begins, eliminating
the race condition.

## Related Issue

Fixes #18865

## Testing

This fix addresses the root cause identified in the Sonarly analysis on
the issue. The concurrent mutation pattern was causing:
- PostgreSQL row-level lock contention on viewGroup rows
- Cache thundering herd from repeated invalidation/recomputation cycles
- Server stalls requiring container restarts

The sequential approach ensures proper ordering and prevents these race
conditions.

---------

Co-authored-by: Rayan <rayan@example.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-27 08:13:51 +00:00
Charles BochetandGitHub 17424320e3 fix: gate command-menu-items query behind feature flag (#19029)
## Summary
- Gate the `FindManyCommandMenuItems` GraphQL query behind the
`IS_COMMAND_MENU_ITEM_ENABLED` feature flag on the frontend, preventing
an uncaught error when the flag is not enabled for a workspace
- Remove `IS_CONNECTED_ACCOUNT_MIGRATED` from the default feature flags
list
2026-03-27 08:11:26 +01:00
6360fb3bce chore: sync AI model catalog from models.dev (#19028)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-03-27 07:22:18 +01:00
Baptiste DevessierandGitHub da1b1f1cbc Combine and clean upgrade commands for record page layouts (#19004)
- Remove the label identifier field for all standard objects as it's a
first-class citizen that is displayed specifically in the app; it
doesn't make a lot of sense to display it in the Fields widgets
- Disable logic that made the label identifier required and in first
position
- Add all fields for all standard objects in record page layout view
fields
- Do not include position and ts vector fields in custom objects

> [!IMPORTANT]
> The command will create Field widgets for all relations. It is
consistent to the way the frontend dynamically generates them as of
today. We will have to decide which relations we pin as individual Field
widgets before the release. (This will likely land in this command or in
another one.)
2026-03-26 17:50:29 +01:00
nitinandGitHub 31718d163c [Dashboards] fix rich text widget AGAIN (#19017) 2026-03-26 17:50:10 +01:00
Abdul RahmanandGitHub f47608de07 Clear navbar edit selection when closing the side panel (#18940)
In navbar edit mode, selecting an item for edit stored
selectedNavigationMenuItemIdInEditModeState (and related
pending-insertion state). Closing the side panel did not reset those
atoms, so the nav item stayed visually selected. Reset both when the
panel closes so the highlight matches the closed panel; reopening in
edit mode then starts from the generic “new item” entry unless the user
picks an item again.
2026-03-26 17:49:50 +01:00
Thomas des FrancsandGitHub 695518a15e Fix not shared chip height + hard coded border radius (#19020)
## Summary
- align the forbidden field "Not shared" chip with small chip dimensions
in the object table
- use the shared small border radius token instead of a hardcoded value
- add `overflow: hidden` and `user-select: none` to match chip behavior
more closely

## Testing
- Not run (not requested)
2026-03-26 17:48:20 +01:00
Thomas TrompetteandGitHub 34ab72c460 Fix: Cannot create two workflow agent nodes because these have the same name (#19015)
Currently, when trying to create a second step agent, we get the error
agent already exists because the name is using workflow id. Using step
id instead.
2026-03-26 16:00:15 +00:00
9eba54134d i18n - translations (#19019)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-26 16:50:18 +01:00
34832815e6 fix: update settings page for mobile viewport (#18991)
## Description

- This PR fixes
https://github.com/twentyhq/core-team-issues/issues/2313#issuecomment-4116380772
- Fixes Admin Panel Apps table, Data model table and Roles table
- made data model table scrollable for mobile viewport keeping name
column fixed.


## Visual Appearance

## Before

- Roles Table

<img width="636" height="1039" alt="Screenshot 2026-03-26 at 1 28 30 PM"
src="https://github.com/user-attachments/assets/fa7532bd-aeb0-4c8f-a40e-08cc976cd2c2"
/>



- Admin Apps table

<img width="610" height="998" alt="Screenshot 2026-03-26 at 1 28 12 PM"
src="https://github.com/user-attachments/assets/8712473c-350f-46f8-9e68-9cb0a5fa9d80"
/>





- Data Model table



https://github.com/user-attachments/assets/c48075c5-56dd-4b76-acd4-76330d6dab94






## After

- Roles Table

<img width="761" height="1045" alt="Screenshot 2026-03-26 at 1 23 19 PM"
src="https://github.com/user-attachments/assets/17099956-816a-4d41-b987-563f6931a995"
/>

- Admin Apps table

<img width="794" height="1044" alt="Screenshot 2026-03-26 at 1 23 34 PM"
src="https://github.com/user-attachments/assets/0463a087-2363-4192-9adb-7d27076f303a"
/>


- Data model Table


https://github.com/user-attachments/assets/fd44e353-cc7c-44cf-bda0-d2f3cd531f2e

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-26 16:47:40 +01:00
Félix MalfaitandGitHub 7a341c6475 feat: support authType on AI providers for IAM role authentication (#19016)
## Summary
- Adds an `authType` field (`'api_key' | 'access_key' | 'iam_role'`) to
AI provider config
- Providers like Amazon Bedrock that authenticate via IAM role (instance
profile) can now be registered without explicit API keys or access keys
- Backend: `isProviderConfigured()` checks `apiKey || accessKeyId ||
authType`
- Frontend admin panel: shows green "Configured" badge and "IAM role"
description for providers with `authType: "iam_role"`
- Provider detail page shows "IAM role (instance profile)" in the
credentials row

## Companion PR
- twentyhq/twenty-infra#528 — patches `authType: "iam_role"` into
dev/staging Bedrock catalogs

## Changed files
- **Backend**: new `AiProviderAuthType` type, `isProviderConfigured`
util, updated registry + resolver
- **Frontend**: new `AiProviderAuthType` type, updated provider list
card + detail page

## Test plan
- [ ] Deploy with a Bedrock catalog that includes `"authType":
"iam_role"` — verify Bedrock shows "Configured" in admin AI panel
- [ ] Verify OpenAI/Anthropic with `apiKey` still show "Configured"
- [ ] Verify a provider with no credentials and no `authType` still
shows "No credentials"

Made with [Cursor](https://cursor.com)
2026-03-26 16:43:09 +01:00
1c297e5ace i18n - translations (#19018)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-26 16:41:17 +01:00
MarieandGitHub c3a3cf9c67 [Apps SDK] Add error message if relationType is missing (#19006)
Before
<img width="2138" height="1434" alt="image"
src="https://github.com/user-attachments/assets/23aa04ae-9994-4ff9-bac8-9f245e6ca05f"
/>


After
<img width="1029" height="610" alt="image"
src="https://github.com/user-attachments/assets/7af48bc0-678d-4451-8cfb-a5e4028f2c2d"
/>
2026-03-26 15:25:12 +00:00
Lucas BordeauandGitHub 8ef99671e2 Fix board drag and drop issues (#19005)
This PR solves multiple problems : 
- An infinite loop that was happening on board with initial and fetch
more queries
- Warning messages that get triggered when we drag and drop multiple
times in a row
- An attempt to fix an existing error in Sentry, that couldn't be
reproduced for now.
2026-03-26 15:06:20 +00:00
Lucas BordeauandGitHub e63a23ea00 Fix create new with filters (#18969)
Fixes https://github.com/twentyhq/twenty/issues/18949

## Problem
- Creating a record from filters with DATE_TIME fields produced empty
objects instead of dates — `isPlainObject` from `twenty-shared` treats
`Date` instances as plain objects, so `mergeCompositeValues` spread them
into `{}`
- `buildRecordInputFromFilter` applied composite merge logic to all
field types indiscriminately, including primitives, dates, and strings
- DATE_TIME "is before" filters produced exact boundary values instead
of subtracting a minute

## Fix
- Composite and non-composite fields are now handled in separate
branches — `buildRecordInputFromFilter` uses `isCompositeFieldType` to
decide whether to merge or assign directly
- `mergeCompositeValues` extracted to its own file with a properly typed
signature (`Record<string, unknown>`) — no more runtime type guessing
- DATE_TIME fields with `IS_BEFORE` operand subtract one minute using
`subMinutes` from `date-fns`
- 13 unit tests for `mergeCompositeValues` covering all composite field
types (currency, address, full name, links, emails, phones) and
successive sub-field accumulation
2026-03-26 15:05:35 +00:00
39b9ae6a76 i18n - translations (#19014)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-26 16:11:40 +01:00
afeef8e04a feat: add command menu item to layout customization (#18764)
figma
https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=93630-365000&t=QesAkY3JOyI9D3UU-0


https://github.com/user-attachments/assets/cf3b354d-0b08-41ae-91ae-386054b5cb2e



https://github.com/user-attachments/assets/73bfd676-f823-44c9-a70d-107c20523664

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-26 16:04:03 +01:00
Paul RastoinandGitHub c7f6036a47 [SDK] twenty-ui/display selective re-export to avoid bloating icons (#19010)
<img width="1946" height="792" alt="image"
src="https://github.com/user-attachments/assets/d0abf62b-85d4-4f5f-b6dc-f2cc1c691f7e"
/>

Avoid re-exporting twenty-ui icons bundle that are massive ~4MB
As discussed with @charlesBochet the problem should rather be treated at
twenty-ui level at some point, that's quite a quick workaround in order
to avoid overloading the twenty-sdk build size

When time comes, where twenty-ui is mature enough to get published we
will work on its bundle size
2026-03-26 14:53:11 +00:00
EtienneandGitHub ec5ab2b84a Fix format result (#18975)
For a field that is not a relation and not a composite, there is no
legitimate reason to recurse into a plain object value.

The only cases where recursion makes sense are:
- Relations, the value is a nested entity record (handled at line 149)
- Composite fields - the value is being reassembled from flat columns
(handled at line 176+)

For everything else, a plain object value is just data


#### Perf testing

Compare findManyCompanies with a jsonField having same 20-level nested
value (cf quote)
- with fix
Average ≈ 75.77 ms - min 52.43 ms - max 114.99 ms.

- without
Average: ≈ 376.62 ms - Min: 194.19 ms - Max: 1183.65 ms (39 requests)

```
{
  "document": {
    "id": "doc-nested-20",
    "title": "20-level nested sample",
    "tags": ["sample", "nested", "json", "fixture", "demo"],
    "counts": [100, 200, 300, 400, 500],
    "extras": {
      "a": true,
      "b": false,
      "c": null,
      "d": 3.14159,
      "e": "unicode-测试-αβγ"
    }
  },
  "users": [
    { "id": 1, "name": "Alice", "roles": ["admin", "editor"] },
    { "id": 2, "name": "Bob", "roles": ["viewer"] },
    { "id": 3, "name": "Carol", "roles": ["editor", "viewer"] }
  ],
  "matrix": [[1, 2, 3], [4, 5, 6], [7, 8, 9]],
  "settings": {
    "theme": "dark",
    "notifications": { "email": true, "push": false, "sms": false },
    "locale": "en-US"
  },
  "deep": {
    "level01": {
      "level02": {
        "level03": {
          "level04": {
            "level05": {
              "level06": {
                "level07": {
                  "level08": {
                    "level09": {
                      "level10": {
                        "level11": {
                          "level12": {
                            "level13": {
                              "level14": {
                                "level15": {
                                  "level16": {
                                    "level17": {
                                      "level18": {
                                        "level19": {
                                          "level20": {
                                            "leaf": true,
                                            "summary": "deepest object in this branch",
                                            "indices": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
                                            "meta": {
                                              "maxDepth": 20,
                                              "note": "Count deep.level01..level20 as 20 nested objects"
                                            }
                                          }
                                        }
                                      }
                                    }
                                  }
                                }
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "footer": {
    "checksum": "example-only-not-real",
    "generated": "2025-03-25"
  }
}
```
2026-03-26 14:40:36 +00:00
nitinandGitHub b171a23216 [AI] replace custom styles with MenuItem and SidePanelGroup in AI agent persmissions tab (#19003)
closes
https://discord.com/channels/1130383047699738754/1486666682553598032
2026-03-26 14:33:07 +00:00
f771b13a20 i18n - translations (#19011)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-26 15:35:49 +01:00
nitinandGitHub 92635b960b [AI] Navigation panel scroll refactor + Non chat placeholder (#18999)
<img width="374" height="1319" alt="CleanShot 2026-03-26 at 16 19 34"
src="https://github.com/user-attachments/assets/bebc7f67-3e9d-47c2-8e13-b57dab83d923"
/>

<img width="438" height="1320" alt="CleanShot 2026-03-26 at 16 19 20"
src="https://github.com/user-attachments/assets/e48ae2d2-066e-402f-a04b-aba7bae776bb"
/>


https://github.com/user-attachments/assets/64a8ad9b-0030-414c-a67d-3817ac3dd6b0
2026-03-26 14:20:48 +00:00
Thomas TrompetteandGitHub 2e015ee68d Add missing row lvl permission check on Kanban view (#19002)
The Kanban view builds a query in two layers:
- Inner query — selects actual records from the table (has all the
permission context)
- Outer query — wraps the inner query's raw SQL string to do
grouping/pagination
The problem: the inner query's SQL is copied out as a plain string
before RLS predicates are added to it. RLS predicates are normally added
lazily when you execute the query, but here the execution happens on the
outer query — which doesn't know about the entity or its RLS rules.

So RLS predicates are never applied anywhere.

The fix: explicitly apply RLS predicates to the inner query before its
SQL is extracted.

Additonnaly, fixed a temporal issue in Datetime pickers.
2026-03-26 14:20:05 +00:00
neo773andGitHub 82611de9b6 connected accounts follow up (#18998)
- Fixed group emails actions
- Fixed delta updates for folder manager
- Updated crons
2026-03-26 13:14:49 +00:00
36c7c99e34 fix: hide objects with no addable views in sidebar view picker (#18993)
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-26 13:13:49 +00:00
Paul RastoinandGitHub 160a80cbcb Bump prelease version sdk, sdk-client, create-twenty-app (#19000) 2026-03-26 13:20:08 +01:00
Paul RastoinandGitHub 052aecccc7 Refactor dependency graph for SDK, client-sdk and create-app (#18963)
## Summary

### Externalize `twenty-client-sdk` from `twenty-sdk`

Previously, `twenty-client-sdk` was listed as a `devDependency` of
`twenty-sdk`, which caused Vite to bundle it inline into the dist
output. This meant end-user apps had two copies of `twenty-client-sdk`:
one hidden inside `twenty-sdk`'s bundle, and one installed explicitly in
their `node_modules`. These copies could drift apart since they weren't
guaranteed to be the same version.

**Change:** Moved `twenty-client-sdk` from `devDependencies` to
`dependencies` in `twenty-sdk/package.json`. Vite's `external` function
now recognizes it and keeps it as an external `require`/`import` in the
dist output. End users get a single deduplicated copy resolved by their
package manager.

### Externalize `twenty-sdk` from `create-twenty-app`

Similarly, `create-twenty-app` had `twenty-sdk` as a `devDependency`
(bundled inline). After refactoring `create-twenty-app` to
programmatically import operations from `twenty-sdk` (instead of
shelling out via `execSync`), it became a proper runtime dependency.

**Change:** Moved `twenty-sdk` from `devDependencies` to `dependencies`
in `create-twenty-app/package.json`.

### Switch E2E CI to `yarn npm publish`

The `workspace:*` protocol in `dependencies` is a Yarn-specific feature.
`npm publish` publishes it as-is (which breaks for consumers), while
`yarn npm publish` automatically replaces `workspace:*` with the
resolved version at publish time (e.g., `workspace:*` becomes `=1.2.3`).

**Change:** Replaced `npm publish` with `yarn npm publish` in
`.github/workflows/ci-create-app-e2e.yaml`.

### Replace `execSync` with programmatic SDK calls in
`create-twenty-app`

`create-twenty-app` was shelling out to `yarn twenty remote add` and
`yarn twenty server start` via `execSync`, which assumed the `twenty`
binary was already installed in the scaffolded app. This was fragile and
created an implicit circular dependency.

**Changes:**
- Replaced `execSync('yarn twenty remote add ...')` with a direct call
to `authLoginOAuth()` from `twenty-sdk/cli`
- Replaced `execSync('yarn twenty server start')` with a direct call to
`serverStart()` from `twenty-sdk/cli`
- Deleted the duplicated `setup-local-instance.ts` from
`create-twenty-app`

### Centralize `serverStart` as a dedicated operation

The Docker server start logic was previously inline in the `server
start` CLI command handler (`server.ts`), and `setup-local-instance.ts`
was shelling out to `yarn twenty server start` to invoke it -- meaning
`twenty-sdk` was calling itself via a child process.

**Changes:**
- Extracted the Docker container management logic into a new
`serverStart` operation (`cli/operations/server-start.ts`)
- Merged the detect-or-start flow from `setup-local-instance.ts` into
`serverStart` (detect across multiple ports, start Docker if needed,
poll for health)
- Deleted `setup-local-instance.ts` from `twenty-sdk`
- Added `onProgress` callback (consistent with other operations like
`appBuild`) instead of direct `console.log` calls
- Both the `server start` CLI command and `create-twenty-app` now call
`serverStart()` programmatically

related to https://github.com/twentyhq/twenty-infra/pull/525
2026-03-26 10:56:52 +00:00
Abdul RahmanandGitHub b651a74b1f fix: hide "Move to folder" when no destination folder is available (#18992) 2026-03-26 10:41:00 +00:00
nitinandGitHub 29979f535d [Ai] fix overflow on new chat button (#18996)
before - 

<img width="368" height="220" alt="CleanShot 2026-03-26 at 15 38 38"
src="https://github.com/user-attachments/assets/c3a87ffd-bd84-408e-b2fb-fd4ce3ce8d61"
/>


after - 

<img width="418" height="274" alt="CleanShot 2026-03-26 at 15 37 59"
src="https://github.com/user-attachments/assets/04d462c4-3cbc-4f9f-9ed5-8b90ae1f112c"
/>
2026-03-26 10:35:33 +00:00
nitinandGitHub b1e449d764 fix dashboard widget edit mode content (#18965) 2026-03-26 10:18:23 +00:00
Félix MalfaitandGitHub c28e637ea7 fix: prevent empty array id.in filter in single record picker (#18995)
## Summary

- Fixes intermittent `INVALID_QUERY_INPUT` errors (152 occurrences / 2
users in Sentry) caused by the single record picker sending `{ id: { in:
[] } }` to the Search query when no records are selected
- The `skip` guard is logically correct but Apollo Client v4 can briefly
fire queries during React 18 render transitions before processing the
skip flag
- Makes `selectedIdsFilter` conditional on `hasSelectedIds`, so
variables contain a safe empty filter `{}` regardless of skip behavior —
matching the existing defensive pattern used by the third query's
`notFilter`

## Test plan

- [ ] Open a relation field picker (e.g., Person on an Opportunity) with
no existing value — search should load without errors
- [ ] Open a relation field picker with an existing value — selected
record should appear and search should work
- [ ] Clear a selected relation and reopen the picker — no console
errors


Made with [Cursor](https://cursor.com)
2026-03-26 10:17:59 +00:00
b732b2efd4 i18n - translations (#18997)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-26 11:12:19 +01:00
3c5796bdb0 fix: handle dashboard filters referencing deleted fields (#18512)
closes https://github.com/twentyhq/twenty/issues/18174

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-26 09:55:28 +00:00
Baptiste DevessierandGitHub cfefe9273b Use record page layouts in the merge records feature (#18961)
There was a design issue. It revealed that we weren't using record page
layouts in the merge records feature.

## Before

<img width="802" height="1283" alt="image"
src="https://github.com/user-attachments/assets/e4238144-e10e-47a6-83e6-7cc03ca89b15"
/>

## After


https://github.com/user-attachments/assets/c0fa9cf5-2b28-4696-bf20-8271dce9e62c
2026-03-26 09:54:33 +00:00
Félix MalfaitandGitHub d126d54bbc feat: make workflow objects searchable (#18906)
## Summary
- Adds `isSearchable: true` to the workflow standard object definition
so new workspaces get searchable workflows automatically
- Adds a `upgrade:1-20:make-workflow-searchable` migration command that
flips the `isSearchable` flag on the `objectMetadata` row for existing
workspaces, with proper cache invalidation and metadata version
increment
- Registers the command in the 1-20 upgrade module and the
`upgrade.command.ts` orchestrator

The `searchVector` stored generated column already exists on the
workflow table, so no data backfill is needed — this is purely a
metadata flag change that makes the search service include workflows in
results.

## Test plan
- [x] `--dry-run` logs what it would do without making changes
- [x] Actual run updates both workspaces and invalidates caches
- [x] Idempotent: re-running skips already-searchable workspaces
- [x] Typecheck passes
- [x] Lint passes on changed files


Made with [Cursor](https://cursor.com)
2026-03-26 07:53:11 +00:00
5041b3e14b i18n - translations (#18990)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-26 08:41:15 +01:00
BugIsGodandGitHub 1e2b31b040 fix: prevent saving API key with empty name (#18970)
Fixes: #18959 
Generally, users need to type name before they create the api key.


https://github.com/user-attachments/assets/bb3ec0ec-d05f-48a8-b762-a35288a9e111

<img width="656" height="559" alt="image"
src="https://github.com/user-attachments/assets/cb8fd461-98f0-4475-b3f0-0de1e62624e2"
/>
2026-03-26 07:25:57 +00:00
340a01567a i18n - translations (#18988)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-26 07:18:15 +01:00
nitinandGitHub 4985270e69 [AI] Refactor settings search to use SearchInput and restructure Skills tab (#18876)
closes -
https://discord.com/channels/1130383047699738754/1480980523315626178
2026-03-26 05:57:41 +00:00
b87762b1c2 i18n - translations (#18987)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-26 07:02:45 +01:00
578d990b9c [AI] Match ai chat composer to figma (#18874)
https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=93653-368288&t=obTG32NRidXid4lN-0

closes
https://discord.com/channels/1130383047699738754/1480990726442582086

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
2026-03-26 05:43:54 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dfe9cb4346 chore(deps): bump @microsoft/microsoft-graph-types from 2.40.0 to 2.43.1 (#18985)
Bumps
[@microsoft/microsoft-graph-types](https://github.com/microsoftgraph/msgraph-typescript-typings)
from 2.40.0 to 2.43.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/microsoftgraph/msgraph-typescript-typings/releases"><code>@​microsoft/microsoft-graph-types</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v2.43.1</h2>
<h2><a
href="https://github.com/microsoftgraph/msgraph-typescript-typings/compare/v2.43.0...v2.43.1">2.43.1</a>
(2025-09-30)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>moves pipeline to governed template. (<a
href="https://github.com/microsoftgraph/msgraph-typescript-typings/commit/9af5685c46beb20119409b6484fc7bd4e78e719b">9af5685</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/microsoftgraph/msgraph-typescript-typings/blob/main/CHANGELOG.md"><code>@​microsoft/microsoft-graph-types</code>'s
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/microsoftgraph/msgraph-typescript-typings/compare/v2.43.0...v2.43.1">2.43.1</a>
(2025-09-30)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>moves pipeline to governed template. (<a
href="https://github.com/microsoftgraph/msgraph-typescript-typings/commit/9af5685c46beb20119409b6484fc7bd4e78e719b">9af5685</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/microsoftgraph/msgraph-typescript-typings/commits/v2.43.1">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~microsoft1es">microsoft1es</a>, a new
releaser for <code>@​microsoft/microsoft-graph-types</code> since your
current version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@microsoft/microsoft-graph-types&package-manager=npm_and_yarn&previous-version=2.40.0&new-version=2.43.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 06:44:38 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
91374262f2 chore(deps): bump @ai-sdk/xai from 3.0.59 to 3.0.74 (#18986)
Bumps [@ai-sdk/xai](https://github.com/vercel/ai) from 3.0.59 to 3.0.74.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vercel/ai/commit/d0a7e0e2a0964ce2c8490995f69779627c722210"><code>d0a7e0e</code></a>
Version Packages (<a
href="https://redirect.github.com/vercel/ai/issues/13772">#13772</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/3caa5443f0fce84b48b752c07789b28de54f326a"><code>3caa544</code></a>
Backport: fix(xai): update model list - add GA models, remove beta
variants (...</li>
<li><a
href="https://github.com/vercel/ai/commit/c77352dd7ddae6353d5d7d265d6ff4683260ba60"><code>c77352d</code></a>
Version Packages (<a
href="https://redirect.github.com/vercel/ai/issues/13765">#13765</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/2e5adffb292de7c44b8bcf7c923cb9c08884549c"><code>2e5adff</code></a>
Backport: chore(provider/google): remove obsolete Google image model (<a
href="https://redirect.github.com/vercel/ai/issues/13759">#13759</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/a141a1e7b85a5d09f3ae11aad7828354c6fd2674"><code>a141a1e</code></a>
Version Packages (<a
href="https://redirect.github.com/vercel/ai/issues/13762">#13762</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/9c548debc505551b046cc0cd7b50c4b22538981d"><code>9c548de</code></a>
Backport: feat(openai): add additional GPT-5.4 models (<a
href="https://redirect.github.com/vercel/ai/issues/13755">#13755</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/bcb04df1e51e307aff84a953f09fa326724819b0"><code>bcb04df</code></a>
Backport: feat: add support for response.failed event type with finish
reason...</li>
<li><a
href="https://github.com/vercel/ai/commit/7f197797b9ee817baf8b59689df6bb4e3340c734"><code>7f19779</code></a>
Version Packages (<a
href="https://redirect.github.com/vercel/ai/issues/13740">#13740</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/763e17854f99dd49a9ff095d4dcfada0a840056e"><code>763e178</code></a>
Backport: chore(provider/gateway): update gateway model settings files
v6 (<a
href="https://redirect.github.com/vercel/ai/issues/1">#1</a>...</li>
<li><a
href="https://github.com/vercel/ai/commit/be357d5ef5e5754d2923a41f8e7b8def3436ac1b"><code>be357d5</code></a>
Version Packages (<a
href="https://redirect.github.com/vercel/ai/issues/13734">#13734</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vercel/ai/compare/@ai-sdk/xai@3.0.59...@ai-sdk/xai@3.0.74">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@ai-sdk/xai&package-manager=npm_and_yarn&previous-version=3.0.59&new-version=3.0.74)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 06:44:27 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7335d352a9 chore(deps): bump @linaria/core from 6.2.0 to 6.3.0 (#18984)
Bumps [@linaria/core](https://github.com/callstack/linaria) from 6.2.0
to 6.3.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/callstack/linaria/releases"><code>@​linaria/core</code>'s
releases</a>.</em></p>
<blockquote>
<h2><code>@​linaria/core</code><a
href="https://github.com/6"><code>@​6</code></a>.3.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>281ca4f5: The new version of wyw-in-js, with the support of a
configurable code remover, can help prevent compilation errors and
improve build time.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/callstack/linaria/commit/759acaf4dd611a97a2bdf612d6ff9d426885c10f"><code>759acaf</code></a>
Version Packages (<a
href="https://redirect.github.com/callstack/linaria/issues/1442">#1442</a>)</li>
<li><a
href="https://github.com/callstack/linaria/commit/281ca4f525a6e9a02e86bad29031a7215dac24a1"><code>281ca4f</code></a>
fix: bump wyw to 6.0.0 (<a
href="https://redirect.github.com/callstack/linaria/issues/1443">#1443</a>)</li>
<li><a
href="https://github.com/callstack/linaria/commit/bd8d45fd9408aad3cd863ff0f521c784707ba9f2"><code>bd8d45f</code></a>
fix: use <code>React.JSX</code> instead of <code>JSX</code> for React 19
support (<a
href="https://redirect.github.com/callstack/linaria/issues/1420">#1420</a>)</li>
<li><a
href="https://github.com/callstack/linaria/commit/3790746c6e91ce3fc069f5018865002d3df6b2f8"><code>3790746</code></a>
chore: remove node 16 and add 22</li>
<li><a
href="https://github.com/callstack/linaria/commit/a8702d9178aba16c796259a6b83b05282442ac0c"><code>a8702d9</code></a>
chore: fix pnpm version on CI</li>
<li><a
href="https://github.com/callstack/linaria/commit/74942f7ccfd7f04d4c6ac29dc4afc08906739452"><code>74942f7</code></a>
chore: update pnpm (<a
href="https://redirect.github.com/callstack/linaria/issues/1441">#1441</a>)</li>
<li><a
href="https://github.com/callstack/linaria/commit/35f42a14a3446165246f7ad0e75f57b0a047ab55"><code>35f42a1</code></a>
chore(deps-dev): bump rollup from 3.28.0 to 3.29.5 (<a
href="https://redirect.github.com/callstack/linaria/issues/1426">#1426</a>)</li>
<li><a
href="https://github.com/callstack/linaria/commit/8be3ac8e76701997a1d3a3d21f017c8316932e0f"><code>8be3ac8</code></a>
chore(deps): bump express from 4.19.2 to 4.20.0 (<a
href="https://redirect.github.com/callstack/linaria/issues/1425">#1425</a>)</li>
<li><a
href="https://github.com/callstack/linaria/commit/52ba8fdaeb3a94644d2b13f23f562965c278479c"><code>52ba8fd</code></a>
chore(deps-dev): bump vite from 3.2.7 to 3.2.11 (<a
href="https://redirect.github.com/callstack/linaria/issues/1424">#1424</a>)</li>
<li><a
href="https://github.com/callstack/linaria/commit/a6714914ed34faa89184595caef6c666417bc219"><code>a671491</code></a>
chore(deps-dev): bump webpack from 5.76.0 to 5.94.0 (<a
href="https://redirect.github.com/callstack/linaria/issues/1422">#1422</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/callstack/linaria/compare/@linaria/core@6.2.0...@linaria/core@6.3.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@linaria/core&package-manager=npm_and_yarn&previous-version=6.2.0&new-version=6.3.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 06:44:08 +01:00
bfdbc93b1c i18n - docs translations (#18982)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 21:27:47 +01:00
3abef48663 i18n - docs translations (#18981)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 19:35:35 +01:00
Lucas BordeauandGitHub 441a9464d1 Fixed board no value column not fetching more (#18977)
Fixes https://github.com/twentyhq/twenty/issues/18148

## Problem
- Scrolling down in the "no value" kanban column never loads additional
records when it's the only column that needs pagination
- `useTriggerRecordBoardFetchMore` — `.filter(isDefined)` removes `null`
from the list of group values to fetch, and `null` is the value used by
"no value" columns, causing early exit
- `useTriggerRecordBoardFetchMore` — the inline `{ in: [...values] }`
filter cannot express a NULL match, so even without the early exit the
query would be wrong

## Fix
- "No value" column now triggers pagination correctly —
`useTriggerRecordBoardFetchMore` (removed `.filter(isDefined)` so `null`
values pass through)
- Query filter correctly matches NULL field values —
`useTriggerRecordBoardFetchMore` (replaced inline `{ in: [...] }` with
`computeRecordGroupOptionsFilter` which generates `{ is: 'NULL' }` for
null values and `{ in: [...] }` for non-null values)
2026-03-25 17:41:37 +00:00
Charles BochetandGitHub 72dd3af155 fix SVG icon sizing broken in Chrome 142 (#18974)
## Summary
- Chrome 142 rejects `var()` references in SVG `width`/`height`
attributes, causing icons to fall back to `100%` size
- Replaced `themeCssVariables.icon.size.*` /
`themeCssVariables.icon.stroke.*` (raw `var()` strings) with resolved
`theme.icon.size.*` / `theme.icon.stroke.*` (numeric values from
`ThemeContext`) when passed as icon component props
- Affects 3 navigation menu item components: `NavigationMenuItemFolder`,
`NavigationMenuItemLinkDisplay`, `LinkIconWithLinkOverlay`

## Test plan
- [ ] Verify navigation drawer folder chevron icons render at correct
size
- [ ] Verify navigation drawer link arrow icons render at correct size
- [ ] Verify link overlay icons render at correct size
- [ ] Test in Chrome 142+ to confirm the fix
- [ ] Test in Firefox/Safari to confirm no regression
2026-03-25 17:37:16 +00:00
Thomas TrompetteandGitHub 6c1db2e7fb Do not run loop when iterator is skipped (#18964)
When an If/Else branch skips an Iterator step (because the branch wasn't
taken), the executor incorrectly entered the iterator's loop body. This
happened because getNextStepIdsToExecute checked !hasProcessedAllItems
on an undefined result, which evaluated to true, causing it to return
initialLoopStepIds instead of the post-loop nextStepIds.

Fix: Add a !executedStepOutput.shouldSkipStepExecution guard to the
iterator condition in getNextStepIdsToExecute, consistent with the
existing shouldFailSafely guard.
2026-03-25 17:35:58 +00:00
Thomas TrompetteandGitHub 511d1bd7ab Fix SSE event stream errors when impersonating users with limited permissions (#18966)
Clear the SSE client before swapping auth tokens during impersonation,
preventing a userWorkspaceId mismatch between the existing event stream
(created under the admin's identity) and the new impersonation token.
Treat NOT_AUTHORIZED event stream errors as recoverable (destroy +
recreate), matching the existing behavior for
EVENT_STREAM_DOES_NOT_EXIST and EVENT_STREAM_ALREADY_EXISTS.
2026-03-25 17:35:54 +00:00
Thomas TrompetteandGitHub d47ddad4c5 Add multiselect option to form step (#18979)
<img width="415" height="462" alt="Capture d’écran 2026-03-25 à 18 10
06"
src="https://github.com/user-attachments/assets/e5157636-25ea-41b9-ae13-8f64a24cbd5e"
/>
<img width="415" height="462" alt="Capture d’écran 2026-03-25 à 18 10
12"
src="https://github.com/user-attachments/assets/5811848f-1ce6-4c09-9563-61f906968888"
/>
2026-03-25 17:35:50 +00:00
33a474c8e6 Add record table widget feature flag (#18960)
Introduce a new feature flag for the record table widget, enabling
conditional rendering and state management based on its status. Update
related components and tests accordingly.

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-25 16:47:20 +00:00
cc247c2e8e Fix record page layout upgrade commands (#18962)
- Fix issue with name that must be first in the view field list
- Improve dry run and logs of backfill page layouts command

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: prastoin <paul@twenty.com>
2026-03-25 16:43:54 +00:00
4d6c8db205 i18n - docs translations (#18976)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 17:42:31 +01:00
Charles BochetandGitHub a8dad6c882 Fix: default to allow-read when object permissions are not yet loaded (#18971)
## Summary

Fixes the merge-queue E2E failures that started after #18912 landed.

`filterReadableActiveObjectMetadataItems` (introduced in #18912) treats
a **missing** entry in the permissions map as "deny read". The previous
helper (`getObjectPermissionsFromMapByObjectMetadataId`) treated it as
**"allow read"** via a `?? { canReadObjectRecords: true, … }` fallback.

Because the permissions map is empty on initial render (before the API
round-trip completes), **every object was temporarily filtered out**.
This made `useDefaultHomePagePath` return the settings-profile path,
triggering a redirect race that broke Settings navigation in three E2E
tests:

- `signup_invite_email.spec.ts` — timed out waiting for
`getByRole('link', { name: 'Members' })`
- `create-kanban-view.spec.ts` — timed out waiting for
`getByRole('link', { name: 'Data model' })`
- `workflow-creation.spec.ts` — timed out waiting for sidebar Workflows
button

**Evidence from CI:**
- Last passing merge-queue run: base `13ea3ec75c` (before #18912)
- First failing merge-queue run: base `5f0d6553f6` (#18912)
- All individual PR CI runs continued to pass (they don't rebase on
latest main)
- Screenshot artifact shows the app stuck on the main page — the
Settings drawer never opened

## Fix

When an object has no entry in the permissions map, default to allowing
read (matching the old behavior). An empty map means permissions haven't
loaded yet, not that the user lacks access.

```diff
  objectMetadataItems.filter((objectMetadataItem) => {
+   if (!objectMetadataItem.isActive) {
+     return false;
+   }
+
    const objectPermissions =
      objectPermissionsByObjectMetadataId[objectMetadataItem.id];
-
-   return (
-     isDefined(objectPermissions) &&
-     objectPermissions.canReadObjectRecords &&
-     objectMetadataItem.isActive
-   );
+
+   if (!isDefined(objectPermissions)) {
+     return true;
+   }
+
+   return objectPermissions.canReadObjectRecords;
  });
```
2026-03-25 17:41:15 +01:00
Félix MalfaitandGitHub 895bb58fc6 feat: add S3 presigned URL redirect for file downloads (#18864)
## Summary

- When `STORAGE_S3_PRESIGNED_URL_BASE` is configured, the file
controller returns a **302 redirect** to a presigned S3 URL instead of
proxying every byte through the server. This eliminates server bandwidth
and CPU overhead for S3-backed deployments.
- For local storage or S3 without a public endpoint, behavior is
unchanged (stream + pipe with security headers).
- Added `getPresignedUrl` to the `StorageDriver` interface (required
method returning `string | null`), with implementations in S3Driver
(uses a separate presign client with the public endpoint), LocalDriver
(returns `null`), and ValidatedStorageDriver (path traversal protection
+ delegation).
- Added a unified `getFileResponseById` method in `FileService` that
performs a single DB lookup and returns either a redirect URL or a
stream, avoiding double lookups.
- Extracted `getContentDisposition` from the header util so both the
proxy path and presigned URL path share the same inline/attachment
allowlist.
- Added MinIO service to `docker-compose.dev.yml` (optional `s3`
profile) for local S3 testing.
- Documented S3 presigned URL setup, CORS, and `nosniff` requirements in
the self-hosting docs.

## Test plan

- [x] All 63 unit tests pass across 5 test suites (util, S3 driver,
validated driver, file storage service, controller)
- [x] `npx nx typecheck twenty-server` passes
- [ ] Manual E2E test with MinIO: `docker compose --profile s3 up -d`,
configure S3 env vars, verify `curl -I` returns 302 with `Location`
header pointing to MinIO
- [ ] Verify local storage (no `STORAGE_S3_PRESIGNED_URL_BASE`) still
streams files with 200 + security headers
- [ ] Verify public assets endpoint still proxies (no redirect)


Made with [Cursor](https://cursor.com)
2026-03-25 16:15:15 +01:00
4fbe0a92ae i18n - translations (#18967)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 16:09:33 +01:00
Félix MalfaitandGitHub 5f0d6553f6 Add object type filter dropdown to side panel record searches (#18912)
## Summary

- Adds an object type filter dropdown to the side panel, allowing users
to scope record search results to a specific object type (e.g. People,
Companies, Opportunities)
- The filter appears as a funnel icon next to the search input in both
the global search (SearchRecords page) and the "Pick a record" sidebar
flow
- Replaces the AI sparkles button on the search records page with the
filter icon
- Uses colored icons matching the navigation menu style
(NavigationMenuItemStyleIcon + getStandardObjectIconColor)

## Test plan

- [ ] Open the side panel, type something to enter SearchRecords mode,
verify the filter icon appears
- [ ] Click the filter icon, verify the dropdown opens with "Object"
header, search input, "All Objects" and individual object types with
colored icons
- [ ] Select an object type, verify only records of that type appear in
search results
- [ ] Select "All Objects", verify all record types appear again
- [ ] Verify the filter icon turns blue when a filter is active
- [ ] In layout customization mode, add a new sidebar item > Record,
verify the filter dropdown also works there
- [ ] Close and reopen the side panel, verify the filter resets to "All
Objects"
- [ ] Verify the AI sparkles button no longer appears on the command
menu root page
- [ ] Verify the AI edit icon still appears on Ask AI pages


Made with [Cursor](https://cursor.com)
2026-03-25 16:02:24 +01:00
Charles BochetandGitHub 13ea3ec75c Split command menu items backfill into separate migration runs per application (#18957)
## Summary

- The 1-20 backfill command menu items upgrade command was mixing
standard and custom application flat entities into a single
`validateBuildAndRunWorkspaceMigration` call. Since each migration run
is tied to a single application, this split the backfill into two
separate runs: standard items under `twentyStandardFlatApplication` and
workflow trigger items under `workspaceCustomFlatApplication`.
2026-03-25 13:37:58 +00:00
e25ea6069d i18n - translations (#18958)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 14:31:01 +01:00
f7ef41959b i18n - translations (#18956)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 14:23:30 +01:00
ba0108944f i18n - translations (#18955)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 14:11:05 +01:00
Félix MalfaitandGitHub 03c94727be fix: wrap standard object/field metadata labels in msg for i18n extraction (#18951)
## Summary

- Standard object and field metadata labels were plain strings instead
of being wrapped in Lingui `msg` template literals, which prevented
extraction into translation catalogs. This caused "Uncompiled message
detected!" warnings at runtime.
- The bug was introduced when the decorator-based approach
(`@WorkspaceEntity({ labelSingular: msg`...` })`) was replaced by flat
metadata builder utils with plain strings.
- Adds an `i18nLabel` helper to safely extract the message string from
`MessageDescriptor` objects.
- Re-runs `lingui extract` and `lingui compile` to update all locale PO
files and compiled catalogs.
- Adds an integration test that queries the metadata API with `x-locale`
headers to verify locale-aware label resolution.
- Includes a ClickHouse usage event writer integration test (small
unrelated fix noticed along the way).

## Test plan

- [ ] CI lint passes (prettier + oxlint)
- [ ] CI typecheck passes
- [ ] Server unit tests pass
- [ ] Integration tests pass (including new `object-metadata-i18n` test)
- [ ] No "Uncompiled message detected!" warnings for standard
object/field labels at runtime


Made with [Cursor](https://cursor.com)
2026-03-25 14:03:51 +01:00
MarieandGitHub bf22373315 Fix: use user role for OAuth tokens bearing user context (#18954)
see [discord
discussion](https://discord.com/channels/1130383047699738754/1486299347091198054/1486299351520383116)

## Summary

When an OAuth application token carries both `applicationId` and
`userId`/`userWorkspaceId`, the auth context now uses the **user's
role** for permissions instead of the application's `defaultRoleId`.

This fixes the case where external clients authenticating via OAuth
(e.g. a client's external AI chat) were getting the app's permissions
instead of the authenticated user's.

### What changed

- **`jwt.auth.strategy.ts`** (`validateApplicationToken`): when an
application token includes user info, also resolve `workspaceMemberId`
and `workspaceMember` from the workspace cache (same pattern as
`validateAccessToken`)
- **`workspace-auth-context.middleware.ts`** (`buildAuthContext`): when
both `application` and `user` (with
`workspaceMemberId`/`workspaceMember`) are present on the request, build
a `UserWorkspaceAuthContext` instead of
`ApplicationWorkspaceAuthContext`. Falls back to application context if
workspace member cannot be resolved.

### Places impacted by this change (no code changes, behavior changes)

These places check `isApplicationAuthContext` or resolve roles from auth
context. Since hybrid tokens (OAuth with user) now produce a
`UserWorkspaceAuthContext`, they naturally flow into the
`isUserAuthContext` branches:

| File | Impact |
|------|--------|
| `permissions.service.ts` —
`resolveRolePermissionConfigFromAuthContext` | OAuth+user now uses
user's role via `isUserAuthContext` branch instead of
`application.defaultRoleId` |
| `common-api-context-builder.service.ts` — `getObjectsPermissions` |
Same — OAuth+user falls into `isUserAuthContext` branch |
| `common-base-query-runner.service.ts` — `getRoleIdOrThrow` | Same —
OAuth+user falls into `isUserAuthContext` branch |
| `actor-from-auth-context.service.ts` — `buildActorMetadata` | Records
created via OAuth+user will show the **user's name** as actor instead of
the application's name |
| `message-find-one.post-query.hook.ts` | OAuth+user now passes the
`isUserAuthContext` check (previously would fail unless it was the
Twenty standard application) |
| `front-component.resolver.ts` | Front component tokens include
`userId` — they will now correctly use the user's role, fixing a
pre-existing permission escalation where a user could access data
through a front component's app role that exceeded their own |
| `logic-function-executor.service.ts` | **Not impacted** — only
generates tokens with `applicationId` (no `userId`) |
2026-03-25 12:38:01 +00:00
Thomas TrompetteandGitHub e1374e34a7 Fix object permission override (#18948)
Issue: https://www.loom.com/share/dd48cd509f614e51829f6a5b58d41b6b

Bug: Unsetting a revoked object permission keeps it revoked
When a role has a global permission enabled (e.g.
canReadAllObjectRecords: true) but an object-level override revokes it
(canReadObjectRecords: false), clicking to remove that override had no
effect — the permission stayed revoked after save.

Root cause:
Backend (object-permission.service.ts): The nullish coalescing operator
(??) was used to fall back to the current DB value when the input didn't
provide a value. Since ?? treats both null and undefined as nullish,
sending canReadObjectRecords: null (meaning "remove override") was
coalesced to the current value (false), silently discarding the reset.

Fix:
- Backend: Replaced ?? with explicit !== undefined checks, so null is
preserved as a meaningful value (meaning "no override / inherit from
global") while undefined (field not provided) still falls back to the
current value. This also fixes the "Reset all permissions" flow which
sends null for all permission fields.

Additional frontend fix: Changed !value to value === false so that only
an explicit false cascades revocation to write permissions. Setting null
(reset to inherit) now only affects the read permission itself.
2026-03-25 10:49:05 +00:00
Paul RastoinandGitHub 523289efad Do not rollback on cache invalidation failure in workspace migration runner (#18947) 2026-03-25 10:30:16 +00:00
neo773andGitHub e6bb39deea fix: reset throttle state on channel relaunch (#18843)
Relaunch jobs reset syncStage/syncStatus but not throttleFailureCount
2026-03-25 09:52:17 +00:00
Charles BochetandGitHub 790a58945b Migrate twenty-companion from npm to yarn workspaces (#18946)
## Summary
- Migrates twenty-companion from standalone npm to the repo yarn
workspaces
- Removes package-lock.json (resolves Oneleet security finding about npm
lifecycle scripts)
- Converts npm overrides to yarn resolutions
- Updates scripts from npm run to yarn

## Test plan
- [x] Verified yarn install succeeds at root
- [x] Verified yarn start in twenty-companion launches the Electron app
- [ ] Verify Oneleet finding is resolved after merge
2026-03-25 10:45:43 +01:00
3295f5ee07 fix logo upload during workspace onboarding (#18905)
Logo upload during onboarding failed because it required the Twenty
Standard Application, which doesn't exist yet at that point

We only need custom app's universalIdentifier
(workspace.workspaceCustomApplicationId, available from sign up) so we
can safely remove ApplicationService dependency


https://github.com/user-attachments/assets/a18599ee-0b91-4629-ad77-2f708351449a


/closes #18829

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-25 08:02:08 +00:00
Abdul RahmanandGitHub 2bfd2f6b85 fix navigation menu item overflow issue (#18937)
Before
<img width="231" height="99" alt="Screenshot 2026-03-25 at 5 34 59 AM"
src="https://github.com/user-attachments/assets/47980a03-b2ec-47db-be16-db0a48b122dd"
/>


After
<img width="220" height="96" alt="Screenshot 2026-03-25 at 5 32 44 AM"
src="https://github.com/user-attachments/assets/5ad405c2-a697-407d-ac1e-450c4a0b9f62"
/>
2026-03-25 07:35:49 +00:00
5de269a64e i18n - docs translations (#18942)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 05:52:30 +01:00
cc7131b0b5 i18n - docs translations (#18941)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 04:18:13 +01:00
fb34d2ce80 i18n - docs translations (#18938)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 01:55:36 +01:00
42d9e25bf2 i18n - translations (#18936)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-25 00:58:32 +01:00
b642d1b114 Animate the sidebar when opening/closing an element in the "opened" section (#18867)
https://github.com/user-attachments/assets/38f64750-7bca-4b10-8489-11d386a10298

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-24 23:44:01 +00:00
766f956a15 Set system object icon colors to gray in New menu item flows (#18862)
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-25 00:17:23 +01:00
981e83ecb1 i18n - translations (#18935)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-24 23:34:15 +01:00
martmullandGitHub 98c21f958a Add twenty-sdk-client to deployed packages unique version check (#18933)
as title
2026-03-24 23:28:51 +01:00
4a099ed097 Use side panel sub-pages for add-to-folder flow (#18872)
https://github.com/user-attachments/assets/a31b8523-2d0a-4a02-bcd9-5548c906b7cc

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-24 23:28:40 +01:00
01af7bc7fb i18n - docs translations (#18934)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-24 23:20:45 +01:00
Félix MalfaitandGitHub d5b41b2801 Unify auth context → role permission config resolution into a single pure utility (#18927)
## Summary

- Consolidates duplicated auth-context-to-role-ID resolution logic
(previously in
`PermissionsService.resolveRolePermissionConfigFromAuthContext` and
`CommonBaseQueryRunnerService.getRoleIdOrThrow`) into a single pure
utility function `resolveRolePermissionConfig` in the ORM layer
- The utility is synchronous and operates on cached data
(`userWorkspaceRoleMap`, `apiKeyRoleMap`) already loaded into the
workspace context — no async calls, no service dependencies
- Adds `apiKeyRoleMap` to `ORMWorkspaceContext` (it was already in the
workspace cache, just not loaded into the ORM context)
- Removes `PermissionsService` dependency from
`NavigationMenuItemRecordIdentifierService`
- Removes `UserRoleService` and `ApiKeyRoleService` injections from
`CommonBaseQueryRunnerService`

## Test plan

- [ ] Existing typecheck passes (`npx nx typecheck twenty-server`)
- [ ] Verify record identifier resolution still works for navigation
menu items (user, system, API key, and application auth contexts)
- [ ] Verify GraphQL CRUD queries still enforce correct role-based
permissions
- [ ] Verify API key authenticated requests resolve permissions
correctly


Made with [Cursor](https://cursor.com)
2026-03-24 21:37:58 +01:00
37640521d5 Batch create, update, and delete navigation menu items (#18882)
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-24 20:24:47 +00:00
0af7760441 i18n - docs translations (#18932)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-24 21:26:41 +01:00
a6519f2c97 i18n - docs translations (#18931)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-24 19:41:27 +01:00
5c99d7205f i18n - translations (#18930)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-24 19:30:01 +01:00
4ea2e32366 Refactor twenty client sdk provisioning for logic function and front-component (#18544)
## 1. The `twenty-client-sdk` Package (Source of Truth)

The monorepo package at `packages/twenty-client-sdk` ships with:
- A **pre-built metadata client** (static, generated from a fixed
schema)
- A **stub core client** that throws at runtime (`CoreApiClient was not
generated...`)
- Both ESM (`.mjs`) and CJS (`.cjs`) bundles in `dist/`
- A `package.json` with proper `exports` map for
`twenty-client-sdk/core`, `twenty-client-sdk/metadata`, and
`twenty-client-sdk/generate`

## 2. Generation & Upload (Server-Side, at Migration Time)

**When**: `WorkspaceMigrationRunnerService.run()` executes after a
metadata schema change.

**What happens in `SdkClientGenerationService.generateAndStore()`**:
1. Copies the stub `twenty-client-sdk` package from the server's assets
(resolved via `SDK_CLIENT_PACKAGE_DIRNAME` — from
`dist/assets/twenty-client-sdk/` in production, or from `node_modules`
in dev)
2. Filters out `node_modules/` and `src/` during copy — only
`package.json` + `dist/` are kept (like an npm publish)
3. Calls `replaceCoreClient()` which uses `@genql/cli` to introspect the
**application-scoped** GraphQL schema and generates a real
`CoreApiClient`, then compiles it to ESM+CJS and overwrites
`dist/core.mjs` and `dist/core.cjs`
4. Archives the **entire package** (with `package.json` + `dist/`) into
`twenty-client-sdk.zip`
5. Uploads the single archive to S3 under
`FileFolder.GeneratedSdkClient`
6. Sets `isSdkLayerStale = true` on the `ApplicationEntity` in the
database

## 3. Invalidation Signal

The `isSdkLayerStale` boolean column on `ApplicationEntity` is the
invalidation mechanism:
- **Set to `true`** by `generateAndStore()` after uploading a new client
archive
- **Checked** by both logic function drivers before execution — if
`true`, they rebuild their local layer
- **Set back to `false`** by `markSdkLayerFresh()` after the driver has
successfully consumed the new archive

Default is `false` so existing applications without a generated client
aren't affected.

## 4a. Logic Functions — Local Driver

**`ensureSdkLayer()`** is called before every execution:
1. Checks if the local SDK layer directory exists AND `isSdkLayerStale`
is `false` → early return
2. Otherwise, cleans the local layer directory
3. Calls `downloadAndExtractToPackage()` which streams the zip from S3
directly to disk and extracts the full package into
`<tmpdir>/sdk/<workspaceId>-<appId>/node_modules/twenty-client-sdk/`
4. Calls `markSdkLayerFresh()` to set `isSdkLayerStale = false`

**At execution time**, `assembleNodeModules()` symlinks everything from
the deps layer's `node_modules/` **except** `twenty-client-sdk`, which
is symlinked from the SDK layer instead. This ensures the logic
function's `import ... from 'twenty-client-sdk/core'` resolves to the
generated client.

## 4b. Logic Functions — Lambda Driver

**`ensureSdkLayer()`** is called during `build()`:
1. Checks if `isSdkLayerStale` is `false` and an existing Lambda layer
ARN exists → early return
2. Otherwise, deletes all existing layer versions for this SDK layer
name
3. Calls `downloadArchiveBuffer()` to get the raw zip from S3 (no disk
extraction)
4. Calls `reprefixZipEntries()` which streams the zip entries into a
**new zip** with the path prefix
`nodejs/node_modules/twenty-client-sdk/` — this is the Lambda layer
convention path. All done in memory, no disk round-trip
5. Publishes the re-prefixed zip as a new Lambda layer via
`publishLayer()`
6. Calls `markSdkLayerFresh()`

**At function creation**, the Lambda is created with **two layers**:
`[depsLayerArn, sdkLayerArn]`. The SDK layer is listed last so it
overwrites the stub `twenty-client-sdk` from the deps layer (later
layers take precedence in Lambda's `/opt` merge).

## 5. Front Components

Front components are built by `app:build` with `twenty-client-sdk/core`
and `twenty-client-sdk/metadata` as **esbuild externals**. The stored
`.mjs` in S3 has unresolved bare import specifiers like `import {
CoreApiClient } from 'twenty-client-sdk/core'`.

SDK import resolution is split between the **frontend host** (fetching &
caching SDK modules) and the **Web Worker** (rewriting imports):

**Server endpoints**:
- `GET /rest/front-components/:id` —
`FrontComponentService.getBuiltComponentStream()` returns the **raw
`.mjs`** directly from file storage. No bundling, no SDK injection.
- `GET /rest/sdk-client/:applicationId/:moduleName` —
`SdkClientController` reads a single file (e.g. `dist/core.mjs`) from
the generated SDK archive via
`SdkClientGenerationService.readFileFromArchive()` and serves it as
JavaScript.

**Frontend host** (`FrontComponentRenderer` in `twenty-front`):
1. Queries `FindOneFrontComponent` which returns `applicationId`,
`builtComponentChecksum`, `usesSdkClient`, and `applicationTokenPair`
2. If `usesSdkClient` is `true`, renders
`FrontComponentRendererWithSdkClient` which calls the
`useApplicationSdkClient` hook
3. `useApplicationSdkClient({ applicationId, accessToken })` checks the
Jotai atom family cache for existing blob URLs. On cache miss, fetches
both SDK modules from `GET /rest/sdk-client/:applicationId/core` and
`/metadata`, creates **blob URLs** for each, and stores them in the atom
family
4. Once the blob URLs are cached, passes them as `sdkClientUrls`
(already blob URLs, not server URLs) to `SharedFrontComponentRenderer` →
`FrontComponentWorkerEffect` → worker's `render()` call via
`HostToWorkerRenderContext`

**Worker** (`remote-worker.ts` in `twenty-sdk`):
1. Fetches the raw component `.mjs` source as text
2. If `sdkClientUrls` are provided and the source contains SDK import
specifiers (`twenty-client-sdk/core`, `twenty-client-sdk/metadata`),
**rewrites** the bare specifiers to the blob URLs received from the host
(e.g. `'twenty-client-sdk/core'` → `'blob:...'`)
3. Creates a blob URL for the rewritten source and `import()`s it
4. Revokes only the component blob URL after the module is loaded — the
SDK blob URLs are owned and managed by the host's Jotai cache

This approach eliminates server-side esbuild bundling on every request,
caches SDK modules per application in the frontend, and keeps the
worker's job to a simple string rewrite.

## Summary Diagram

```
app:build (SDK)
  └─ twenty-client-sdk stub (metadata=real, core=stub)
       │
       ▼
WorkspaceMigrationRunnerService.run()
  └─ SdkClientGenerationService.generateAndStore()
       ├─ Copy stub package (package.json + dist/)
       ├─ replaceCoreClient() → regenerate core.mjs/core.cjs
       ├─ Zip entire package → upload to S3
       └─ Set isSdkLayerStale = true
              │
     ┌────────┴────────────────────┐
     ▼                             ▼
Logic Functions               Front Components
     │                             │
     ├─ Local Driver               ├─ GET /rest/sdk-client/:appId/core
     │   └─ downloadAndExtract     │    → core.mjs from archive
     │      → symlink into         │
     │        node_modules         ├─ Host (useApplicationSdkClient)
     │                             │    ├─ Fetch SDK modules
     └─ Lambda Driver              │    ├─ Create blob URLs
         └─ downloadArchiveBuffer  │    └─ Cache in Jotai atom family
            → reprefixZipEntries   │
            → publish as Lambda    ├─ GET /rest/front-components/:id
              layer                │    → raw .mjs (no bundling)
                                   │
                                   └─ Worker (browser)
                                        ├─ Fetch component .mjs
                                        ├─ Rewrite imports → blob URLs
                                        └─ import() rewritten source
```

## Next PR
- Estimate perf improvement by implementing a redis caching for front
component client storage ( we don't even cache front comp initially )
- Implem frontent blob invalidation sse event from server

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-24 18:10:25 +00:00
16451ee2ee i18n - translations (#18926)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-24 18:34:48 +01:00
Lucas BordeauandGitHub 96f3ff0e90 Add record table widget to dashboards (#18747)
## Demo



https://github.com/user-attachments/assets/584de452-544a-41f8-ae9f-4be9e9d0cd9f


## Problem
- Dashboards only supported chart widgets — tabular record data had no
inline widget type
- `RecordTable` was tightly coupled to the record index: HTML IDs, CSS
variables, and hover portals were global strings with no per-instance
scoping, so multiple tables on the same page would collide
- `updateRecordTableCSSVariable`, `RECORD_TABLE_HTML_ID`, and cell
portal IDs were hardcoded — placing two tables caused hover portals and
CSS column widths to bleed across instances
- Grid drag-select captured record UUIDs as cell IDs, producing `NaN`
layout coordinates and a full-page freeze on second widget creation

## Fix
- `RECORD_TABLE` is now a valid widget type across the full stack —
server DTOs, DB enum migration, universal config mapping, GraphQL
codegen, shared types (`RecordTableConfigurationDto`, `WidgetType`,
`addRecordTableWidgetType` migration)
- A record table widget can be placed on a dashboard and boots from a
View ID with no record index dependency —
`StandaloneRecordTableProvider` + `StandaloneRecordTableViewLoadEffect`
(wraps existing `RecordTableWithWrappers` unchanged)
- Selecting a data source auto-creates a dedicated View with up to 6
initial fields; switching source or deleting the widget cleans up the
View — `useCreateViewForRecordTableWidget` +
`useDeleteViewForRecordTableWidget`
- The settings panel exposes source, field visibility/reorder, filter
conditions, sort rules, and editable widget title —
`SidePanelPageLayoutRecordTableSettings` + sub-pages, matching chart
widget pattern
- Filters, sorts, and aggregate operations update the table in real time
but only persist to the View on explicit dashboard save —
`useSaveRecordTableWidgetsViewDataOnDashboardSave` (diff + flush on
save)
- Headers are always non-interactive (no dropdown, no cursor pointer);
columns are resizable only in edit mode; cells are non-editable in both
modes — `isRecordTableColumnHeadersReadOnlyComponentState`,
`isRecordTableColumnResizableComponentState`,
`isRecordTableCellsNonEditableComponentState` (Jotai component states)
- Hover portals and CSS column widths no longer bleed between multiple
table widgets — `getRecordTableHtmlId(tableId)`,
`getRecordTableCellId(tableId, …)`,
`updateRecordTableCSSVariable(tableId, …)` scope all DOM IDs and CSS
variables per instance
- Clicking inside a widget's content area no longer opens the settings
panel — `WidgetCardContent` stops click propagation when editable,
limiting settings-open to the card header and chrome
- Second widget creation no longer freezes the page —
`PageLayoutGridLayout` drag-select filters by `cell-` prefix to exclude
record UUIDs from grid cell detection

## Follow-up fixes

**Widget save flow**
- Saving a dashboard silently dropped record table widget changes
(column visibility, order, filters, sorts, aggregates) because widget
data save was bundled inside the layout save and only ran when layout
structure changed
- Widget data now persists independently via
`useSavePageLayoutWidgetsData`, called in all save paths (dashboard
save, record page save, layout customization save); saves are also
skipped when nothing has changed

**Drag-and-drop / checkbox columns in widget**
- Record table widgets showed the drag handle column and checkbox
selection column even though row reordering and multi-select are
meaningless in a read-only widget
- Two new component states
(`isRecordTableDragColumnHiddenComponentState`,
`isRecordTableCheckboxColumnHiddenComponentState`) hide each column
independently; widget tables now display only data columns

**Sticky column layout**
- Sticky positioning of the first three columns used `:nth-of-type` CSS
selectors — when drag or checkbox columns were hidden, the selector
targeted the wrong column and the first data column didn't stick
- Sticky CSS now targets semantic class names
(`RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH_CLASS_NAME`, etc.) so sticky
behavior is correct regardless of which columns are hidden

**Save/Cancel buttons during edit mode**
- Save and Cancel command-menu buttons were unpinned during dashboard
edit mode because the pin logic excluded all items while
`isPageInEditMode` was true
- Items whose availability expression contains `isPageInEditMode` are
now exempted from the unpin rule; Save/Cancel stay pinned during editing

**Title input auto-focus**
- Selecting "Record Table" as widget type auto-focused the title input,
interrupting the configuration flow
- `focusTitleInput` is now `false` when navigating to record table
settings

**Morph relation field error**
- A field with missing `morphRelations` metadata crashed the page with a
"refresh" error from `mapObjectMetadataToGraphQLQuery`
- Now returns an empty array and silently omits the field from the query
instead of crashing

**`updateRecordMutation` prop removal**
- `RecordTableWithWrappers` required callers to pass an
`updateRecordMutation` callback, duplicating `useUpdateOneRecord` at
every usage site
- The mutation is now owned inside `RecordTableContextProvider` via
`RecordTableUpdateContext`; the prop is gone

**Standalone → Widget module rename**
- `record-table-standalone` module renamed to `record-table-widget` —
`StandaloneRecordTable` → `RecordTableWidget`,
`StandaloneRecordTableViewLoadEffect` →
`RecordTableWidgetViewLoadEffect`, etc.

**RecordTableRow cell extraction**
- Row rendering logic (`RecordTableCellDragAndDrop`,
`RecordTableCellCheckbox`, `RecordTableFieldsCells`, hotkey/arrow-key
effects) was duplicated between `RecordTableRow` and
`RecordTableRowVirtualizedFullData`
- Extracted `RecordTableRowCells` (shared cell content) and
`RecordTableStaticTr` (non-draggable `<tr>` wrapper); when drag column
is hidden, rows render inside a static `<tr>` instead of the draggable
wrapper

**View load effect metadata tracking**
- `RecordTableWidgetViewLoadEffect` now tracks
`objectMetadataItem.updatedAt` alongside `viewId` to re-load states when
metadata changes (e.g. field additions), preventing stale column data

**Data source dropdown deduplication**
- Extracted `filterReadableActiveObjectMetadataItems` util, shared by
both chart and record table data source dropdowns — removes duplicated
permission-filtering logic

**RECORD_TABLE view identifier mapping (server)**
- Added `RECORD_TABLE` case to
`fromPageLayoutWidgetConfigurationToUniversalConfiguration` and
`fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration` so
widget views are properly mapped during workspace import/export

**GraphQL error handler typing (server)**
- `formatError` parameter changed from `any` to `unknown`;
`workspaceQueryRunnerGraphqlApiExceptionHandler` broadened from
`QueryFailedErrorWithCode` to `Error | QueryFailedError` — removes
unsafe type casts

**Save hook signature**
- `useSaveRecordTableWidgetsViewDataOnDashboardSave` no longer takes
`pageLayoutId` in constructor; receives it as a callback parameter,
eliminating the need for `useAtomComponentStateCallbackState`

**Customize Dashboard hidden during edit mode**
- The "Customize Dashboard" command was still visible while already
editing — its `conditionalAvailabilityExpression` now includes `not
isPageInEditMode`

**Fields dropdown split**
- `RecordTableFieldsDropdownContent` (300+ lines) split into
`RecordTableFieldsDropdownVisibleFieldsContent` and
`RecordTableFieldsDropdownHiddenFieldsContent`

**Checkbox placeholder cleanup**
- Removed unnecessary `StyledRecordTableTdContainer` wrapper from
`RecordTableCellCheckboxPlaceholder`
2026-03-24 18:28:56 +01:00
Thomas TrompetteandGitHub c94aa7316a send X-Schema-Version header when metadataVersion is 0 (#18924)
Fixes https://github.com/twentyhq/twenty/issues/13103

Newly created workspaces start with metadataVersion = 0. The truthy
check this.currentWorkspace?.metadataVersion && {…} treated 0 as falsy,
so the X-Schema-Version header was never sent. Without this header, the
backend's schema mismatch detection is bypassed and raw validation
errors leak to Sentry. Replaced with isDefined() check.

Also replaced bare negation checks with isDefined() in the backend
validation hook for consistency.
2026-03-24 18:13:31 +01:00
Raphaël BosiandGitHub cac92bffe3 Refactor backfill-command-menu-items to use a single validateBuildAndRun call (#18923)
Refactored `backfill-command-menu-items` upgrade command to remove the
manual `QueryRunner` transaction and wrap standard and trigger workflow
version command menu item creation into a single
`validateBuildAndRunWorkspaceMigration` call.
2026-03-24 18:13:10 +01:00
Charles BochetandGitHub d9eef5f351 Fix visual regression dispatch for fork PRs (#18921)
## Summary
- Visual regression dispatch was failing for external contributor PRs
because fork PRs don't have access to repo secrets
(`CI_PRIVILEGED_DISPATCH_TOKEN`)
- Moved the dispatch from inline jobs in `ci-front.yaml` / `ci-ui.yaml`
to a new `workflow_run`-triggered workflow
- `workflow_run` runs in the base repo context and always has access to
secrets, regardless of whether the PR is from a fork
- Follows the same pattern already used by `post-ci-comments.yaml` for
breaking changes dispatch
- Handles the fork case where `workflow_run.pull_requests` is empty by
falling back to a head label search

## Test plan
- [ ] Verify CI Front and CI UI workflows still pass without the removed
jobs
- [ ] Verify the new `visual-regression-dispatch.yaml` triggers after CI
Front / CI UI complete
- [ ] Test with a fork PR to confirm the dispatch succeeds
2026-03-24 18:13:00 +01:00
77bade8114 i18n - docs translations (#18925)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-24 17:42:35 +01:00
8696e3b7ec i18n - translations (#18922)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-24 17:21:18 +01:00
856c72c5d6 i18n - translations (#18920)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-24 17:15:17 +01:00
cec23e89fa Fix batch update optimistic and prevent accidental mass-update (#17213)
## Problem
- ⚠️ Multi-edit could silently update ALL records of an object with no
undo, no ctrl-z
- After a batch update the table showed stale data —
`useIncrementalUpdateManyRecords` had no explicit `findMany` refetch and
`skipOptimisticEffect` was missing
- Clicking inside the side panel deselected all kanban cards —
`RecordBoardClickOutsideEffect` uses `refs:[]` and the side panel had no
`data-click-outside-id`
- Clicking a currency/select dropdown inside the panel also triggered
deselection — `FloatingPortal` renders outside the side panel DOM,
bypassing the click-outside-id exclusion
- An empty selection silently matched every record —
`computeContextStoreFilters` returned `undefined` filter when
`selectedRecordIds` was `[]`

## Fix
- Table refreshes correctly after batch update —
`useRefetchFindManyRecords` explicitly refetches `FindMany<Object>`
queries; `useIncrementalUpdateManyRecords` adds `skipOptimisticEffect:
true` and calls it in `finally`
- Clicking the side panel no longer deselects kanban cards —
`SidePanelForDesktop` carries `data-click-outside-id`;
`RecordBoardClickOutsideEffect` +
`RecordTableBodyFocusClickOutsideEffect` exclude it
- Clicking dropdowns inside the panel no longer deselects either —
`ParentClickOutsideIdContext` propagates the side panel ID into
`FloatingPortal` content via `DropdownInternalContainer`
- Empty selection can no longer match all records —
`computeContextStoreFilters` returns `{ id: { in: [] } }` instead of
`undefined`
- Apply is disabled with no selection; a confirmation modal shows the
count + no-undo warning before executing —
`UpdateMultipleRecordsContainer`

## Not included
- Undo / snapshot restore — requires backend changes, out of scope

## Blast radius
- `ParentClickOutsideIdContext` touches `DropdownInternalContainer` (207
`<Dropdown>` usages). `parentClickOutsideId` is `undefined` everywhere
outside the side panel → attribute not rendered → zero behavioral change
for existing consumers.

---------

Co-authored-by: Samuel Arbibe <samuelarbibe@Samuels-MacBook-Pro.local>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-03-24 17:14:30 +01:00
Raphaël BosiandGitHub 49afe5dbc4 Refactor: Unify command menu item execution (#18908)
- Makes engineComponentKey non-nullable on CommandMenuItem. Adds two new
engine component keys: TRIGGER_WORKFLOW_VERSION and
FRONT_COMPONENT_RENDERER to cover the former standalone cases.
- Unifies the previously separated engine, front component and workflow
runners into a single `CommandRunner` component with a unified
`useMountCommand` hook that handles all command types.
2026-03-24 15:59:52 +00:00
27c0ca975f Fix navigation “add before/after” insertion index (#18879)
Before: 
<video
src="https://github.com/user-attachments/assets/f0b0740d-414f-4c59-a712-cdf96d4f3eb1"
/>


After:
<video
src="https://github.com/user-attachments/assets/48ce878c-4a29-4666-89d4-4d60882dd5ab"
/>

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-03-24 15:49:07 +00:00
341c13bf32 Fix documentation (#18917)
as title

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-24 15:00:37 +00:00
37787defc2 Fix readme (#18918)
as title

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-24 15:00:10 +00:00
Charles BochetandGitHub 611947e031 fix: metadata store lifecycle during sign-in, sign-out, and locale change (#18901)
## Summary

Fixes metadata lifecycle bugs during sign-in, sign-out, and locale
change:

- **Cross-tab sign-out**: Broadcasts sign-out via `BroadcastChannel` so
other tabs clear their session gracefully instead of hitting stale-token
errors
- **SSE teardown on sign-out**: Handles `UNAUTHENTICATED`/`FORBIDDEN`
errors in the SSE event stream effect instead of throwing unhandled
errors
- **Locale switch resilience**: `invalidateAndReload` now invalidates
collection hashes instead of clearing the store to empty, so components
never see 0 metadata items during the reload transition
- **Sign-in background mock**: Uses non-throwing
`objectMetadataItemFamilySelector` instead of hooks that throw on
missing metadata
- **View name placeholders**: Guards against `undefined` `viewName`
during metadata transitions (the minimal metadata query doesn't include
`name`)
- **Session cleanup**: Selective `localStorage` clearing
(`clearSessionLocalStorageKeys`) preserves metadata keys;
`clearAllSessionLocalStorageKeys` for full clears
- **Metadata reload API**: New `useMetadataStoreActions` hook as the
high-level API for metadata lifecycle operations (`applyMockedMetadata`,
`invalidateAndReload`, `loadMockedMetadataAtomic`)

## Test plan

- [ ] Sign out on Tab A → Tab A shows sign-in page with no console
errors
- [ ] Tab B (logged in) receives cross-tab broadcast and redirects to
sign-in
- [ ] No "Forbidden resource" SSE errors in console during sign-out
- [ ] Change language in Settings > Experience → no crash, metadata
refreshes in background
- [ ] Sign back in after sign-out → metadata loads correctly, app is
functional
- [ ] Re-sign-in after locale change → correct locale is preserved
2026-03-24 14:57:53 +00:00
martmullandGitHub 2317a701bd Fix double arrow (#18916)
## Before

<img width="1004" height="612" alt="image"
src="https://github.com/user-attachments/assets/f0f832eb-d909-449f-88c1-1d30d2ec2c53"
/>


## after

<img width="971" height="555" alt="image"
src="https://github.com/user-attachments/assets/dc751866-85e8-4a3c-8100-2cf109969970"
/>
2026-03-24 14:33:37 +00:00
4c6e102493 Display found items after full items loaded (#18914)
The performCombinedFindManyRecords call was previously fire-and-forget
(.then()), meaning the loading state was set to false and the picker
became interactive before the full records were written into the Apollo
cache.

Fixes https://github.com/twentyhq/twenty/issues/17669

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-24 14:05:22 +00:00
c1ec5567ce fix: insert-before folder for workspace DnD and add-to-nav drags (#18789)
Closes [#2294](https://github.com/twentyhq/core-team-issues/issues/2294)


https://github.com/user-attachments/assets/f7e0da72-dd08-46c4-92d7-be0453aaadd9

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-03-24 14:02:01 +00:00
Baptiste DevessierandGitHub 7a1780e415 Fix duplicate views creation in command (#18900)
Ensure the command doesn't fail on workspaces who got standard views
seeded the 13/03
2026-03-24 13:56:55 +00:00
4104b1d2bc i18n - translations (#18915)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-24 14:59:22 +01:00
Charles BochetandGitHub 7b6fb52df7 fix: validate blocknote JSON in rich text fields (#18902)
## Summary
- **Backend**: Add JSON validation for the `blocknote` subfield in rich
text API inputs — rejects values that aren't valid JSON or aren't arrays
(BlockNote content is always `PartialBlock[]`). This prevents corrupted
data from being persisted to the database.
- **Frontend**: Replace all 5 unprotected `JSON.parse` calls on
blocknote content with the safe `parseJson` utility from
`twenty-shared`. Invalid content now degrades gracefully (empty block /
empty string / unchanged passthrough) instead of crashing the app.
- **Tests**: Added integration tests for invalid blocknote JSON (both
GraphQL and REST), unit tests for the new validation, and updated
existing test constants to use valid BlockNote JSON.

## Context
A user reported a `SyntaxError: Expected ',' or ']' after array element`
crash caused by malformed blocknote JSON stored in the database. The
data had `"children":[]` nested inside the `content` array instead of as
a sibling property. The API accepted this invalid JSON because it only
validated that `blocknote` was a string, not that it contained valid
JSON. On the frontend, 5 call sites used bare `JSON.parse` with no error
handling, causing a white-screen crash.

## Test plan
- [x] Unit tests pass: `validate-rich-text-field-or-throw.util.spec.ts`
(10/10)
- [x] Integration tests pass: `rich-text-field-create-input-validation`
(8/8)
- [ ] Verify creating a note with valid rich text still works end-to-end
- [ ] Verify API returns clear error when blocknote contains invalid
JSON
- [ ] Verify frontend renders empty block instead of crashing when
encountering corrupted data

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-03-24 14:53:15 +01:00
EtienneandGitHub 56ea79d98c Perf investigation - Time qgl query (#18911)
Added a new IS_GRAPHQL_QUERY_TIMING_ENABLED feature flag that, when
activated per workspace, logs the execution time and operation name of
every GraphQL query across both the standard Yoga pipeline and the
direct execution fast path. The timing context propagates via
AsyncLocalStorage to also instrument buildColumnsToSelect and
formatResult — giving a breakdown of column selection and result
transformation costs without any caller changes.
2026-03-24 13:41:15 +00:00
martmullandGitHub d3c7b0131d Fix lambda driver (#18907)
as title
2026-03-24 13:33:39 +00:00
Abdul RahmanGitHubcubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
ec459d8dc8 Fix cropped view/link overlay icons in collapsed navigation sidebar (#18883)
<img width="45" height="670" alt="Screenshot 2026-03-24 at 8 46 31 AM"
src="https://github.com/user-attachments/assets/6eba1854-c279-46d8-a0d0-3034a41a3ce8"
/>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-03-24 13:05:59 +00:00
neo773andGitHub e0b36c3931 fix: ai providers json formatting (#18910)
fixes CI
2026-03-24 12:53:34 +01:00
Baptiste DevessierandGitHub 8eff9efab2 Fix rich text widget edition (#18899)
Fixes https://github.com/twentyhq/twenty/issues/18894
2026-03-24 11:09:41 +00:00
960c80999e i18n - docs translations (#18903)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-24 11:41:29 +01:00
martmullandGitHub cc2be505c0 Fix twenty app dev image (#18852)
as title
2026-03-24 09:31:05 +00:00
493830204a [AI] Fix new chat button stacking side panel and auto-focus editor (#18875)
closes
https://discord.com/channels/1130383047699738754/1480984504737861853
https://discord.com/channels/1130383047699738754/1481210013950279740

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-03-24 09:26:33 +01:00
Félix MalfaitandGitHub dd84ab25df chore: optimize app-dev Docker image and add CI test (#18856)
## Summary

- **Reduce app-dev image size** by stripping ~60MB of build artifacts
not needed at runtime from the server build stage: `.js.map` source maps
(29MB), `.d.ts` type declarations (9MB), compiled test files (14MB), and
unused package source directories (~9MB).
- **Add CI smoke test** for the `twenty-app-dev` all-in-one Docker
image, running in parallel with the existing docker-compose test. Builds
the image, starts the container, and verifies `/healthz` returns 200.

## Test plan

- [x] Built image locally and verified server, worker, Postgres, and
Redis all start correctly
- [x] Verified `/healthz` returns 200 and frontend serves at `/`
- [ ] CI `test-compose` job passes (existing test, renamed from `test`)
- [ ] CI `test-app-dev` job passes (new parallel job)

Made with [Cursor](https://cursor.com)
2026-03-24 08:44:30 +01:00
fd044ba9a2 chore: sync AI model catalog from models.dev (#18885)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-03-24 07:19:06 +01:00
Charles BochetandGitHub 69fe6fcadd chore: add explicit return type to getBackgroundColor (#18878)
Trivial typing improvement to trigger CI Front + CI UI visual regression
pipelines.
2026-03-24 01:10:22 +01:00
Charles BochetandGitHub 0ded15b363 Add visual regression CI for twenty-ui (#18877)
## Summary

- New workflow `ci-visual-regression.yaml` that runs on PRs touching
`twenty-ui` or `twenty-shared`
- Builds `twenty-ui` storybook and uploads the tarball as a GitHub
Actions artifact
- Dispatches to `twentyhq/ci-privileged` which handles the pixel-diff
comparison and posts a PR comment

### Flow

```
twenty CI (this PR)          ci-privileged                  pixel-perfect
─────────────────          ──────────────                  ──────────────
Build storybook
Upload artifact
Dispatch ──────────────►  Download artifact
                          Upload to S3 (OIDC)
                          POST /import-from-storage ────►  Import build
                          POST /diffs/run ──────────────►  Screenshots + diff
                          ◄──────────────────────────────  Diff report JSON
                          Post PR comment
```

Companion PRs:
- https://github.com/twentyhq/ci-privileged/pull/1 (ci-privileged
workflow)
- https://github.com/twentyhq/twenty-infra/pull/497 (OIDC trust + Helm
cleanup)
- https://github.com/twentyhq/pixel-perfect/pull/5 (API simplification)

## Test plan

- [ ] Merge companion PRs first and configure secrets/environments
- [ ] Open a test PR touching twenty-ui, verify storybook builds and
dispatch fires
- [ ] Verify visual regression comment appears on the PR
2026-03-24 00:15:45 +01:00
Charles BochetandGitHub 708e53d829 Fix multi-select option removal crashing when records contain removed values (#18871)
## Summary

- Fixes a bug where removing an option from a MULTI_SELECT field fails
with `invalid input value for enum` when existing records contain the
removed value alongside surviving values.
- The root cause was the `ELSE` branch in `updateArrayEnum` which tried
to cast removed enum values (e.g. `DISTRIBUTOR`) to the new enum type
that no longer includes them.
- The fix replaces the `ELSE` cast with a NULL-producing implicit CASE
default and uses `array_agg(...) FILTER (WHERE mapped_value IS NOT
NULL)` to silently strip removed values from existing arrays.

### Before (bug)
```sql
-- ELSE branch tries to cast removed value to new enum → crash
CASE unnest_value::text
  WHEN 'IMPL' THEN 'IMPL'::new_enum
  WHEN 'APP' THEN 'APP'::new_enum
  ELSE unnest_value::text::new_enum  -- 'DISTRIBUTOR' fails here
END
```

### After (fix)
```sql
-- No ELSE: removed values produce NULL, filtered out by array_agg
SELECT array_agg(mapped_value) FILTER (WHERE mapped_value IS NOT NULL)
FROM (
  SELECT CASE unnest_value::text
    WHEN 'IMPL' THEN 'IMPL'::new_enum
    WHEN 'APP' THEN 'APP'::new_enum
  END AS mapped_value
  FROM unnest(old_column) AS unnest_value
) enum_mapping
```
2026-03-23 20:06:34 +00:00
e2c85b5af0 Fix workspace dropdown truncation and center auth titles (#18869)
# Before

<img width="543" height="315" alt="CleanShot 2026-03-23 at 18 37 17"
src="https://github.com/user-attachments/assets/3a8233f8-507d-40e3-b4f0-0aae325bc4a8"
/>


# After

<img width="230" height="167" alt="CleanShot 2026-03-23 at 18 38 06"
src="https://github.com/user-attachments/assets/dda637d8-766a-4dc4-9909-78edef866c88"
/>

+ video: (long & short title)


https://github.com/user-attachments/assets/5373d168-6ffc-495b-909c-27fc1e68e712


also fixed text not centered in onboarding

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-23 21:06:14 +01:00
Thomas TrompetteandGitHub c6d4162b73 Fix: Background color selected row + border bottom settings tab (#18870)
<img width="1285" height="73" alt="Capture d’écran 2026-03-23 à 18 58
09"
src="https://github.com/user-attachments/assets/a01cfc8e-6492-4ebe-a47a-b504a73e616c"
/>
<img width="574" height="59" alt="Capture d’écran 2026-03-23 à 18 58
39"
src="https://github.com/user-attachments/assets/4947ec02-e151-48fb-87e9-86dbc0ed4fe9"
/>
2026-03-23 19:22:28 +00:00
93de331428 i18n - translations (#18873)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-23 19:36:47 +01:00
Abdul RahmanandGitHub 24055527c8 Rename "New sidebar item" to "New menu item" (#18859) 2026-03-23 18:21:41 +00:00
EtienneandGitHub c58bf9cf06 Fix e2e test (#18866)
Three feature flags changed the UI:
IS_NAVIGATION_MENU_ITEM_ENABLED — Sidebar no longer has a default
"People" link → navigate via URL instead
IS_COMMAND_MENU_ITEM_ENABLED — Button label is now "Create new Person"
(interpolated from object name) instead of static "Create new record"
IS_JUNCTION_RELATIONS_ENABLED — Company is now a junction relation
("Previous Companies") displayed inline, no longer a boxed
dynamic-relation-widget on the record page
2026-03-23 16:56:15 +00:00
Baptiste DevessierandGitHub 07a4cf2d26 Ensure command backfills all relations as Field widget (#18858)
\+ add missing Field widget for workflow relations

Copies the logic of the injectRelationWidgetsIntoLayout front-end
function
2026-03-23 17:49:05 +01:00
Raphaël BosiandGitHub 1cfdd2e8ed Update BackfillCommandMenuItemsCommand with workflow backfill (#18848)
Updated upgrade command to also backfill command menu items for
workflows.
This has been done in the same command because we need to enable the
feature flag once both operations are complete: the creation of the
standard command menu items and the creation of workflow command menu
items.
2026-03-23 17:48:50 +01:00
Félix MalfaitGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>EtienneEtienne
6f4f7a1198 fix: add security headers to file serving endpoints to prevent stored XSS (#18857)
## Summary

- File serving endpoints (`GET file/:fileFolder/:id` and `GET
public-assets/...`) were piping S3/local file streams directly to the
response without any HTTP headers, allowing a stored XSS attack via
uploaded HTML files rendered inline on the CRM origin.
- Adds `Content-Type`, `Content-Disposition`, and
`X-Content-Type-Options: nosniff` headers to all file serving responses.
Only known-safe MIME types (images, PDF, plain text, audio, video) are
served inline; everything else (HTML, SVG, XML, etc.) forces
`Content-Disposition: attachment` to trigger download instead of
rendering.
- New `setFileResponseHeaders` utility with an explicit allowlist of
inline-safe MIME types.

## Test plan

- [x] Unit tests pass (9 tests including 2 new ones: header assertions
and attachment-disposition for HTML)
- [x] Lint clean (`lint:diff-with-main`)
- [x] Typecheck clean (`nx typecheck twenty-server`)
- [ ] Manual: upload an HTML file via `uploadWorkflowFile`, access the
returned URL — should download instead of rendering
- [ ] Manual: upload a PNG image, access the URL — should render inline
with correct `Content-Type: image/png`
- [ ] Manual: verify `X-Content-Type-Options: nosniff` header is present
on all file responses


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

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Etienne <etiennejouan@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-03-23 16:19:16 +01:00
Charles BochetandGitHub 0626f3e469 Fix deploy: remove stale @ai-sdk/groq from yarn.lock (#18863)
## Summary

- The front deploy to S3 is failing because `@ai-sdk/groq` was removed
from `packages/twenty-server/package.json` but the `yarn.lock` was never
updated
2026-03-23 15:39:35 +01:00
Félix MalfaitandGitHub 630f3a0fd7 chore: trigger automerge for AI catalog sync PRs (#18855)
## Summary
- Adds a `repository-dispatch` step to the AI catalog sync workflow
(`ci-ai-catalog-sync.yaml`) that notifies `twenty-infra` when a sync PR
is created
- This allows the automerge workflow in `twenty-infra` to reactively
merge the PR instead of waiting for the next cron run

## Test plan
- [ ] Verify `TWENTY_INFRA_TOKEN` secret is available (same one used by
i18n workflows)
- [ ] Trigger the AI catalog sync workflow manually and confirm the
dispatch fires

Made with [Cursor](https://cursor.com)
2026-03-23 13:33:19 +01:00
b813d64324 chore: sync AI model catalog from models.dev (#18854)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-03-23 13:14:09 +01:00
Félix MalfaitandGitHub c9ca4dfa14 fix: run AI catalog sync as standalone script to avoid DB dependency (#18853)
## Summary
- The `ci-ai-catalog-sync` cron workflow was failing because the
`ai:sync-models-dev` NestJS command bootstraps the full app, which tries
to connect to PostgreSQL — unavailable in CI.
- Converted the sync logic to a standalone `ts-node` script
(`scripts/ai-sync-models-dev.ts`) that runs without NestJS, eliminating
the database dependency.
- Removed the `Build twenty-server` step from the workflow since it's no
longer needed, making the job faster.

## Test plan
- [x] Verified the standalone script runs successfully locally via `npx
nx run twenty-server:ts-node-no-deps-transpile-only --
./scripts/ai-sync-models-dev.ts`
- [x] Verified `--dry-run` flag works correctly
- [x] Verified the output `ai-providers.json` is correctly written with
valid JSON (135 models across 5 providers)
- [x] Verified the script passes linting with zero errors
- [ ] CI should pass without requiring a database service

Fixes:
https://github.com/twentyhq/twenty/actions/runs/23424202182/job/68135439740

Made with [Cursor](https://cursor.com)
2026-03-23 13:07:53 +01:00
Félix MalfaitandGitHub 2dfa742543 chore: improve i18n workflow to prevent stale compiled translations (#18850)
## Summary

- **Add `lingui:compile` to Dockerfile** before both the server and
frontend build stages, ensuring compiled translation catalogs are always
fresh regardless of git state
- **Add `repository-dispatch` to i18n workflows** (`i18n-push.yaml` and
`i18n-pull.yaml`) to trigger reactive automerge in `twenty-infra` when
the i18n PR is ready, replacing the 15-minute polling approach

## Context

Users sometimes see "Uncompiled message detected" errors because
releases can be cut from `main` before the i18n PR (with freshly
compiled translation catalogs) has been merged. This creates a race
condition between new translatable strings landing on `main` and their
compiled catalogs being available.

These changes fix this in two ways:
1. **Safety net in builds**: Every Docker build now compiles
translations before building, so even if compiled catalogs in git are
stale, the build artifact is always correct
2. **Faster i18n PR merges**: Instead of a 15-minute cron polling for
i18n PRs, the workflows now notify `twenty-infra` immediately when
translations are ready, reducing merge latency from ~15 minutes to ~1
minute

Companion PR in twenty-infra: twentyhq/twenty-infra
(feat/i18n-reactive-automerge)

## Test plan

- [ ] Verify `TWENTY_INFRA_TOKEN` secret is available to i18n workflows
- [ ] Docker build still succeeds with the added `lingui:compile` steps
- [ ] i18n-push triggers automerge in twenty-infra after pushing changes
- [ ] i18n-pull triggers automerge in twenty-infra after pulling
translations


Made with [Cursor](https://cursor.com)
2026-03-23 12:53:31 +01:00
e618cc6cf9 i18n - translations (#18846)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-23 10:36:08 +01:00
Félix MalfaitandGitHub bb9e3c44a1 Show AI provider sections regardless of billing status (#18845)
## Summary

- Removes the `isBillingEnabled` guard that was hiding the Providers and
Custom Providers sections in the Admin AI settings
- The `GET_AI_PROVIDERS` query was being skipped when billing was
enabled, and the provider UI sections were conditionally hidden —
there's no reason to gate provider configuration behind billing status
- Cleans up the now-unused `billingState` and `useAtomStateValue`
imports

## Test plan

- [ ] Verify Providers and Custom Providers sections are visible in
Admin > AI settings on cloud (billing enabled)
- [ ] Verify they remain visible on self-hosted (billing disabled)


Made with [Cursor](https://cursor.com)
2026-03-23 10:29:32 +01:00
77d4bd9158 Add billing usage analytics dashboard with ClickHouse integration (#18592)
## Summary
This PR adds a comprehensive billing usage analytics feature that
provides detailed breakdowns of credit consumption across execution
types, users, resources, and time periods. The implementation includes a
new ClickHouse-backed analytics service, GraphQL API endpoint, and a
frontend dashboard component.

## Key Changes

### Backend
- **New BillingAnalyticsService**: Queries ClickHouse for usage
breakdowns by user, resource, execution type, and time series data
- **BillingEventWriterService**: Writes billing events to ClickHouse for
analytics while maintaining best-effort semantics (never blocks Stripe
billing)
- **ClickHouse Schema**: Added `billingEvent` table with 3-year TTL for
storing detailed billing event data
- **GraphQL Resolver**: New `getBillingAnalytics` query that aggregates
usage data for the current billing period, protected by feature flag and
billing permissions
- **Enhanced BillingUsageEvent**: Added `userWorkspaceId` field to track
per-user credit consumption
- **AI Billing Integration**: Updated AI billing service to pass
`userWorkspaceId` when recording usage events

### Frontend
- **SettingsBillingAnalyticsSection**: New component displaying:
  - Usage breakdown by execution type with progress bars
  - Daily usage time series chart (28-day view)
  - Per-user credit consumption breakdown
  - Per-resource (agent/workflow) credit consumption breakdown
- **SettingsUsage Page**: Dedicated page for viewing usage analytics
- **GraphQL Query**: `GetBillingAnalytics` query with generated hooks
- **Navigation**: Added Usage menu item in settings (feature-flagged)
- **Mock Data**: Included screenshot mock data for preview/testing

### Feature Flag
- Added `IS_USAGE_ANALYTICS_ENABLED` feature flag to control visibility
and access to analytics features

## Implementation Details
- Analytics data is queried in parallel for performance
- ClickHouse writes are non-blocking to ensure billing operations never
fail
- Progress bars use dynamic coloring from a predefined palette
- Time series visualization normalizes bar heights relative to max value
- Empty state handling when no analytics data is available
- Responsive UI with proper text truncation for long names

https://claude.ai/code/session_01Y1EqrX6PFq3EJxJq89h7DF

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-23 10:28:23 +01:00
Félix MalfaitandGitHub 49af539032 Fix migration crash: agent.modelId NOT NULL violation (#18844)
## Summary

- The `MigrateModelIdsToCompositeFormat` migration was setting
`agent.modelId = NULL`, but the column has a `NOT NULL` constraint —
causing the migration to fail on deploy.
- Fixed by setting `modelId` to the `'default-smart-model'` sentinel
value instead of NULL. The runtime already resolves this sentinel
dynamically via `getEffectiveModelConfig` / `isDefaultModelSentinel`, so
agents correctly fall back to the admin-configured smart model.

## Test plan

- [ ] Run `npx nx run twenty-server:database:migrate:prod` — migration
should complete without errors
- [ ] Verify agents still resolve to the correct model at runtime
(sentinel → `getDefaultPerformanceModel()`)


Made with [Cursor](https://cursor.com)
2026-03-23 09:44:41 +01:00
68a508a353 i18n - translations (#18839)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-23 01:40:20 +01:00
be89ef30cd Migrate field widgets to backend (#18808)
- Renamed FieldConfiguration's layout field to fieldDisplayMode as it
caused issues with the layout field of BarChartConfiguration
- Create relation Field widgets for standard objects

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-23 01:26:00 +01:00
d90d2e3151 i18n - translations (#18838)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-23 00:51:03 +01:00
e9aa6f47e5 Allow users to create Fields and Field widget (#18801)
- Allow users to create a Fields widget or a Field widget; **this PR is
focused on Fields widgets as Field widget can't be configured yet**
- Automatically create a filled view when the user creates a draft
Fields widget in edit mode


https://github.com/user-attachments/assets/b2dbba52-c614-44cd-bf6c-095ce9d4ec26

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-23 00:44:49 +01:00
dc00701448 Fix: TypeError: Cannot convert undefined or null to object (#18333)
## Automated fix for [bug 6848](https://sonarly.com/issue/6848?type=bug)

**Severity:** `critical`

### Summary
WorkflowEditActionCodeFields.tsx calls Object.entries(functionInput)
without a null guard on line 40, crashing when
action.settings.input.logicFunctionInput is undefined. This happens
because workflow version step data in the database may contain the old
field name serverlessFunctionInput (from before the rename in commit
da6f1bbef3), and no data migration was created to update JSON keys
inside the steps JSONB column.

### User Impact
Users viewing or editing a workflow version containing a CODE step that
was created before the serverlessFunction-to-logicFunction rename see a
crash. The page fails to render the workflow step detail panel,
preventing them from viewing or modifying their workflow.

### Root Cause
Proximate cause: Object.entries(functionInput) on line 40 of
WorkflowEditActionCodeFields.tsx throws TypeError because functionInput
is undefined. The stack trace confirms this: the crash occurs during
React rendering (through scheduler -> react-dom render pipeline ->
WorkflowEditActionCodeFields:40:15 -> Object.entries).

1. Why did Object.entries throw? Because the functionInput prop is
undefined. It is initialized from
action.settings.input.logicFunctionInput via useState on line 142 of
WorkflowEditActionCode.tsx, with no fallback value.

2. Why is action.settings.input.logicFunctionInput undefined? Because
the workflow version step data stored in the database JSONB steps column
contains the OLD field name serverlessFunctionInput instead of
logicFunctionInput. When the frontend accesses logicFunctionInput on the
deserialized JSON object, it returns undefined.

3. Why does the database still have the old field name? Because commit
da6f1bbef3 (Rename serverlessFunction to logicFunction, Jan 28 2026)
renamed the Zod schema field from serverlessFunctionInput to
logicFunctionInput, and the database migration
1769556947746-renameServerless.ts only renames SQL tables and columns
(serverlessFunction table to logicFunction, etc.) but does NOT update
the JSON keys inside the steps JSONB column of the workflowVersion
table.

4. Why was no JSON data migration created? The rename was a large-scale
refactoring across the entire codebase. The steps column stores workflow
action data as opaque JSON, and the migration only addressed relational
schema changes (table/column renames) without updating the embedded JSON
document structure. There is no upgrade command in 1-17, 1-18, or 1-19
directories that transforms the JSON keys from serverlessFunctionInput
to logicFunctionInput.

5. Why did the component not handle this gracefully? The
WorkflowEditActionCodeFields component has no null guard on the
functionInput prop before calling Object.entries. Notably, the analogous
WorkflowEditActionLogicFunction component DOES have a null guard on line
52 (action.settings.input.logicFunctionInput ?? {}), showing the team
was aware of this possibility in one component but missed it in the
other.

**Introduced by:** martmull on 2026-02-16 in commit
[`da064d5`](https://github.com/twentyhq/twenty/commit/da064d5e88a62939e0545a37c68381822e6932ef)

### Suggested Fix
Two minimal null guards are added, matching the pattern already used in
the analogous WorkflowEditActionLogicFunction component. First, in
WorkflowEditActionCode.tsx the useState initializer is changed from
action.settings.input.logicFunctionInput to
action.settings.input.logicFunctionInput ?? {} so that functionInput is
always an empty object rather than undefined when the DB record still
uses the old serverlessFunctionInput key. Second, in
WorkflowEditActionCodeFields.tsx the Object.entries call is changed from
Object.entries(functionInput) to Object.entries(functionInput ?? {}) as
a defensive guard at the render layer, ensuring the component renders
safely even if an undefined value is passed from any caller.

### Evidence
- **Code:**
[packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCode.tsx:142
- const [functionInput, setFunctionInput]
=](https://github.com/twentyhq/twenty/blob/main/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCode.tsx#L142)
- **Code:**
[packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields.tsx:40
- {Object.entries(functionInput ?? {}).map(([inputKey, inputValue]) =>
{](https://github.com/twentyhq/twenty/blob/main/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields.tsx#L40)

---
*Generated by [Sonarly](https://sonarly.com)*

Co-authored-by: Sonarly Claude Code <claude-code@sonarly.com>
2026-03-23 00:44:19 +01:00
a1b1622d94 i18n - translations (#18837)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-23 00:03:35 +01:00
c107d804d2 Create command menu items for workflows with manual trigger (#18746)
- Automatically create and sync command menu items for all workflows
with a manual trigger
- Refactor `useCommandMenuItemsFromBackend`
- Prefill a _Quick Lead_ workflow command menu item during workspace
setup and dev seeding
- Add a ready prop to `HeadlessEngineCommandWrapperEffect` to prevent
premature execution when async data hasn't loaded yet

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-22 23:52:43 +01:00
d16b94bde6 fix: reset form defaultValues after save to fix dirty detection (#18835)
fixes #18833 
I have put some log video and explained the details in pr #18630.
<img width="929" height="454" alt="image"
src="https://github.com/user-attachments/assets/894d0b89-fb0c-478e-ba42-a4c004a98550"
/>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-22 21:11:02 +00:00
Charles BochetandGitHub e95adcc757 fix(helm): add unit tests, extraEnv schema validation, and minor fixes (#18836)
## Summary

Follow-up to #18157. Cherry-picks the useful parts from #18481 (by
@dnplkndll), adapted to align with the current state of `main`:

- **Helm unit tests** for Redis external authentication (secret-based +
plaintext password, both server and worker)
- **Helm unit tests** for `extraEnv` injection (plain values and
`valueFrom` on both server and worker)
- **JSON schema validation** for `server.extraEnv` and `worker.extraEnv`
in `values.schema.json`
- **Fix** `server_url_test.yaml` to use JSONPath filters
(`@.name=="SERVER_URL"`) instead of brittle `env[0]` index selectors
- **Fix** worker `storageEnv` whitespace (missing `-` in `{{-
$storageEnv | nindent 12 }}`)

Stale tests from #18481 (for `disableDbMigrations`, `server.env`
pass-through, and `run-migrations` init container) were dropped since
those features were removed during the #18157 cleanup.

## Test plan

- [ ] `helm lint` passes
- [ ] `helm template` renders cleanly
- [ ] Unit tests pass with `helm unittest` (requires the plugin)


Made with [Cursor](https://cursor.com)
2026-03-22 21:45:31 +01:00
9d613dc19d Improve helm chart // Fix linting issues & introduce Redis externalSecret for redis password // Add additional ENVs // Improve migrations (#18157)
This pull request enhances the Helm chart for the Twenty application by
improving how environment variables and Redis credentials are handled
for both server and worker deployments. The main changes include support
for injecting additional environment variables, improved Redis password
management (including external secrets), and a more robust database
migration workflow.

**Environment Variable Injection:**
- Added support for specifying additional environment variables for both
the server and worker deployments via the `additionalEnv` field in
`values.yaml`. These variables are automatically injected into the
respective pods.
[[1]](diffhunk://#diff-b5d958eae48fd1919e5623bcf0144aac7abb323ae8743e6f31367e383c63c296R55)
[[2]](diffhunk://#diff-b5d958eae48fd1919e5623bcf0144aac7abb323ae8743e6f31367e383c63c296R109-R110)
[[3]](diffhunk://#diff-20bb91909627a12b50b3c165a2a027b663479c0104ed8dbf91d2b9ad8ea8a931R74-R77)
[[4]](diffhunk://#diff-20bb91909627a12b50b3c165a2a027b663479c0104ed8dbf91d2b9ad8ea8a931R157-R172)
[[5]](diffhunk://#diff-20bb91909627a12b50b3c165a2a027b663479c0104ed8dbf91d2b9ad8ea8a931R225-R229)
[[6]](diffhunk://#diff-fb612a3b7a13156aaa607b27d23025e2c6831f111b6a582fd313fad26d2fdb5bR89-R92)

**Redis Credential Management:**
- Introduced support for using external secrets for Redis passwords by
adding `secretName` and `passwordKey` fields under `redis.external` in
`values.yaml`, and logic to inject `REDIS_PASSWORD` from a Kubernetes
secret if configured.
[[1]](diffhunk://#diff-b5d958eae48fd1919e5623bcf0144aac7abb323ae8743e6f31367e383c63c296R180-R182)
[[2]](diffhunk://#diff-5c4fa358b10abd7581188995feb9b4d6be0bc4f06a95bf27bb31b5595d6693d8R92-R100)
[[3]](diffhunk://#diff-20bb91909627a12b50b3c165a2a027b663479c0104ed8dbf91d2b9ad8ea8a931R157-R172)
[[4]](diffhunk://#diff-20bb91909627a12b50b3c165a2a027b663479c0104ed8dbf91d2b9ad8ea8a931R196-R205)
[[5]](diffhunk://#diff-fb612a3b7a13156aaa607b27d23025e2c6831f111b6a582fd313fad26d2fdb5bR70-R79)
- Updated the logic for constructing the `REDIS_URL` to include
authentication information if a password is set or an external secret is
used.

**Database Migration Workflow:**
- Improved the startup command for the server deployment to optionally
skip database migrations (using `DISABLE_DB_MIGRATIONS`), check for an
existing schema before running migrations, and ensure setup scripts are
only run on empty databases.

These changes make the chart more flexible and secure, especially for
production deployments requiring externalized secrets and custom
environment configurations.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-22 21:36:10 +01:00
23fb2a0d58 i18n - docs translations (#18832)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-22 21:22:28 +01:00
fa04e7077e i18n - translations (#18831)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-22 17:02:40 +01:00
1a0588d233 fix: auto-retry Microsoft OAuth on AADSTS650051 race condition (#18405)
## Summary

Fixes #13008

Azure AD has a [known
bug](https://learn.microsoft.com/en-us/answers/questions/5570899/our-adminconsent-url-always-fails-with-aadsts65005)
where the first consent attempt for a multi-tenant app fails with
`AADSTS650051` because the service principal is mid-provisioning in the
target tenant. Microsoft's own engineers have acknowledged the issue
with no fix ETA.

This PR adds a transparent retry to the `MicrosoftOAuthGuard`:

1. When the OAuth redirect returns `AADSTS650051`, the guard parses the
`state` parameter to recover the original query params (workspaceId,
invite hash, locale, etc.)
2. Redirects the user back to `/auth/microsoft` with a `msRetry=1`
counter
3. The full OAuth flow restarts — by this point the service principal
has finished provisioning, so consent succeeds
4. Capped at 1 retry (`MAX_MICROSOFT_AUTH_RETRIES`) to prevent infinite
loops. If it still fails, falls through to the normal error page

From the user's perspective, they just see a brief extra redirect
instead of a cryptic error page.

### Context

- `AADSTS650051` is a race condition in Azure AD's service principal
provisioning during multi-tenant app consent
- It's distinct from missing consent (`AADSTS65001`) or admin consent
required (`AADSTS90094`)
- The error is transient — retrying the same flow immediately succeeds
- Microsoft Q&A threads confirm this affects many multi-tenant apps, not
just Twenty

### References

- [Microsoft Q&A: adminconsent always fails with AADSTS650051 on first
attempt](https://learn.microsoft.com/en-us/answers/questions/5570899/our-adminconsent-url-always-fails-with-aadsts65005)
- [Microsoft Q&A: AADSTS650051 for multiple
applications](https://learn.microsoft.com/en-us/answers/questions/5571098/when-customers-attempt-to-sign-in-and-grant-consen)

## Test plan

- [ ] Deploy to a staging environment with Microsoft OAuth enabled
- [ ] Have a user from a new Azure AD tenant attempt Microsoft sign-in
for the first time
- [ ] If AADSTS650051 occurs, verify the user is transparently retried
and signs in successfully
- [ ] Verify `msRetry` counter prevents infinite redirect loops (check
server logs for the warn message)
- [ ] Verify normal Microsoft sign-in flow (no error) is unaffected

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: neo773 <neo773@protonmail.com>
2026-03-22 16:51:10 +01:00
8ef32c4781 Add In-Reply-To to Email Workflow Node (#18641)
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-22 16:49:08 +01:00
7bde8a4dfa [Chore]: Migration to dynamic view names for standard views (#18701)
Follow up for #18700

---------

Co-authored-by: Arun kumar <arunkumar@Aruns-MacBook-Air.local>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-22 15:18:17 +00:00
Charles BochetandGitHub d5a7dec117 refactor: rename ObjectMetadataItem to EnrichedObjectMetadataItem and clean up metadata flows (#18830)
## Summary

- Renames `ObjectMetadataItem` to `EnrichedObjectMetadataItem` across
the entire frontend (~440 files) to clarify that this type includes
derived fields (`readableFields`, `updatableFields`, nested `fields[]`,
`indexMetadatas[]`) computed at read time from the metadata store
- Creates `splitObjectMetadataGqlResponse` that goes directly from a
GraphQL `ObjectMetadataItemsQuery` response to flat store items
(combining the old
`mapPaginatedObjectMetadataItemsToObjectMetadataItems` +
`splitObjectMetadataItemWithRelated` two-step flow into one call)
- Removes `ObjectMetadataItemWithRelated` type and all "WithRelated"
naming
- Renames `generatedMockObjectMetadataItems` to
`generateTestEnrichedObjectMetadataItemsMock` to make it clear this is
test-only enriched data
- Deletes `useLoadMockedObjectMetadataItems` hook (consolidated into
`useLoadMockedMinimalMetadata`)
- Ensures nothing destined for the metadata store computes
`readableFields`/`updatableFields` (preventing the localStorage bloat
from #18809)

## Type hierarchy (before → after)

**Before:**
```
ObjectMetadataItemsQuery → mapPaginated → ObjectMetadataItemWithRelated → enrich → ObjectMetadataItem
                                        → split → FlatObjectMetadataItem (store)
```

**After:**
```
ObjectMetadataItemsQuery → splitObjectMetadataGqlResponse → FlatObjectMetadataItem (store)
                         → mapPaginated + enrich (tests only) → EnrichedObjectMetadataItem
```

## Test plan

- [x] `npx nx typecheck twenty-front` passes
- [x] `npx nx test twenty-front` passes (767 suites, 4505 tests)
- [x] `npx nx lint twenty-front` passes
- [ ] CI checks pass


Made with [Cursor](https://cursor.com)
2026-03-22 15:53:47 +01:00
96c5728ed0 fix: prevent localStorage bloat from derived fields on mock metadata (#18809)
## Summary

- On unauthenticated pages (login), mocked object metadata was being
hydrated into the store with `readableFields` and `updatableFields`
attached. These derived arrays duplicate every field per object,
inflating `objectMetadataItems` in localStorage from ~70 KB to ~2 MB.
Combined with other metadata keys, this exceeded Safari's 5 MB quota and
caused `QuotaExceededError`, preventing real data from replacing mock
data on login.
- Extracted flat mock data into a dedicated file
(`generatedMockObjectMetadataItemsWithRelated.ts`) and use it for store
hydration, keeping the enriched version
(`generatedMockObjectMetadataItems`) only for tests.
- Completed `splitObjectMetadataItemWithRelated`'s contract by stripping
`readableFields` and `updatableFields` at runtime (not just via
TypeScript's `Omit`), matching the `FlatObjectMetadataItem` return type
that omits all four relational properties.

## Root cause

1. `MinimalMetadataLoadEffect` loads mocked metadata on unauthenticated
pages.
2. `generatedMockObjectMetadataItems` was produced by
`enrichObjectMetadataItemsWithPermissions`, which attaches
`readableFields` and `updatableFields` (full copies of the `fields`
array).
3. `splitObjectMetadataItemWithRelated` only destructured `fields` and
`indexMetadatas` — `readableFields`/`updatableFields` leaked into
`...objectProperties` at runtime because `Omit` is a compile-time-only
guard.
4. These bloated objects were written to localStorage, consuming ~2 MB
instead of ~70 KB.
5. On login, the intermediate state (old composite mock `current` + new
flat real `draft`) exceeded the 5 MB quota, causing a
`QuotaExceededError` deadlock.

## Test plan

- [ ] Open the app in a fresh Safari private window (empty localStorage)
- [ ] Verify the login page loads without errors
- [ ] Log in and verify metadata loads correctly
- [ ] Check `localStorage` size —
`metadataStoreState__objectMetadataItems` should be ~70 KB, not ~2 MB
- [ ] Run existing tests: `npx nx test twenty-front` — no regressions
from the mock data split


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

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-22 13:54:44 +01:00
bdaff0b7e2 Fetch load more on group by (#18811)
Fixes https://github.com/twentyhq/twenty/issues/18587 "Load More" in
grouped record table view (aggregated view) which was broken — clicking
it loaded no records and made the button disappear

Root cause: the Load More button called fetchMore on a useLazyQuery that
was never executed, causing Apollo to throw Invariant Violation:
'fetchMore' cannot be called before executing the query. The component
now uses fetchMoreRecords from the same useQuery that powers the table
data.

Before

https://github.com/user-attachments/assets/5266424b-7f35-4261-b7b7-9f7bc3eb8ad6

After

https://github.com/user-attachments/assets/866fe7d7-720a-40c7-bb28-267b85bd98d5

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-22 12:59:09 +01:00
Charles BochetandGitHub 9a306ddb9a feat: store SSO connections as connected accounts during sign-in (#18825)
## Summary

- Store SSO connections (Google, Microsoft, OIDC, SAML) as connected
accounts in the core schema during sign-in/sign-up, gated behind the
`IS_CONNECTED_ACCOUNT_MIGRATED` feature flag
- Add `OIDC` and `SAML` to `ConnectedAccountProvider` enum with
exhaustive switch handling across frontend and backend
- Add `IS_CONNECTED_ACCOUNT_MIGRATED` to `DEFAULT_FEATURE_FLAGS` for new
workspaces, with a fallback check so SSO accounts are created even
before workspace activation
- Always upsert connected accounts to both workspace and core schemas
during messaging OAuth flow, fixing FK constraint violations when
SSO-only accounts exist only in core
- Create message/calendar channels when they don't exist regardless of
new vs reconnect flow
- Filter settings accounts list to only show accounts that have message
or calendar channels

## Test plan

- [ ] Sign up with Google SSO → verify connected account is created in
core schema
- [ ] Connect messaging (Google APIs) after SSO sign-up → verify no FK
errors, channels created, configuration page renders correctly
- [ ] Reconnect an existing messaging account → verify tokens updated,
sync resets triggered
- [ ] Sign in with OIDC/SAML SSO → verify connected account created with
oidcTokenClaims
- [ ] Verify settings accounts page only shows accounts with channels
(SSO-only accounts hidden)
- [ ] Verify typecheck, lint, and unit tests pass
2026-03-22 12:29:58 +01:00
neo773andGitHub 246afe0f2a fix: skip chat messages query when thread ID is the unknown-thread default (#18828)
Prevents invalid UUID error on initial mount before a real thread is
selected
2026-03-22 10:53:43 +01:00
d69e4d7008 fix: prevent FIND_RECORDS from silently dropping unresolved filter variables (#18814)
## Summary

Fixes #18744 — The workflow FIND_RECORDS action silently drops filter
conditions when a variable resolves to null/empty, causing the query to
return **all records** instead of erroring.

**Root cause (three compounding layers):**

1. **`variable-resolver.ts`** — `resolveString` returns `undefined` when
a variable lookup fails (e.g., `{{steps.trigger.output.userId}}` where
`userId` doesn't exist in context). The return type says `string` but
`evalFromContext` actually returns `undefined` at runtime.

2. **`checkIfShouldSkipFiltering.ts`** — Treats `undefined`/`null`/`""`
values as "skip this filter." This is correct for the **UI filter
builder** (user hasn't finished typing), but wrong for **workflow
execution** (variable resolution failed = misconfigured workflow).

3. **`find-records.workflow-action.ts`** — When all filters are silently
skipped, `computeRecordGqlOperationFilter` returns `{}` (match
everything). The query runs with no filter, returning all records —
silently succeeding with wrong results.

## Fix

Added validation in `find-records.workflow-action.ts` **after**
`resolveInput` but **before** `computeRecordGqlOperationFilter`. For
each filter with a value-requiring operand (i.e., not IS_EMPTY,
IS_NOT_EMPTY, IS_IN_PAST, IS_IN_FUTURE, IS_TODAY), if the resolved value
is `undefined`, `null`, or `""`, throw `INVALID_STEP_INPUT` with a
descriptive error message.

**Why this approach:**
- Scoped to the workflow executor — does **not** break the UI filter
builder's intentional skip-on-empty behavior
- Does not change shared utilities (`checkIfShouldSkipFiltering`,
`resolveInput`) used across the app
- Fails fast with a clear error instead of silently returning wrong data
- 1 file changed, 23 lines added

## Test plan

- [x] Backend typecheck passes
- [x] oxlint passes (0 warnings, 0 errors)
- [x] Prettier passes
- [ ] Manual: Create a workflow with FIND_RECORDS using a variable that
doesn't exist → should error with "Filter condition has an empty value
after variable resolution" instead of returning all records
- [ ] Manual: Create a workflow with FIND_RECORDS using IS_EMPTY operand
(no value needed) → should still work correctly

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-21 18:27:01 +01:00
Charles BochetandGitHub 03a2abb305 fix: board view loads all records instead of showing skeleton placeholders (#18824)
## Summary

- The record board (Kanban view) only loaded the first 10 records per
column, then showed skeleton placeholder cards for the rest without ever
fetching the remaining data
- **Root cause**: The `RecordBoardFetchMoreInViewTriggerComponent`
(IntersectionObserver) is positioned at the bottom of the entire board.
With 10 real cards + 10 skeleton cards per column (~3500px of content),
the trigger div was pushed far beyond the 1600px `rootMargin` detection
zone, so `recordBoardShouldFetchMore` never became `true` and fetch-more
was never triggered
- **Fix**: The initial query now sets `recordBoardShouldFetchMore =
true` when columns need more data, and `triggerRecordBoardFetchMore`
uses an internal `while` loop to load all remaining pages in a single
invocation — making it immune to the InView component racing to reset
the flag between React render cycles
2026-03-21 17:20:53 +01:00
c01fc3bafb i18n - translations (#18823)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-21 16:21:41 +01:00
Félix MalfaitandGitHub 908aefe7c1 feat: replace hardcoded AI model constants with JSON seed catalog (#18818)
## Summary

- Replaces per-provider TypeScript constant files
(`openai-models.const.ts`, `anthropic-models.const.ts`, etc.) with a
single `ai-providers.json` catalog as the source of truth
- Adds runtime model discovery via AI SDK for self-hosted providers,
with `models.dev` enrichment for pricing/capabilities
- Introduces composite model IDs (`provider/modelId`) for canonical,
conflict-free identification
- Simplifies provider configuration: API keys are injected from
environment variables (e.g., `OPENAI_API_KEY`)
- Adds admin panel UI for provider management (add/remove/test), model
discovery, recommended model configuration, and default fast/smart model
selection per workspace
- Removes deprecated config variables (`AI_DISABLED_MODEL_IDS`,
`AUTO_ENABLE_NEW_AI_MODELS`, etc.)
- Adds database migration for composite model ID format

## Test plan

- [ ] Server typecheck passes
- [ ] Frontend typecheck passes
- [ ] Server unit tests pass
- [ ] Frontend unit tests pass
- [ ] CI pipeline green
- [ ] Admin panel AI tab loads correctly
- [ ] Provider discovery works for configured providers
- [ ] Model recommendation toggles persist
- [ ] Default fast/smart model selection works


Made with [Cursor](https://cursor.com)
2026-03-21 16:03:58 +01:00
50a0bef0e4 i18n - translations (#18822)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-21 16:03:02 +01:00
cd651f57cb fix: prevent blank subdomain from being saved (#18812)
## Summary

Fixes #17941 — Saving a blank subdomain causes a redirect to
`.website.com`, effectively breaking the workspace.

**Root cause:** Three layers all fail to reject an empty string `""`:

1. **Frontend (`SettingsDomain.tsx`):** `SaveButton` has both
`onClick={onSave}` and `type="submit"`. The `onClick` fires first,
calling `handleSave()` directly without running Zod validation. So
`isDefined("")` returns `true`, the confirmation modal opens, and the
blank subdomain is submitted.

2. **Backend DTO (`update-workspace-input.ts`):** The `subdomain` field
has `@IsString()` + `@IsOptional()` but no pattern validation, so an
empty string passes the DTO layer.

3. **Backend service (`workspace.service.ts:152`):** `if
(payload.subdomain && ...)` — empty string is falsy in JS, so it skips
`validateSubdomainOrThrow()` entirely and writes `subdomain: ""` to the
database.

**The crash:** After save, the redirect logic does
`"myworkspace.website.com".replace("myworkspace", "")` →
`".website.com"`, sending the user to an invalid URL.

## Fix

- **Frontend:** Call `form.trigger()` at the start of `handleSave` to
run Zod validation regardless of whether the function was invoked via
`onClick` or `form.handleSubmit`. Returns early with validation error if
invalid.
- **Backend DTO:** Add `@Matches(/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/)`
to reject invalid subdomains at the request validation layer
(defense-in-depth).
- **Backend service:** Change `if (payload.subdomain && ...)` to `if
(isDefined(payload.subdomain) && ...)` so empty strings route through
`validateSubdomainOrThrow()` instead of being silently skipped.

## Test plan

- [x] Existing `is-subdomain-valid.util.spec.ts` tests pass (36/36)
- [x] TypeScript type checks pass for both `twenty-server` and
`twenty-front`
- [x] oxlint passes on all changed files
- [x] Prettier passes on all changed files
- [ ] Manual: Navigate to Settings > Domains, clear the subdomain field,
click Save — should show validation error, not redirect

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-21 14:49:21 +00:00
fc9723949b Fix AI chat re-renders and refactored code (#18585)
This PR: 

- Breaks useAgentChatData into focused effect components (streaming,
fetch,
init, auto-scroll, diff sync)
- Splits message list into non-last (stable) + last (streaming/error) to
prevent full re-renders on each stream chunk
- Adds scroll-to-bottom button and MutationObserver-based auto-scroll on
thread switch
- Lifts loading state from context to atoms
- Adds areEqual to selector
factories

We could improve further but this sets up a robust architecture for
further refactoring.

## Messages flow

The flow of messages loading and streaming is now more solid. 

Everything goes out from `AgentChatAiSdkStreamEffect`, whether loaded
from the DB or streaming directly, and every consumers is using only one
atom `agentChatMessagesComponentFamilyState`

## Data sync effect with callbacks new hook 

See
`packages/twenty-front/src/modules/apollo/hooks/useQueryWithCallbacks.ts`
which allows to fix Apollo v4 migration leftovers and is an
implementation of the pattern we talked about with @charlesBochet

We could refine this pattern in another PR.

# Before



https://github.com/user-attachments/assets/84e7a96f-6790-405d-8a73-2dacbf783be5


# After



https://github.com/user-attachments/assets/4c692e3a-2413-4513-abcc-44d0da311203

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-21 12:52:21 +00:00
d389a4341d [Fix]: Vertical bar charts with max data range do not show data up to the top if above max data range (#18748)
fixes issue : #18606 

we have removed the irrelevant use of filters in the backend which
removes the values that are out of max, min range hence modifying the
rawValues.

- Modified relevant tests
- fixed regressions (Horizontal bar charts rightLabel cropping)
- removed dead utility filterByRange code

Before :
<img width="1253" height="722" alt="Screenshot 2026-03-18 at 10 24
14 PM"
src="https://github.com/user-attachments/assets/20adb5bb-0f96-4952-9777-1f1a6ebe03eb"
/>

After:
<img width="865" height="724" alt="Screenshot 2026-03-18 at 11 16 33 PM"
src="https://github.com/user-attachments/assets/b2695d85-ca5e-4923-8f57-8d3c87c8212a"
/>

<img width="1260" height="689" alt="Screenshot 2026-03-19 at 12 16
41 AM"
src="https://github.com/user-attachments/assets/fb25e15f-eec2-445e-9103-280d58664454"
/>

---------

Co-authored-by: Arun kumar <arunkumar@Aruns-MacBook-Air.local>
Co-authored-by: ehconitin <nitinkoche03@gmail.com>
2026-03-20 19:33:21 +00:00
2a1d22d870 Fix page scrolling when layout customization bar is visible (#18802)
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-03-20 18:02:15 +00:00
Charles BochetandGitHub 732aea9121 fix: restore original migration timestamp for messaging infrastructure (#18810)
## Summary
- Restores the original migration timestamp (`1773945207801`) that was
accidentally changed to `1774005903909` during PR #18787
- Keeps the content improvements (default column values) from that PR
intact

## Test plan
- [ ] Verify migration runs correctly with `npx nx run
twenty-server:database:migrate:prod`


Made with [Cursor](https://cursor.com)
2026-03-20 18:14:51 +01:00
42269cc45b fix(links): preserve percent-encoded URLs during normalization (#18792)
## Summary

This preserves percent-encoded payloads when normalizing links fields.

`lowercaseUrlOriginAndRemoveTrailingSlash` was decoding the path and
query string while lowercasing the URL origin. That changes URLs where
encoded payloads are semantically significant, such as Google Maps links
containing `%2F` segments.

Closes #18698.

## Changes

- stop decoding the path/query payload in
`lowercaseUrlOriginAndRemoveTrailingSlash`
- preserve the raw path, query, and hash while still lowercasing the
origin and trimming a trailing slash
- update shared URL normalization tests to assert encoded payloads stay
encoded
- add a server-side regression test covering imported links field
normalization

## Validation

- `corepack yarn jest --config packages/twenty-shared/jest.config.mjs
packages/twenty-shared/src/utils/url/__tests__/lowercaseUrlOriginAndRemoveTrailingSlash.test.ts
--runInBand`
- `corepack yarn jest --config packages/twenty-server/jest.config.mjs
packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/transform-links-value.util.spec.ts
--runInBand`
- `corepack yarn nx test twenty-server --runInBand
--testFile=src/engine/core-modules/record-transformer/utils/__tests__/transform-links-value.util.spec.ts`

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-20 16:56:12 +00:00
1be0f1554f i18n - docs translations (#18807)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-20 17:46:46 +01:00
d149c2a041 i18n - translations (#18806)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-20 17:44:20 +01:00
EtienneandGitHub 9698996771 fix useless auth in sdk gql codegen command (#18805) 2026-03-20 17:22:41 +01:00
9cb21e71fa feat: secure and user-scope metadata resolvers for messaging infrastructure (#18787)
## Summary

Builds on the messaging infrastructure migration (#18784) by securing
and user-scoping all 4 metadata resolvers:

### DTOs secured
- **ConnectedAccountDTO**: `@HideField()` on `accessToken`,
`refreshToken`, `connectionParameters`, `oidcTokenClaims`
- **MessageChannelDTO / CalendarChannelDTO**: `@HideField()` on
`syncCursor`
- **MessageFolderDTO**: `@HideField()` on `syncCursor`, `externalId`
- **UpdateMessageFolderInputUpdates**: stripped to only `isSynced`
(removed `name`, `syncCursor`, `pendingSyncAction`)

### Resolvers user-scoped via `@AuthUserWorkspaceId()`
- `myConnectedAccounts` — returns only the calling user's accounts (no
permission guard)
- `myMessageChannels(connectedAccountId?)` — returns channels for the
user's connected accounts
- `myCalendarChannels(connectedAccountId?)` — same pattern
- `myMessageFolders(messageChannelId?)` — returns folders through the
ownership chain

### Admin-only listing with permission guard
- `connectedAccounts` query retained with
`SettingsPermissionGuard(CONNECTED_ACCOUNTS)` for admin listing of all
workspace accounts

### Unsafe mutations removed
- Removed `createConnectedAccount`, `updateConnectedAccount` (OAuth/IMAP
flows create/refresh tokens server-side)
- Removed `create*`/`delete*` mutations from MessageChannel,
CalendarChannel, MessageFolder (managed by sync engine)

### Update mutations restricted with ownership verification
- `deleteConnectedAccount(id)` — verifies `entity.userWorkspaceId ===
currentUserWorkspaceId`
- `updateMessageChannel` / `updateCalendarChannel` /
`updateMessageFolder` — verify ownership through connected account chain
- New `OWNERSHIP_VIOLATION` exception codes map to `ForbiddenError` in
GraphQL

### `@AuthUserWorkspaceId` decorator hardened
- Added `allowUndefined` option (default: `false`) — throws
`ForbiddenException` if `userWorkspaceId` is undefined (e.g. API key
auth)
- Existing callers updated to `@AuthUserWorkspaceId({ allowUndefined:
true })` where needed
- New user-scoped resolvers enforce non-undefined `userWorkspaceId` at
decorator level

### Exception handler chaining
- `MessageFolderGraphqlApiExceptionInterceptor`,
`MessageChannelGraphqlApiExceptionInterceptor`,
`CalendarChannelGraphqlApiExceptionInterceptor` chain upstream exception
handling (ConnectedAccountException, MessageChannelException) for
correct `ForbiddenError` propagation

### Metadata services enhanced
- `findByUserWorkspaceId()`, `getUserConnectedAccountIds()`,
`findByConnectedAccountIds()`, `findByMessageChannelIds()`
- `findBy*ForUser()` methods encapsulate ownership checks before
querying
- `verifyOwnership()` on all 4 services with proper chain validation
- Named parameters throughout for clarity

### Dev seeds for both schemas
- Added JANE to connected account, message channel, calendar channel
workspace seeds
- Created message folder workspace seeds (TIM, JONY, JANE)
- New `seed-metadata-entities.util.ts` seeds core schema tables
(connectedAccount, messageChannel, calendarChannel, messageFolder) with
same IDs as workspace seeds, mapping `accountOwnerId` →
`userWorkspaceId`

### Integration tests (using seeds, not raw SQL)
- 4 test suites (`connected-account`, `message-channel`,
`calendar-channel`, `message-folder`)
- Tests use seeded data IDs from seed constants — no raw SQL
inserts/deletes
- Tests read via GraphQL resolvers
- Tests cover: user scoping, admin permission checks, sensitive field
exclusion, ownership enforcement on mutations

### Frontend migration
- Feature-flag-gated hooks (`useMyConnectedAccounts`,
`useMyMessageChannels`, `useMyCalendarChannels`, `useMyMessageFolders`)
- When `IS_CONNECTED_ACCOUNT_MIGRATED` is on: hooks use metadata API
(`POST /metadata`)
- When flag is off: hooks use existing workspace API (`POST /graphql`,
current behavior)
- Settings account pages updated to use new hooks
- `useEffect` extracted to
`SettingsAccountsSelectedMessageChannelEffect` component per project
conventions
- Error messages translated with Lingui

## Test plan
- [x] Server typecheck passes
- [x] Server lint passes
- [x] Server unit tests pass (477 suites, 4269 tests)
- [x] Frontend typecheck passes
- [x] Frontend lint passes
- [x] Integration tests verify user-scoping, ownership enforcement,
hidden fields
- [ ] CI green

---------

Co-authored-by: neo773 <neo773@protonmail.com>
2026-03-20 17:22:22 +01:00
Thomas TrompetteandGitHub a8625d8bfb Clean workflow query invalid input errors from sentry (#18804)
As title
2026-03-20 16:12:25 +00:00
Charles BochetandGitHub 3d49c21b51 fix: decouple viewPicker favorite detection from sorted navigation menu items (#18803)
## Summary

- Reverts the `useSortedNavigationMenuItems` coupling in
`ViewPickerOptionDropdown` introduced by #18791
- The viewPicker now uses `useNavigationMenuItemsData` directly for the
`isFavorite` check, keeping view ordering and navigation menu item
ordering independent

## Why

PR #18791 replaced `useNavigationMenuItemsData` with
`useSortedNavigationMenuItems` in the viewPicker for favorite detection.
While the intent was to filter out stale/orphaned navigation items, this
created an unnecessary coupling: `useSortedNavigationMenuItems`
subscribes to `viewsSelector` and `objectMetadataItemsSelector`, making
the viewPicker transitively dependent on the navigation menu item
ordering system. View ordering in the picker (driven by `view.position`)
and navigation menu item ordering (driven by
`navigationMenuItem.position`) should remain decorrelated.

## Test plan

- [ ] Open the viewPicker dropdown and verify views are listed in
correct order
- [ ] Drag-and-drop to reorder views in the viewPicker — confirm it
works
- [ ] Verify the "Add to Favorite" / "Manage favorite" label still
correctly reflects favorite state
- [ ] Reorder navigation menu items in the sidebar — confirm viewPicker
order is unaffected


Made with [Cursor](https://cursor.com)
2026-03-20 16:08:26 +00:00
7c8f060b08 Fix orphan navigation menu items for deleted views (#18791)
## Summary

Fixes #18757

This fixes a set of Favorites / navigation-menu-item integrity problems
related to deleted views, stale hidden items, and upgraded workspaces
with orphaned navigation items.

## What changed

- delete `navigationMenuItem` entries when their favorited view is
deleted
- keep the client metadata store in sync immediately when a view is
deleted
- determine whether a view is already favorited from visible valid
navigation items instead of raw stale items
- add a `1.20.0` upgrade repair command that deletes orphan navigation
menu items and normalizes positions
- add regression coverage for deletion of both record-based and
view-based navigation menu items

## Details

Server:
- extend `NavigationMenuItemDeletionService` so cleanup applies to
deleted views as well as deleted records
- add regression tests covering record-based deletion, view-based
deletion, and no-op behavior
- add `DeleteOrphanNavigationMenuItemsCommand` to remove orphaned items
pointing to:
  - deleted views
  - deleted records
  - missing folders
- normalize positions per scope (`userWorkspaceId + folderId`) after
repair
- wire the new repair command into the `1.20.0` upgrade flow

Frontend:
- add `useRemoveNavigationMenuItemByViewId`
- remove the related navigation item from client metadata immediately
when deleting a view
- use sorted / visible navigation items for favorite detection so stale
hidden rows do not block re-adding a favorite

## Why

Issue `#18757` reports mismatches between Favorites shown in the UI and
rows users can still find in the database. We found that current
Favorites behavior is driven by `navigationMenuItem`, not the legacy
`favorite` table, and that stale / orphaned `navigationMenuItem` rows
could:
- remain after deleting a favorited view
- stay hidden from the UI if they point to invalid targets
- still cause the UI to think a view was already favorited
- persist in workspaces with migration damage from skipped sequential
upgrades

This patch addresses those cases directly and adds an upgrade-time
repair path for older corrupted workspaces.

## Validation

Passed:
- `./node_modules/.bin/jest --config
packages/twenty-server/jest.config.mjs --runInBand
packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/__tests__/navigation-menu-item-deletion.service.spec.ts`
- `./node_modules/.bin/tsc -p packages/twenty-front/tsconfig.json
--noEmit --pretty false`

Known unrelated existing failure:
- `./node_modules/.bin/tsc -p packages/twenty-server/tsconfig.json
--noEmit --pretty false`

The server typecheck failure is pre-existing and unrelated to this
branch. Current errors are around `@file-type/pdf` module resolution and
`is-psl-parsed-domain.type.ts`.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-20 15:54:33 +01:00
89300564ba Add a note to documentation about variable change (#18752)
Fixes https://github.com/twentyhq/twenty/issues/18646

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-20 15:54:11 +01:00
e7434bdc39 Direct graphql execution (#18759)
On top of [previous closed
PR](https://github.com/twentyhq/twenty/pull/18713) from @FelixMalfait :
- add a schema-creation-skipping optimization
- extract a handler-per-operation pattern, 
- add runtime input validation guards,
- integrate with the standard workspace cache
- add gql-style error handling


To do/optimize/check : 
- gql parsing and null backfilling


## Intro 

This PR introduces a **direct GraphQL execution path** that bypasses
per-workspace GraphQL schema generation for workspace data queries (CRUD
on user-defined objects like companies, people, tasks, etc.).

## Why

In the current architecture, every workspace gets its own
dynamically-generated GraphQL schema reflecting its custom objects and
fields. This costs **~20MB of RAM per workspace per pod** and takes time
to build. For a multi-tenant SaaS with thousands of workspaces, this is
a significant infrastructure cost and a latency bottleneck (especially
on cold starts or cache misses).

The insight is that most workspace queries (`findMany`, `createOne`,
`updateOne`, etc.) don't actually *need* the full schema — they can be
routed directly to the existing Common API query runners by parsing the
GraphQL AST and matching resolver names against object metadata. The
schema is only truly needed for introspection, subscriptions, or queries
that mix core and workspace resolvers.

## How It Works

1. A Yoga `onRequest` plugin intercepts incoming GraphQL requests
2. It parses the query AST and checks if all top-level fields map to
generated workspace resolvers (e.g. `findManyCompanies`,
`createOnePerson`)
3. If yes, it executes them directly against the query runners, skipping
schema generation entirely
4. If the query contains introspection, subscriptions, or core-only
resolvers, it falls through to the normal path
5. Even for mixed queries it can't fully handle, it sets
`skipWorkspaceSchemaCreation` to avoid building the schema when
unnecessary

The whole thing is gated behind the
`IS_DIRECT_GRAPHQL_EXECUTION_ENABLED` feature flag for safe incremental
rollout.

**Net effect**: dramatically lower memory footprint and faster response
times for the vast majority of workspace API calls.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2026-03-20 14:33:56 +00:00
EtienneandGitHub 9a850e2241 fix - handle invoice.paid webhook to recover unpaid subscriptions (#18770)
Fixes https://github.com/twentyhq/private-issues/issues/432

## Problem

When a user's invoice goes unpaid, Stripe moves their subscription to
`unpaid` status, and Twenty suspends the workspace. But if the user pays
that invoice while a new billing period has started, Stripe has already
generated a new **draft** invoice for that period. Since the draft isn't
finalized or paid, Stripe doesn't reactivate the subscription — the
workspace stays suspended indefinitely.

## What was missing

- No handler for the `invoice.paid` Stripe webhook event
- No mechanism to finalize draft invoices that accumulated during the
`unpaid` period
- No way to reset the workspace deletion countdown (`suspendedAt`) when
a user shows payment intent

## What was added

### 1. `StripeInvoiceService` — new Stripe SDK wrapper

- `listDraftInvoices(stripeSubscriptionId)` — lists all draft invoices
for a subscription
- `finalizeInvoice(invoiceId)` — finalizes a draft with `auto_advance:
true` so Stripe auto-charges it

### 2. `INVOICE_PAID` enum value

Added to `BillingWebhookEvent` so the controller can route it.

### 3. `processInvoicePaid()` in `BillingWebhookInvoiceService`

New private handler that:

- Fetches all draft invoices for the subscription
- Filters to only those whose `period_end` is in the past (already
overdue)
- Finalizes each one (with error handling per invoice to avoid blocking
the webhook)
- If the workspace is suspended, resets `suspendedAt` to now (buys time
before deletion)

### 4. Controller routing

`INVOICE_FINALIZED` and `INVOICE_PAID` are now grouped in the same
switch case, both calling `processStripeEvent(data, eventType)`, which
forks internally in the service.

## Expected recovery flow

User pays overdue invoice
→ Stripe fires invoice.paid
→ Handler finalizes past-due draft invoices (auto_advance: true)
→ Stripe auto-charges them
→ Subscription becomes active
→ customer.subscription.updated fires
→ Existing logic unsuspends workspace
→ suspendedAt refreshed (resets deletion countdown while payments
cascade)
2026-03-20 14:19:53 +00:00
Charles BochetandGitHub 91793ef930 fix: update workspace members state on profile onboarding completion (#18799)
## Summary

Fixes #18610

After completing the CreateProfile onboarding step,
`currentWorkspaceMembersState` (plural, used by `useActorFieldDisplay`
to resolve "Created by" names) was never updated with the new name —
only `currentWorkspaceMemberState` (singular) was. This caused "Created
by" fields to display "Untitled" for the remainder of the session.

**Root cause analysis:**

The issue reporter attributed the bug to empty
`core.user.firstName/lastName`, but `createdBy` display doesn't use
`core.user` at all. The actual flow:

1. Sign-up creates workspace member with empty names (copied from empty
`core.user`)
2. `GetCurrentUserDocument` loads `currentWorkspaceMembersState` with
empty names
3. CreateProfile step updates workspace member in DB +
`currentWorkspaceMemberState` (singular) ✓
4. `currentWorkspaceMembersState` (plural) is **never refreshed** — the
query is skipped once `currentUser` exists
5. `useActorFieldDisplay` looks up from the stale plural state → empty
name → "Untitled"

**Fix:** Update `currentWorkspaceMembersState` alongside
`currentWorkspaceMemberState` in the CreateProfile submit handler.
2026-03-20 14:02:47 +00:00
Charles BochetandGitHub 217adc8d17 fix: add missing LEFT JOIN for relation fields in groupBy orderByForRecords (#18798)
## Summary

Fixes "missing FROM-clause entry for table" SQL error when using
`orderByForRecords` with a relation field (e.g. `{ company: { name:
"AscNullsFirst" } }`) in a `groupBy` query.

### Root cause

This is a regression from #18005 (Feb 17). That PR correctly removed a
duplicate `applyOrderToBuilder()` call on the groupBy subquery (which
was conflicting with the `ROW_NUMBER() OVER (... ORDER BY ...)` window
function), but in doing so it also removed the LEFT JOINs that
`applyOrderToBuilder` was adding for relation fields.

After that change, ordering relied solely on `getOrderByRawSQL()` inside
`applyPartitionByToBuilder()`, which builds raw SQL for the window
function but never added the required JOINs. Scalar field ordering (e.g.
`name`, `position`) kept working since those don't need JOINs, but
relation field ordering (e.g. `company.name`) broke.

### Fix

- `getOrderByRawSQL` now returns `relationJoins` alongside the SQL
string so callers get the join info they need
- `applyPartitionByToBuilder` in `GroupByWithRecordsService` adds the
required LEFT JOINs before building the `ROW_NUMBER()` window function,
mirroring the pattern used by `applyOrderToBuilder` in the `findMany`
path

## Test plan

- [x] Manually tested locally with a GraphQL query matching the
customer's failing pattern (`orderByForRecords: [{ company: { name:
"AscNullsFirst" } }]`)
- [x] Added integration tests for ascending and descending relation
field ordering in `group-by-with-records-resolver.integration-spec.ts`
- [x] Typecheck passes
- [x] Lint passes
- [x] CI green
2026-03-20 14:02:35 +00:00
Thomas TrompetteandGitHub 698c15b8cd Avoid side panel tab overriden by show page (#18800)
Missing prop so layout are considered as the same
2026-03-20 13:56:02 +00:00
Charles BochetandGitHub 34b3158308 fix: backfill missing ids on SELECT/MULTI_SELECT field metadata options (#18797)
## Summary
- Some production workspaces have `SELECT` or `MULTI_SELECT`
fieldMetadata options that are missing the `id` property (e.g.
`[{"color":"yellow","label":"Draft","value":"DRAFT","position":0},
...]`).
- Adds a new upgrade command
`upgrade:1-20:backfill-select-field-option-ids` that queries all
SELECT/MULTI_SELECT fieldMetadata per workspace, detects options missing
an `id`, and backfills them with a UUID v4.
- The command is idempotent (no-op when all options already have ids),
supports `--dry-run`, and invalidates workspace caches after patching.
2026-03-20 13:08:55 +00:00
Charles BochetandGitHub 66be7c3ef4 fix: autogrow input sizing + surface nested error details in upgrade command (#18796)
## Summary

### Fix 1: Autogrow input unclickable when value is empty string
- Fixes the last name input on the Person record show page being
unclickable on regular screens

### Fix 2: Surface nested migration errors in add-missing-system-fields
command
- `AddMissingSystemFieldsToStandardObjectsCommand` calls
`workspaceMigrationRunnerService.run()` directly and was re-throwing
`WorkspaceMigrationRunnerException` without reading its nested `errors`
- Extracted `getNestedErrorMessages()` helper (reused by both
`isUniqueViolationError` and `enrichErrorMessage`)
- Both catch sites now call `enrichErrorMessage()` before re-throwing,
appending nested error details to the message

**Before:**
```
ERROR [UpgradeCommand] Error in workspace ...: Migration action 'create' for 'fieldMetadata' failed
ERROR [UpgradeCommand] undefined
```

**After:**
```
ERROR [UpgradeCommand] Error in workspace ...: Migration action 'create' for 'fieldMetadata' failed (metadata: duplicate key value violates unique constraint "...")
```

## Test plan
- [x] Lint passes
- [x] Typecheck passes
- [x] Verify upgrade command errors now include nested error details
- [x] Verify autogrow input is clickable when value is empty string
2026-03-20 12:22:27 +00:00
Félix MalfaitandGitHub 0a6b514898 fix: remove redundant cookie write that made tokenPair a session cookie (#18795)
## Summary

- Removes a redundant direct `cookieStorage.setItem('tokenPair', ...)`
call in `handleSetAuthTokens` that was overwriting the Jotai-managed
cookie (which has a 180-day expiry) with a session cookie (no expiry)
- This caused users to be logged out whenever their browser fully
closed, instead of staying authenticated for 180 days

## Root cause

In April 2025 (`a7e6564017`), a direct `cookieStorage.setItem` call was
added alongside `setTokenPair()` as a workaround because Recoil's
`onSet` effect fired too late for the Apollo client to read the token
synchronously.

In February 2026 (`674f4353cd`), `tokenPairState` was migrated from
Recoil to Jotai's `atomWithStorage`, which writes the cookie
**synchronously** with a 180-day `expires`. The old direct write was
left in place and now runs *after* the Jotai write, overwriting the
cookie without an `expires` — making it a session cookie.

## Test plan

- [ ] Log in to the app
- [ ] Inspect the `tokenPair` cookie in DevTools → Application → Cookies
- [ ] Verify the cookie has an expiration date ~180 days from now (not
"Session")
- [ ] Close and reopen the browser — confirm you remain logged in


Made with [Cursor](https://cursor.com)
2026-03-20 12:03:30 +01:00
Thomas TrompetteandGitHub ffb02a6878 Improve workflow metrics with faillure reason (#18768)
- split workflow errors into system and user errors
- add workspace id to attributes for debugging
2026-03-20 10:04:40 +00:00
3cbd9f2b5d i18n - translations (#18786)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-20 00:39:40 +01:00
Charles BochetandGitHub cee4cf6452 feat: migrate ConnectedAccount infrastructure entities to metadata schema (#18784)
## Summary

- Migrates 4 entities (`connectedAccount`, `messageChannel`,
`calendarChannel`, `messageFolder`) from per-workspace schemas to the
shared `core` metadata schema
- Introduces a `IS_CONNECTED_ACCOUNT_MIGRATED` feature flag to control
the migration: when enabled, reads come from core metadata and all
writes are dual-written to both workspace and core
- Extracts 12 enums from workspace entity files to `twenty-shared` for
reuse across frontend and backend
- Creates new TypeORM entities, metadata services, GraphQL
resolvers/DTOs, and exception interceptors per entity
- Each entity owns its own data access module
(`ConnectedAccountDataAccessModule`, `MessageChannelDataAccessModule`,
`CalendarChannelDataAccessModule`, `MessageFolderDataAccessModule`) — no
umbrella infrastructure module
- Adds a 1.20 upgrade command that backfills data from workspace schemas
to core (preserving UUIDs) and enables the feature flag
- Replaces direct repository access with data access service calls
across ~50 files in messaging, calendar, and connected-account modules
- Adds `lastSignedInAt` and `oidcTokenClaims` fields to the new
`ConnectedAccountEntity`
- Drops unused `lastSyncHistoryId` field from the migrated connected
account entity

## Test plan

- [x] Lint passes (`npx nx lint:diff-with-main twenty-server`)
- [x] Typecheck passes (`npx nx typecheck twenty-server`)
- [x] All unit tests pass (477 suites, 4267 tests, 0 failures)
- [ ] Manual test: verify messaging sync works with feature flag
disabled (existing behavior)
- [ ] Manual test: run upgrade command on a workspace, verify data
backfilled to core tables
- [ ] Manual test: verify messaging/calendar sync works with feature
flag enabled (dual-write path)
- [ ] Manual test: verify GraphQL metadata resolvers return correct data
when flag enabled
2026-03-20 00:34:58 +01:00
Abdul RahmanandGitHub cd594ce8bd Fix: Hide object from Opened section when it already has a workspace nav item (#18766)
Previously, the Opened section was always hidden for all workflow
objects. We now base the “in nav” check on the full set of object
metadata IDs that have a workspace nav item, instead of only active
non-system objects. So: if an object has a nav item (e.g. workflow
runs), it no longer appears under Opened; if it doesn’t, it can appear
there. The hook was simplified to only expose
`objectMetadataIdsInWorkspaceNav`, and the unused return value was
removed.
2026-03-19 21:57:11 +00:00
26139ee463 fix: stop event propagation when removing file in AI chat preview (#18779)
## Summary

When clicking the ✕ button on an uploaded file in the AI chatbot context
preview, the click event bubbled up to the `StyledClickableContainer`
parent, which triggered `handleClick` (opening the file preview modal)
instead of — or in addition to — calling `onRemove`.

**Root cause:** `StyledClickableContainer` has `onClick={handleClick}`
at the div level. The `AvatarOrIcon` (X button) `onClick={onRemove}`
callback didn't stop propagation, so the event continued to bubble and
opened the preview.

**Fix:** Wrap `onRemove` in a `handleRemove` callback that calls
`e.stopPropagation()` before invoking the original handler.

Fixes #18298

---------

Co-authored-by: victorjzq <zhiqiangjia@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-19 19:07:19 +00:00
8005b35b56 Fix relation connect where failing on mixed-case email and URL (#18605)
Related to issue #17711 
Follow-up to PR #17774  which fixed the frontend link normalization only
## Summary
- Normalize `primaryEmail` (lowercase) and `primaryLinkUrl` in relation
`connect.where` composite values
- Applied in both frontend spreadsheet import and backend
`DataArgProcessorService` to cover all entry points (UI import, GraphQL
API)

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-03-19 17:56:26 +00:00
Abdul RahmanandGitHub 02bfebccc2 Fix: cascade delete favorite folder children on backend (#18765) 2026-03-19 17:55:28 +00:00
Thomas TrompetteandGitHub b86e6189c0 Remove postition from Timeline Activities + fix workflow title placeholder (#18777)
- Position were not properly displayed because we never implemented a
display for this
- Untitled placeholder was not displayed anymore
<img width="359" height="117" alt="Capture d’écran 2026-03-19 à 17 11
25"
src="https://github.com/user-attachments/assets/64c90d81-8262-4176-ae25-804748e36b1e"
/>
2026-03-19 17:25:35 +00:00
a9f8a7e1fa Fix: prevent record navigation when clicking Remove from favorite in nav sidebar (#18760)
https://github.com/user-attachments/assets/96abf04d-726a-4225-846e-e5d701a583a2

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-19 14:29:12 +00:00
5526d2e5d0 Separate metadata and object record publisher (#18740)
- Separate both publishers
- Renaming
- Removal of the old SSE endpoint

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-19 13:24:38 +00:00
2743 changed files with 255329 additions and 82964 deletions
@@ -3,12 +3,13 @@ description: >
Starts a full Twenty instance (server, worker, database, redis) using Docker
Compose. The server is available at http://localhost:3000 for subsequent steps
in the caller's job.
Pulls the specified semver image tag from Docker Hub.
Accepts "latest" (pulls the latest Docker Hub image, checks out main) or a
semver tag (e.g., v0.40.0).
Designed to be consumed from external repositories (e.g., twenty-app).
inputs:
twenty-version:
description: 'Twenty Docker Hub image tag as semver (e.g., v0.40.0, v1.0.0).'
description: 'Twenty Docker Hub image tag — either "latest" or a semver tag (e.g., v0.40.0).'
required: true
twenty-repository:
description: 'Twenty repository to checkout docker compose files from.'
@@ -30,12 +31,19 @@ outputs:
runs:
using: 'composite'
steps:
- name: Validate version
- name: Resolve version
id: resolve
shell: bash
run: |
VERSION="${{ inputs.twenty-version }}"
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::twenty-version must be a semver tag (e.g., v0.40.0). Got: '$VERSION'"
if [ "$VERSION" = "latest" ]; then
echo "docker-tag=latest" >> "$GITHUB_OUTPUT"
echo "git-ref=main" >> "$GITHUB_OUTPUT"
elif echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "docker-tag=$VERSION" >> "$GITHUB_OUTPUT"
echo "git-ref=$VERSION" >> "$GITHUB_OUTPUT"
else
echo "::error::twenty-version must be \"latest\" or a semver tag (e.g., v0.40.0). Got: '$VERSION'"
exit 1
fi
@@ -43,7 +51,7 @@ runs:
uses: actions/checkout@v4
with:
repository: ${{ inputs.twenty-repository }}
ref: ${{ inputs.twenty-version }}
ref: ${{ steps.resolve.outputs.git-ref }}
token: ${{ inputs.github-token }}
sparse-checkout: |
packages/twenty-docker
@@ -56,7 +64,7 @@ runs:
run: |
cp .env.example .env
echo "" >> .env
echo "TAG=${{ inputs.twenty-version }}" >> .env
echo "TAG=${{ steps.resolve.outputs.docker-tag }}" >> .env
echo "APP_SECRET=replace_me_with_a_random_string" >> .env
echo "SERVER_URL=http://localhost:3000" >> .env
+3
View File
@@ -10,6 +10,9 @@ packages:
'twenty-sdk':
access: $all
publish: $all
'twenty-client-sdk':
access: $all
publish: $all
'create-twenty-app':
access: $all
publish: $all
+68
View File
@@ -0,0 +1,68 @@
name: AI Catalog Sync
on:
schedule:
- cron: '0 6 * * *' # Daily at 6 AM UTC
workflow_dispatch: # Allow manual trigger
permissions:
contents: write
pull-requests: write
jobs:
sync-catalog:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
NODE_OPTIONS: '--max-old-space-size=4096'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-shared
- name: Run catalog sync
run: npx nx run twenty-server:ts-node-no-deps-transpile-only -- ./scripts/ai-sync-models-dev.ts
- name: Check for changes
id: changes
run: |
if git diff --quiet packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-providers.json; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Create pull request
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: sync AI model catalog from models.dev'
title: 'chore: sync AI model catalog from models.dev'
body: |
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.
branch: chore/ai-catalog-sync
base: main
labels: ai, automated
delete-branch: true
- name: Trigger automerge
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: automated-pr-ready
@@ -143,7 +143,6 @@ jobs:
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Seed current branch database with test data
run: |
@@ -296,7 +295,6 @@ jobs:
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Seed main branch database with test data
run: |
+27 -9
View File
@@ -21,10 +21,12 @@ jobs:
files: |
packages/create-twenty-app/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
packages/twenty-server/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
!packages/twenty-server/package.json
create-app-e2e:
@@ -51,6 +53,8 @@ jobs:
image: redis
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
@@ -64,12 +68,13 @@ jobs:
run: |
CI_VERSION="0.0.0-ci.$(date +%s)"
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
npx nx run-many -t set-local-version -p twenty-sdk create-twenty-app --releaseVersion=$CI_VERSION
npx nx run-many -t set-local-version -p $PUBLISHABLE_PACKAGES --releaseVersion=$CI_VERSION
- name: Build packages
run: |
npx nx build twenty-sdk
npx nx build create-twenty-app
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx build $pkg
done
- name: Install and start Verdaccio
run: |
@@ -86,11 +91,13 @@ jobs:
- name: Publish packages to local registry
run: |
npm set //localhost:4873/:_authToken "ci-auth-token"
yarn config set npmRegistryServer http://localhost:4873
yarn config set unsafeHttpWhitelist --json '["localhost"]'
yarn config set npmAuthToken ci-auth-token
for pkg in twenty-sdk create-twenty-app; do
for pkg in $PUBLISHABLE_PACKAGES; do
cd packages/$pkg
npm publish --registry http://localhost:4873 --tag ci
yarn npm publish --tag ci
cd ../..
done
@@ -153,11 +160,15 @@ jobs:
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --token $SEED_API_KEY --url http://localhost:3000
- name: Build scaffolded app
- name: Deploy scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty build
test -d .twenty/output
npx --no-install twenty deploy
- name: Install scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty install
- name: Execute hello-world logic function
run: |
@@ -166,6 +177,13 @@ jobs:
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
- name: Execute create-hello-world-company logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-hello-world-company)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q "Created company"
- name: Run scaffolded app integration test
env:
TWENTY_API_URL: http://localhost:3000
+5 -6
View File
@@ -25,7 +25,7 @@ jobs:
packages/twenty-server/**
packages/twenty-front/src/generated/**
packages/twenty-front/src/generated-metadata/**
packages/twenty-sdk/src/clients/generated/metadata/**
packages/twenty-client-sdk/**
packages/twenty-emails/**
packages/twenty-shared/**
@@ -122,7 +122,6 @@ jobs:
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Worker / Run
run: |
timeout 30s npx nx run twenty-server:worker || exit_code=$?
@@ -177,14 +176,14 @@ jobs:
HAS_ERRORS=true
fi
npx nx run twenty-sdk:generate-metadata-client
npx nx run twenty-client-sdk:generate-metadata-client
if ! git diff --quiet -- packages/twenty-sdk/src/clients/generated/metadata; then
echo "::error::SDK metadata client changes detected. Please run 'npx nx run twenty-sdk:generate-metadata-client' and commit the changes."
if ! git diff --quiet -- packages/twenty-client-sdk/src/metadata/generated; then
echo "::error::SDK metadata client changes detected. Please run 'npx nx run twenty-client-sdk:generate-metadata-client' and commit the changes."
echo ""
echo "The following SDK metadata client changes were detected:"
echo "==================================================="
git diff -- packages/twenty-sdk/src/clients/generated/metadata
git diff -- packages/twenty-client-sdk/src/metadata/generated
echo "==================================================="
echo ""
HAS_ERRORS=true
+58 -5
View File
@@ -1,4 +1,4 @@
name: CI Docker Compose
name: CI Docker
permissions:
contents: read
@@ -19,7 +19,7 @@ jobs:
files: |
packages/twenty-docker/**
docker-compose.yml
test:
test-compose:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
@@ -30,10 +30,10 @@ jobs:
- name: Run compose
run: |
echo "Patching docker-compose.yml..."
# change image to localbuild using yq
yq eval 'del(.services.server.image)' -i docker-compose.yml
yq eval '.services.server.build.context = "../../"' -i docker-compose.yml
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i docker-compose.yml
yq eval '.services.server.build.target = "twenty"' -i docker-compose.yml
yq eval '.services.server.restart = "no"' -i docker-compose.yml
echo "Setting up .env file..."
@@ -89,11 +89,64 @@ jobs:
echo "Still waiting for server... (${count}/300s)"
done
working-directory: ./packages/twenty-docker/
ci-test-docker-compose-status-check:
test-app-dev:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create frontend placeholder
run: |
mkdir -p packages/twenty-front/build
echo '<html><body>CI placeholder</body></html>' > packages/twenty-front/build/index.html
- name: Build app-dev image
run: |
docker build \
--target twenty-app-dev \
-f packages/twenty-docker/twenty/Dockerfile \
-t twenty-app-dev-ci \
.
- name: Start container
run: |
docker run -d --name twenty-app-dev \
-p 3000:3000 \
twenty-app-dev-ci
docker logs twenty-app-dev -f &
- name: Wait for server health
run: |
echo "Waiting for twenty-app-dev to become healthy..."
count=0
while true; do
status=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/healthz 2>/dev/null || echo "000")
if [ "$status" = "200" ]; then
echo "Server is healthy!"
curl -s http://localhost:3000/healthz
break
fi
container_status=$(docker inspect --format='{{.State.Status}}' twenty-app-dev 2>/dev/null || echo "unknown")
if [ "$container_status" = "exited" ]; then
echo "Container exited unexpectedly"
docker logs twenty-app-dev
exit 1
fi
count=$((count+1))
if [ $count -gt 300 ]; then
echo "Server did not become healthy within 5 minutes"
docker logs twenty-app-dev
exit 1
fi
echo "Still waiting... (${count}/300s) [HTTP ${status}]"
sleep 1
done
ci-test-docker-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, test]
needs: [changed-files-check, test-compose, test-app-dev]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
+112
View File
@@ -0,0 +1,112 @@
name: CI UI
on:
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
yarn.lock
packages/twenty-ui/**
packages/twenty-shared/**
ui-task:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run ${{ matrix.task }}
run: npx nx ${{ matrix.task }} twenty-ui
ui-sb-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build storybook
run: npx nx storybook:build twenty-ui
- name: Upload storybook build
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
retention-days: 1
ui-sb-test:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: ui-sb-build
env:
STORYBOOK_URL: http://localhost:6007
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-shared
- name: Download storybook build
uses: actions/download-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
- name: Install Playwright
run: |
cd packages/twenty-ui
npx playwright install
- name: Serve storybook & run tests
run: |
npx http-server packages/twenty-ui/storybook-static --port 6007 --silent &
timeout 30 bash -c 'until curl -sf http://localhost:6007 > /dev/null 2>&1; do sleep 1; done'
npx nx storybook:test twenty-ui
ci-ui-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs:
[
changed-files-check,
ui-task,
ui-sb-build,
ui-sb-test,
]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+8
View File
@@ -150,3 +150,11 @@ jobs:
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+8
View File
@@ -138,3 +138,11 @@ jobs:
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: steps.compile_translations.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+8
View File
@@ -102,3 +102,11 @@ jobs:
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
@@ -24,10 +24,12 @@ jobs:
yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
echo "Adding SIGN_IN_PREFILLED environment variable to server service..."
yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml
@@ -0,0 +1,144 @@
name: Visual Regression Dispatch
# Uses workflow_run to dispatch visual regression to ci-privileged.
# This runs in the context of the base repo (not the fork), so it has
# access to secrets — making it work for external contributor PRs.
on:
workflow_run:
workflows: ['CI Front', 'CI UI']
types: [completed]
permissions:
actions: read
pull-requests: read
jobs:
dispatch:
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Determine project and artifact name
id: project
uses: actions/github-script@v7
with:
script: |
const workflowName = context.payload.workflow_run.name;
if (workflowName === 'CI Front') {
core.setOutput('project', 'twenty-front');
core.setOutput('artifact_name', 'storybook-static');
core.setOutput('tarball_name', 'storybook-twenty-front-tarball');
core.setOutput('tarball_file', 'storybook-twenty-front.tar.gz');
} else if (workflowName === 'CI UI') {
core.setOutput('project', 'twenty-ui');
core.setOutput('artifact_name', 'storybook-twenty-ui');
core.setOutput('tarball_name', 'storybook-twenty-ui-tarball');
core.setOutput('tarball_file', 'storybook-twenty-ui.tar.gz');
} else {
core.setFailed(`Unexpected workflow: ${workflowName}`);
}
- name: Check if storybook artifact exists
id: check-artifact
uses: actions/github-script@v7
with:
script: |
const artifactName = '${{ steps.project.outputs.artifact_name }}';
const runId = context.payload.workflow_run.id;
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: runId,
});
const found = artifacts.artifacts.some(a => a.name === artifactName);
core.setOutput('exists', found ? 'true' : 'false');
if (!found) {
core.info(`Artifact "${artifactName}" not found in run ${runId} — storybook build was likely skipped`);
}
- name: Get PR number
if: steps.check-artifact.outputs.exists == 'true'
id: pr-info
uses: actions/github-script@v7
with:
script: |
const headBranch = context.payload.workflow_run.head_branch;
const headRepo = context.payload.workflow_run.head_repository;
// workflow_run.pull_requests is empty for fork PRs,
// so fall back to searching by head label (owner:branch)
let pullRequests = context.payload.workflow_run.pull_requests;
let prNumber;
if (pullRequests && pullRequests.length > 0) {
prNumber = pullRequests[0].number;
} else {
const headLabel = `${headRepo.owner.login}:${headBranch}`;
core.info(`pull_requests is empty (likely a fork PR), searching by head label: ${headLabel}`);
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: headLabel,
per_page: 1,
});
if (prs.length > 0) {
prNumber = prs[0].number;
}
}
if (!prNumber) {
core.info('No pull request found for this workflow run — skipping');
core.setOutput('has_pr', 'false');
return;
}
core.setOutput('pr_number', prNumber);
core.setOutput('has_pr', 'true');
core.info(`PR #${prNumber}`);
- name: Download storybook artifact from triggering run
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: actions/download-artifact@v4
with:
name: ${{ steps.project.outputs.artifact_name }}
path: storybook-static
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Package storybook
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
run: tar -czf /tmp/${{ steps.project.outputs.tarball_file }} -C storybook-static .
- name: Upload storybook tarball
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: actions/upload-artifact@v4
with:
name: ${{ steps.project.outputs.tarball_name }}
path: /tmp/${{ steps.project.outputs.tarball_file }}
retention-days: 1
- name: Dispatch to ci-privileged
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: visual-regression
client-payload: >-
{
"pr_number": "${{ steps.pr-info.outputs.pr_number }}",
"run_id": "${{ github.run_id }}",
"repo": "${{ github.repository }}",
"project": "${{ steps.project.outputs.project }}",
"branch": "${{ github.event.workflow_run.head_branch }}",
"commit": "${{ github.event.workflow_run.head_sha }}"
}
+1
View File
@@ -1,6 +1,7 @@
**/**/.env
.DS_Store
/.idea
.claude/settings.json
**/**/node_modules/
.cache
+2 -2
View File
@@ -2,8 +2,8 @@
"mcpServers": {
"postgres": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "${PG_DATABASE_URL}"],
"command": "bash",
"args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""],
"env": {}
},
"playwright": {
+940
View File
File diff suppressed because one or more lines are too long
-942
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,4 +6,4 @@ enableInlineHunks: true
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.2.cjs
yarnPath: .yarn/releases/yarn-4.13.0.cjs
+11
View File
@@ -80,6 +80,17 @@ npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/mi
npx nx run twenty-server:command workspace:sync-metadata
```
### Database Inspection (Postgres MCP)
A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
- Inspect workspace data, metadata, and object definitions while developing
- Verify migration results (columns, types, constraints) after running migrations
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
- Inspect metadata tables to debug GraphQL schema generation or `workspace:sync-metadata` issues
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
### GraphQL
```bash
# Generate GraphQL types (run after schema changes)
+5 -2
View File
@@ -175,7 +175,7 @@
},
"license": "AGPL-3.0",
"name": "twenty",
"packageManager": "yarn@4.9.2",
"packageManager": "yarn@4.13.0",
"resolutions": {
"graphql": "16.8.1",
"type-fest": "4.10.1",
@@ -203,14 +203,17 @@
"packages/twenty-utils",
"packages/twenty-zapier",
"packages/twenty-website",
"packages/twenty-website-new",
"packages/twenty-docs",
"packages/twenty-e2e-testing",
"packages/twenty-shared",
"packages/twenty-sdk",
"packages/twenty-client-sdk",
"packages/twenty-apps",
"packages/twenty-cli",
"packages/create-twenty-app",
"packages/twenty-oxlint-rules"
"packages/twenty-oxlint-rules",
"packages/twenty-companion"
]
},
"prettier": {
+5 -4
View File
@@ -41,9 +41,10 @@ cd my-twenty-app
# Or do it manually:
yarn twenty server start # Start local Twenty server
yarn twenty remote add --local # Authenticate via OAuth
yarn twenty remote add http://localhost:2020 --as local # Authenticate via OAuth
# Start dev mode: watches, builds, and syncs local changes to your workspace
# (also auto-generates typed CoreApiClient — MetadataApiClient ships pre-built — both available via `twenty-client-sdk`)
yarn twenty dev
# Watch your application's function logs
@@ -124,10 +125,10 @@ The server is pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple
## Next steps
- Run `yarn twenty help` to see all available commands.
- Use `yarn twenty remote add --local` to authenticate with your Twenty workspace via OAuth.
- Use `yarn twenty remote add <url>` to authenticate with your Twenty workspace via OAuth.
- Explore the generated project and add your first entity with `yarn twenty add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
- Use `yarn twenty dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
- `CoreApiClient` (for workspace data via `/graphql`) is auto-generated by `yarn twenty dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/clients'`.
- `CoreApiClient` is auto-generated by `yarn twenty dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient } from 'twenty-client-sdk/core'` and `import { MetadataApiClient } from 'twenty-client-sdk/metadata'`.
## Build and publish your application
@@ -168,7 +169,7 @@ Our team reviews contributions for quality, security, and reusability before mer
## Troubleshooting
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
- Auth not working: make sure you're logged in to Twenty in the browser first, then run `yarn twenty remote add --local`.
- Auth not working: make sure you're logged in to Twenty in the browser first, then run `yarn twenty remote add <url>`.
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
## Contributing
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "0.8.0-canary.1",
"version": "0.8.0-canary.5",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -36,6 +36,7 @@
"lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
"twenty-sdk": "workspace:*",
"uuid": "^13.0.0"
},
"devDependencies": {
@@ -45,7 +46,6 @@
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^20.0.0",
"twenty-sdk": "workspace:*",
"twenty-shared": "workspace:*",
"typescript": "^5.9.2",
"vite": "^7.0.0",
@@ -1,17 +1,18 @@
import { copyBaseApplicationProject } from '@/utils/app-template';
import { convertToLabel } from '@/utils/convert-to-label';
import { install } from '@/utils/install';
import {
type LocalInstanceResult,
setupLocalInstance,
} from '@/utils/setup-local-instance';
import { tryGitInit } from '@/utils/try-git-init';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import kebabCase from 'lodash.kebabcase';
import { execSync } from 'node:child_process';
import * as path from 'path';
import { basename } from 'path';
import {
authLoginOAuth,
serverStart,
type ServerStartResult,
} from 'twenty-sdk/cli';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -32,10 +33,10 @@ type CreateAppOptions = {
export class CreateAppCommand {
async execute(options: CreateAppOptions = {}): Promise<void> {
try {
const { appName, appDisplayName, appDirectory, appDescription } =
await this.getAppInfos(options);
const { appName, appDisplayName, appDirectory, appDescription } =
await this.getAppInfos(options);
try {
const exampleOptions = this.resolveExampleOptions(
options.mode ?? 'exhaustive',
);
@@ -46,7 +47,6 @@ export class CreateAppCommand {
await fs.ensureDir(appDirectory);
console.log(chalk.gray(' Scaffolding project files...'));
await copyBaseApplicationProject({
appName,
appDisplayName,
@@ -55,27 +55,29 @@ export class CreateAppCommand {
exampleOptions,
});
console.log(chalk.gray(' Installing dependencies...'));
await install(appDirectory);
console.log(chalk.gray(' Initializing git repository...'));
await tryGitInit(appDirectory);
let localResult: LocalInstanceResult = { running: false };
let serverResult: ServerStartResult | undefined;
if (!options.skipLocalInstance) {
// Auto-detect a running server first
localResult = await setupLocalInstance(appDirectory);
const startResult = await serverStart({
onProgress: (message: string) => console.log(chalk.gray(message)),
});
if (localResult.running && localResult.serverUrl) {
await this.connectToLocal(appDirectory, localResult.serverUrl);
if (startResult.success) {
serverResult = startResult.data;
await this.connectToLocal(serverResult.url);
} else {
console.log(chalk.yellow(`\n${startResult.error.message}`));
}
}
this.logSuccess(appDirectory, localResult);
this.logSuccess(appDirectory, serverResult);
} catch (error) {
console.error(
chalk.red('Initialization failed:'),
chalk.red('\nCreate application failed:'),
error instanceof Error ? error.message : error,
);
process.exit(1);
@@ -193,29 +195,30 @@ export class CreateAppCommand {
appDirectory: string;
appName: string;
}): void {
console.log(chalk.blue('Creating Twenty Application'));
console.log(chalk.gray(` Directory: ${appDirectory}`));
console.log(chalk.gray(` Name: ${appName}`));
console.log('');
console.log(
chalk.blue('\n', 'Creating Twenty Application\n'),
chalk.gray(`- Directory: ${appDirectory}\n`, `- Name: ${appName}\n`),
);
}
private async connectToLocal(
appDirectory: string,
serverUrl: string,
): Promise<void> {
private async connectToLocal(serverUrl: string): Promise<void> {
try {
execSync(
`npx nx run twenty-sdk:start -- remote add ${serverUrl} --as local`,
{
cwd: appDirectory,
stdio: 'inherit',
},
);
console.log(chalk.green('Authenticated with local Twenty instance.'));
const result = await authLoginOAuth({
apiUrl: serverUrl,
remote: 'local',
});
if (!result.success) {
console.log(
chalk.yellow(
'Authentication skipped. Run `yarn twenty remote add --local` manually.',
),
);
}
} catch {
console.log(
chalk.yellow(
'Authentication skipped. Run `npx nx run twenty-sdk:start -- remote add --local` manually.',
'Authentication skipped. Run `yarn twenty remote add --local` manually.',
),
);
}
@@ -223,25 +226,23 @@ export class CreateAppCommand {
private logSuccess(
appDirectory: string,
localResult: LocalInstanceResult,
serverResult?: ServerStartResult,
): void {
const dirName = appDirectory.split('/').reverse()[0] ?? '';
const dirName = basename(appDirectory);
console.log(chalk.green('Application created!'));
console.log('');
console.log(chalk.blue('Next steps:'));
console.log(chalk.gray(` cd ${dirName}`));
console.log(chalk.blue('\nApplication created. Next steps:'));
console.log(chalk.gray(`- cd ${dirName}`));
if (!localResult.running) {
if (!serverResult) {
console.log(
chalk.gray(
' yarn twenty remote add --local # Authenticate with Twenty',
'- yarn twenty remote add --local # Authenticate with Twenty',
),
);
}
console.log(
chalk.gray(' yarn twenty dev # Start dev mode'),
chalk.gray('- yarn twenty dev # Start dev mode'),
);
}
}
@@ -106,6 +106,9 @@ describe('copyBaseApplicationProject', () => {
expect(packageJson.devDependencies['twenty-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.devDependencies['twenty-client-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.scripts['twenty']).toBe('twenty');
});
@@ -362,11 +365,21 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'create-hello-world-company.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'front-components', 'hello-world.tsx'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'page-layouts', 'example-record-page-layout.ts'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'views', 'example-view.ts')),
).toBe(true);
@@ -448,11 +461,21 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'create-hello-world-company.ts'),
),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, 'front-components', 'hello-world.tsx'),
),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, 'page-layouts', 'example-record-page-layout.ts'),
),
).toBe(false);
expect(
await fs.pathExists(join(srcPath, 'views', 'example-view.ts')),
).toBe(false);
@@ -480,7 +503,7 @@ describe('copyBaseApplicationProject', () => {
});
describe('selective examples', () => {
it('should create only front component when only that option is enabled', async () => {
it('should create front component and page layout when only front component option is enabled', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
@@ -506,6 +529,11 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'front-components', 'hello-world.tsx'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'page-layouts', 'example-record-page-layout.ts'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
).toBe(false);
@@ -545,6 +573,11 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'create-hello-world-company.ts'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
).toBe(false);
@@ -47,15 +47,17 @@ describe('scaffoldIntegrationTest', () => {
const content = await fs.readFile(testPath, 'utf8');
expect(content).toContain(
"import { appBuild, appUninstall } from 'twenty-sdk/cli'",
"import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli'",
);
expect(content).toContain(
"import { MetadataApiClient } from 'twenty-sdk/clients'",
"import { MetadataApiClient } from 'twenty-client-sdk/metadata'",
);
expect(content).toContain(
"import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config'",
);
expect(content).toContain('appBuild');
expect(content).toContain('appDeploy');
expect(content).toContain('appInstall');
expect(content).toContain('appUninstall');
expect(content).toContain('new MetadataApiClient()');
expect(content).toContain('findManyApplications');
@@ -136,7 +138,7 @@ describe('scaffoldIntegrationTest', () => {
expect(content).toContain('yarn install --immutable');
expect(content).toContain('yarn test');
expect(content).toContain('TWENTY_API_URL');
expect(content).toContain('TWENTY_TEST_API_KEY');
expect(content).toContain('TWENTY_API_KEY');
});
});
@@ -34,6 +34,8 @@ export const copyBaseApplicationProject = async ({
await createGitignore(appDirectory);
await createNvmrc(appDirectory);
await createPublicAssetDirectory(appDirectory);
const sourceFolderPath = join(appDirectory, SRC_FOLDER);
@@ -69,6 +71,12 @@ export const copyBaseApplicationProject = async ({
fileFolder: 'logic-functions',
fileName: 'hello-world.ts',
});
await createCreateCompanyFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
fileName: 'create-hello-world-company.ts',
});
}
if (exampleOptions.includeExampleFrontComponent) {
@@ -77,6 +85,12 @@ export const copyBaseApplicationProject = async ({
fileFolder: 'front-components',
fileName: 'hello-world.tsx',
});
await createExamplePageLayout({
appDirectory: sourceFolderPath,
fileFolder: 'page-layouts',
fileName: 'example-record-page-layout.ts',
});
}
if (exampleOptions.includeExampleView) {
@@ -186,6 +200,10 @@ yarn-error.log*
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
};
const createNvmrc = async (appDirectory: string) => {
await fs.writeFile(join(appDirectory, '.nvmrc'), '24.5.0\n');
};
const createDefaultRoleConfig = async ({
displayName,
appDirectory,
@@ -230,19 +248,59 @@ const createDefaultFrontComponent = async ({
}) => {
const universalIdentifier = v4();
const content = `import { defineFrontComponent } from 'twenty-sdk';
const content = `import { useEffect, useState } from 'react';
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk';
export const HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'${universalIdentifier}';
export const HelloWorld = () => {
const client = new CoreApiClient();
const [data, setData] = useState<
Pick<CoreSchema.Company, 'name' | 'id'> | undefined
>(undefined);
useEffect(() => {
const fetchData = async () => {
const response = await client.query({
company: {
name: true,
id: true,
__args: {
filter: {
position: {
eq: 1,
},
},
},
},
});
setData(response.company);
};
fetchData();
}, []);
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Hello, World!</h1>
<p>This is your first front component.</p>
{data ? (
<div>
<p>Company name: {data.name}</p>
<p>Company id: {data.id}</p>
</div>
) : (
<p>Company not found</p>
)}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: '${universalIdentifier}',
universalIdentifier: HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'hello-world-front-component',
description: 'A sample front component',
component: HelloWorld,
@@ -253,6 +311,56 @@ export default defineFrontComponent({
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createExamplePageLayout = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const pageLayoutUniversalIdentifier = v4();
const tabUniversalIdentifier = v4();
const widgetUniversalIdentifier = v4();
const content = `import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/front-components/hello-world';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
export default definePageLayout({
universalIdentifier: '${pageLayoutUniversalIdentifier}',
name: 'Example Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '${tabUniversalIdentifier}',
title: 'Hello World',
position: 50,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: '${widgetUniversalIdentifier}',
title: 'Hello World',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultFunction = async ({
appDirectory,
fileFolder,
@@ -288,6 +396,58 @@ export default defineLogicFunction({
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createCreateCompanyFunction = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk';
const handler = async (): Promise<{ message: string }> => {
const client = new CoreApiClient();
const { createCompany } = await client.mutation({
createCompany: {
__args: {
data: {
name: 'Hello World',
},
},
id: true,
name: true,
},
});
return {
message: \`Created company "\${createCompany?.name}" with id \${createCompany?.id}\`,
};
};
export default defineLogicFunction({
universalIdentifier: '${universalIdentifier}',
name: 'create-hello-world-company',
description: 'Creates a company called Hello World',
timeoutSeconds: 5,
handler,
httpRouteTriggerSettings: {
path: '/create-hello-world-company',
httpMethod: 'POST',
isAuthRequired: true,
},
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultPreInstallFunction = async ({
appDirectory,
fileFolder,
@@ -615,6 +775,7 @@ const createPackageJson = async ({
'react-dom': '^19.0.0',
oxlint: '^0.16.0',
'twenty-sdk': createTwentyAppPackageJson.version,
'twenty-client-sdk': createTwentyAppPackageJson.version,
};
if (includeExampleIntegrationTest) {
@@ -5,6 +5,7 @@ import { exec } from 'child_process';
const execPromise = promisify(exec);
export const install = async (root: string) => {
console.log(chalk.gray('Installing yarn dependencies...'));
try {
await execPromise('corepack enable', { cwd: root });
} catch (error: any) {
@@ -14,6 +15,6 @@ export const install = async (root: string) => {
try {
await execPromise('yarn install', { cwd: root });
} catch (error: any) {
console.error(chalk.red('yarn install failed:'), error.stdout);
console.warn(chalk.yellow('yarn install failed:'), error.stdout);
}
};
@@ -1,101 +0,0 @@
import chalk from 'chalk';
import { execSync } from 'node:child_process';
import { platform } from 'node:os';
const DEFAULT_PORT = 2020;
// Minimal health check — the full implementation lives in twenty-sdk
const isServerReady = async (port: number): Promise<boolean> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(`http://localhost:${port}/healthz`, {
signal: controller.signal,
});
const body = await response.json();
return body.status === 'ok';
} catch {
return false;
} finally {
clearTimeout(timeoutId);
}
};
export type LocalInstanceResult = {
running: boolean;
serverUrl?: string;
};
export const setupLocalInstance = async (
appDirectory: string,
): Promise<LocalInstanceResult> => {
console.log('');
console.log(chalk.blue('Setting up local Twenty instance...'));
if (await isServerReady(DEFAULT_PORT)) {
const serverUrl = `http://localhost:${DEFAULT_PORT}`;
console.log(chalk.green(`Twenty server detected on ${serverUrl}.`));
return { running: true, serverUrl };
}
// Delegate to `twenty server start` from the scaffolded app
console.log(chalk.gray('Starting local Twenty server...'));
try {
execSync('yarn twenty server start', {
cwd: appDirectory,
stdio: 'inherit',
});
} catch {
console.log(
chalk.yellow(
'Failed to start Twenty server. Run `yarn twenty server start` manually.',
),
);
return { running: false };
}
console.log(chalk.gray('Waiting for Twenty to be ready...'));
const startTime = Date.now();
const timeoutMs = 180 * 1000;
while (Date.now() - startTime < timeoutMs) {
if (await isServerReady(DEFAULT_PORT)) {
const serverUrl = `http://localhost:${DEFAULT_PORT}`;
console.log(chalk.green(`Twenty server is running on ${serverUrl}.`));
console.log(
chalk.gray(
'Workspace ready — login with tim@apple.dev / tim@apple.dev',
),
);
const openCommand = platform() === 'darwin' ? 'open' : 'xdg-open';
try {
execSync(`${openCommand} ${serverUrl}`, { stdio: 'ignore' });
} catch {
// Ignore if browser can't be opened
}
return { running: true, serverUrl };
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
console.log(
chalk.yellow(
'Twenty server did not become healthy in time. Check: yarn twenty server logs',
),
);
return { running: false };
};
@@ -149,18 +149,17 @@ const createIntegrationTest = async ({
fileName: string;
}) => {
const content = `import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/clients';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
describe('App installation', () => {
let appInstalled = false;
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(\`[build] \${message}\`),
});
@@ -170,14 +169,27 @@ describe('App installation', () => {
);
}
appInstalled = true;
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(\`[deploy] \${message}\`),
});
if (!deployResult.success) {
throw new Error(
\`Deploy failed: \${deployResult.error?.message ?? 'Unknown error'}\`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
\`Install failed: \${installResult.error?.message ?? 'Unknown error'}\`,
);
}
});
afterAll(async () => {
if (!appInstalled) {
return;
}
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
@@ -257,7 +269,7 @@ jobs:
run: yarn test
env:
TWENTY_API_URL: \${{ steps.twenty.outputs.server-url }}
TWENTY_TEST_API_KEY: \${{ steps.twenty.outputs.access-token }}
TWENTY_API_KEY: \${{ steps.twenty.outputs.access-token }}
`;
const workflowDir = join(appDirectory, '.github', 'workflows');
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote add --local
yarn twenty remote add http://localhost:2020 --as local
```
Then, start development mode to sync your app and watch for changes:
@@ -22,7 +22,7 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote add --local # Authenticate with Twenty
yarn twenty remote add http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
@@ -3,7 +3,7 @@ import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineField({
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
universalIdentifier: '8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e',
universalIdentifier: 'b602dbd9-e511-49ce-b6d3-b697218dc69c',
type: FieldType.SELECT,
name: 'category',
label: 'Category',
@@ -3,7 +3,7 @@ import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineField({
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
universalIdentifier: '7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d',
universalIdentifier: '7b57bd63-5a4c-46ca-9d52-42c8f02d1df6',
type: FieldType.NUMBER,
name: 'priority',
label: 'Priority',
@@ -25,8 +25,8 @@ export default defineObject({
{
universalIdentifier: 'd3a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
type: FieldType.ADDRESS,
label: 'Address',
name: 'address',
label: 'Mailing Address',
name: 'mailingAddress',
icon: 'IconHome',
},
],
@@ -0,0 +1,42 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
env:
TWENTY_VERSION: latest
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- name: Install dependencies
run: yarn install --immutable
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }}
+1
View File
@@ -4,6 +4,7 @@
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/app-seeds/rich-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
+2 -2
View File
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote add --local
yarn twenty remote add http://localhost:2020 --as local
```
Then, start development mode to sync your app and watch for changes:
@@ -22,7 +22,7 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote add --local # Authenticate with Twenty
yarn twenty remote add http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
@@ -20,7 +20,8 @@
"@types/react": "^18.2.0",
"oxlint": "^0.16.0",
"react": "^18.2.0",
"twenty-sdk": "0.6.4",
"twenty-client-sdk": "portal:../../twenty-client-sdk",
"twenty-sdk": "portal:../../twenty-sdk",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
@@ -1,16 +1,15 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/clients';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
describe('App installation', () => {
let appInstalled = false;
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(`[build] ${message}`),
});
@@ -20,14 +19,27 @@ describe('App installation', () => {
);
}
appInstalled = true;
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(`[deploy] ${message}`),
});
if (!deployResult.success) {
throw new Error(
`Deploy failed: ${deployResult.error?.message ?? 'Unknown error'}`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
`Install failed: ${installResult.error?.message ?? 'Unknown error'}`,
);
}
});
afterAll(async () => {
if (!appInstalled) {
return;
}
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
@@ -0,0 +1,13 @@
import { defineAgent } from 'twenty-sdk';
export const EXAMPLE_AGENT_UNIVERSAL_IDENTIFIER =
'110bebc2-f116-46b6-a35d-61e91c3c0a43';
export default defineAgent({
universalIdentifier: EXAMPLE_AGENT_UNIVERSAL_IDENTIFIER,
name: 'example-agent',
label: 'Example Agent',
description: 'A sample AI agent for your application',
icon: 'IconRobot',
prompt: 'You are a helpful assistant. Help users with their questions and tasks.',
});
@@ -2,7 +2,7 @@ import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'6563e091-9f5b-4026-a3ea-7e3b3d09e218';
'bb1decf6-dee5-43ef-b881-9799f97b02a8';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
@@ -3,7 +3,7 @@ import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object'
export default defineField({
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
universalIdentifier: '770d32c2-cf12-4ab2-b66d-73f92dc239b5',
universalIdentifier: 'be08a7c6-2586-4d91-9fa7-0a44e6eae30c',
type: FieldType.NUMBER,
name: 'priority',
label: 'Priority',
@@ -1,16 +1,56 @@
import { useEffect, useState } from 'react';
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk';
export const HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'7a758f23-5e7d-497d-98c9-7ca8d6c085b0';
export const HelloWorld = () => {
const client = new CoreApiClient();
const [data, setData] = useState<
Pick<CoreSchema.Company, 'name' | 'id'> | undefined
>(undefined);
useEffect(() => {
const fetchData = async () => {
const response = await client.query({
company: {
name: true,
id: true,
__args: {
filter: {
position: {
eq: 1,
},
},
},
},
});
setData(response.company);
};
fetchData();
}, []);
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Hello, World!</h1>
<p>This is your first front component.</p>
{data ? (
<div>
<p>Company name: {data.name}</p>
<p>Company id: {data.id}</p>
</div>
) : (
<p>Company not found</p>
)}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'd371f098-5b2c-42f0-898d-94459f1ee337',
universalIdentifier: HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'hello-world-front-component',
description: 'A sample front component',
component: HelloWorld,
@@ -0,0 +1,35 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk';
const handler = async (): Promise<{ message: string }> => {
const client = new CoreApiClient();
const { createCompany } = await client.mutation({
createCompany: {
__args: {
data: {
name: 'Hello World',
},
},
id: true,
name: true,
},
});
return {
message: `Created company "${createCompany?.name}" with id ${createCompany?.id}`,
};
};
export default defineLogicFunction({
universalIdentifier: '94abaa53-d265-4fa4-ae52-1d7ea711ecf2',
name: 'create-hello-world-company',
description: 'Creates a company called Hello World',
timeoutSeconds: 5,
handler,
httpRouteTriggerSettings: {
path: '/create-hello-world-company',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -5,7 +5,7 @@ const handler = async (): Promise<{ message: string }> => {
};
export default defineLogicFunction({
universalIdentifier: '2baa26eb-9aaf-4856-a4f4-30d6fd6480ee',
universalIdentifier: 'b05e4b30-72d4-4d7f-8091-32e037b601da',
name: 'hello-world-logic-function',
description: 'A simple logic function',
timeoutSeconds: 5,
@@ -5,7 +5,7 @@ const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
};
export default definePostInstallLogicFunction({
universalIdentifier: '7a3f4684-51db-494d-833b-a747a3b90507',
universalIdentifier: '8c726dcc-1709-4eac-aa8b-f99960a9ec1b',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
@@ -5,7 +5,7 @@ const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
};
export default definePreInstallLogicFunction({
universalIdentifier: '1272ffdb-8e2f-492c-ab37-66c2b97e9c23',
universalIdentifier: 'f8ad4b09-6a12-4b12-a52a-3472d3a78dc7',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
@@ -3,7 +3,7 @@ import { NavigationMenuItemType } from 'twenty-shared/types';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '10f90627-e9c2-44b7-9742-bed77e3d1b17',
universalIdentifier: '9327db91-afa1-41b6-bd9d-2b51a26efb4c',
name: 'example-navigation-menu-item',
icon: 'IconList',
color: 'blue',
@@ -1,10 +1,10 @@
import { defineObject, FieldType } from 'twenty-sdk';
export const EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER =
'dfd43356-39b3-4b55-b4a7-279bec689928';
'47fd9bd9-392b-4d9f-9091-9a91b1edf519';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
'd2d7f6cd-33f6-456f-bf00-17adeca926ba';
'2d9ff841-cf8e-44ec-ad8e-468455f7eebd';
export default defineObject({
universalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
@@ -0,0 +1,31 @@
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/front-components/hello-world';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
export default definePageLayout({
universalIdentifier: '203aeb94-6701-46d6-9af1-be2bbcc9e134',
name: 'Example Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '6ed26b60-a51d-4ad7-86dd-1c04c7f3cac5',
title: 'Hello World',
position: 50,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'aa4234e0-2e5f-4c02-a96a-573449e2351d',
title: 'Hello World',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
@@ -1,7 +1,7 @@
import { defineRole } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'9238bc7b-d38f-4a1c-9d19-31ab7bc67a2f';
'c38f4d11-760c-4d5c-89ed-e569c28b7b70';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
@@ -1,7 +1,7 @@
import { defineSkill } from 'twenty-sdk';
export const EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER =
'd0940029-9d3c-40be-903a-52d65393028f';
'90cf9144-4811-4653-93a2-9a6780fe6aac';
export default defineSkill({
universalIdentifier: EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER,
@@ -1,7 +1,7 @@
import { defineView, ViewKey } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER, NAME_FIELD_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
export const EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER = 'e004df40-29f3-47ba-b39d-d3a5c444367a';
export const EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER = '965e3776-b966-4be8-83f7-6cd3bce5e1bd';
export default defineView({
universalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
@@ -12,7 +12,7 @@ export default defineView({
position: 0,
fields: [
{
universalIdentifier: '496c40c2-5766-419c-93bf-20fdad3f34bb',
universalIdentifier: 'f926bdb7-6af7-4683-9a09-adbca56c29f0',
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
+125 -113
View File
@@ -352,9 +352,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/aix-ppc64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/aix-ppc64@npm:0.27.3"
"@esbuild/aix-ppc64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/aix-ppc64@npm:0.27.4"
conditions: os=aix & cpu=ppc64
languageName: node
linkType: hard
@@ -366,9 +366,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/android-arm64@npm:0.27.3"
"@esbuild/android-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/android-arm64@npm:0.27.4"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
@@ -380,9 +380,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/android-arm@npm:0.27.3"
"@esbuild/android-arm@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/android-arm@npm:0.27.4"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
@@ -394,9 +394,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/android-x64@npm:0.27.3"
"@esbuild/android-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/android-x64@npm:0.27.4"
conditions: os=android & cpu=x64
languageName: node
linkType: hard
@@ -408,9 +408,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/darwin-arm64@npm:0.27.3"
"@esbuild/darwin-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/darwin-arm64@npm:0.27.4"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
@@ -422,9 +422,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/darwin-x64@npm:0.27.3"
"@esbuild/darwin-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/darwin-x64@npm:0.27.4"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
@@ -436,9 +436,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/freebsd-arm64@npm:0.27.3"
"@esbuild/freebsd-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/freebsd-arm64@npm:0.27.4"
conditions: os=freebsd & cpu=arm64
languageName: node
linkType: hard
@@ -450,9 +450,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/freebsd-x64@npm:0.27.3"
"@esbuild/freebsd-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/freebsd-x64@npm:0.27.4"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
@@ -464,9 +464,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-arm64@npm:0.27.3"
"@esbuild/linux-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-arm64@npm:0.27.4"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
@@ -478,9 +478,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-arm@npm:0.27.3"
"@esbuild/linux-arm@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-arm@npm:0.27.4"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
@@ -492,9 +492,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ia32@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-ia32@npm:0.27.3"
"@esbuild/linux-ia32@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-ia32@npm:0.27.4"
conditions: os=linux & cpu=ia32
languageName: node
linkType: hard
@@ -506,9 +506,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-loong64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-loong64@npm:0.27.3"
"@esbuild/linux-loong64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-loong64@npm:0.27.4"
conditions: os=linux & cpu=loong64
languageName: node
linkType: hard
@@ -520,9 +520,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-mips64el@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-mips64el@npm:0.27.3"
"@esbuild/linux-mips64el@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-mips64el@npm:0.27.4"
conditions: os=linux & cpu=mips64el
languageName: node
linkType: hard
@@ -534,9 +534,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ppc64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-ppc64@npm:0.27.3"
"@esbuild/linux-ppc64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-ppc64@npm:0.27.4"
conditions: os=linux & cpu=ppc64
languageName: node
linkType: hard
@@ -548,9 +548,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-riscv64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-riscv64@npm:0.27.3"
"@esbuild/linux-riscv64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-riscv64@npm:0.27.4"
conditions: os=linux & cpu=riscv64
languageName: node
linkType: hard
@@ -562,9 +562,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-s390x@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-s390x@npm:0.27.3"
"@esbuild/linux-s390x@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-s390x@npm:0.27.4"
conditions: os=linux & cpu=s390x
languageName: node
linkType: hard
@@ -576,9 +576,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-x64@npm:0.27.3"
"@esbuild/linux-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-x64@npm:0.27.4"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
@@ -590,9 +590,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/netbsd-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/netbsd-arm64@npm:0.27.3"
"@esbuild/netbsd-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/netbsd-arm64@npm:0.27.4"
conditions: os=netbsd & cpu=arm64
languageName: node
linkType: hard
@@ -604,9 +604,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/netbsd-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/netbsd-x64@npm:0.27.3"
"@esbuild/netbsd-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/netbsd-x64@npm:0.27.4"
conditions: os=netbsd & cpu=x64
languageName: node
linkType: hard
@@ -618,9 +618,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openbsd-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/openbsd-arm64@npm:0.27.3"
"@esbuild/openbsd-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/openbsd-arm64@npm:0.27.4"
conditions: os=openbsd & cpu=arm64
languageName: node
linkType: hard
@@ -632,9 +632,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openbsd-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/openbsd-x64@npm:0.27.3"
"@esbuild/openbsd-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/openbsd-x64@npm:0.27.4"
conditions: os=openbsd & cpu=x64
languageName: node
linkType: hard
@@ -646,9 +646,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openharmony-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/openharmony-arm64@npm:0.27.3"
"@esbuild/openharmony-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/openharmony-arm64@npm:0.27.4"
conditions: os=openharmony & cpu=arm64
languageName: node
linkType: hard
@@ -660,9 +660,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/sunos-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/sunos-x64@npm:0.27.3"
"@esbuild/sunos-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/sunos-x64@npm:0.27.4"
conditions: os=sunos & cpu=x64
languageName: node
linkType: hard
@@ -674,9 +674,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/win32-arm64@npm:0.27.3"
"@esbuild/win32-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/win32-arm64@npm:0.27.4"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
@@ -688,9 +688,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-ia32@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/win32-ia32@npm:0.27.3"
"@esbuild/win32-ia32@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/win32-ia32@npm:0.27.4"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
@@ -702,9 +702,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/win32-x64@npm:0.27.3"
"@esbuild/win32-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/win32-x64@npm:0.27.4"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@@ -1541,11 +1541,11 @@ __metadata:
linkType: hard
"@types/node@npm:*":
version: 25.3.5
resolution: "@types/node@npm:25.3.5"
version: 25.5.0
resolution: "@types/node@npm:25.5.0"
dependencies:
undici-types: "npm:~7.18.0"
checksum: 10c0/4cf0834a6f6933bf0aca6afead117ae3db3b8f02a5f7187a24f871db0fb9344e5e46573ba387bc53b7505e1e219c4c535cbe67221ced95bb5ad98573223b19d0
checksum: 10c0/70c508165b6758c4f88d4f91abca526c3985eee1985503d4c2bd994dbaf588e52ac57e571160f18f117d76e963570ac82bd20e743c18987e82564312b3b62119
languageName: node
linkType: hard
@@ -3408,35 +3408,35 @@ __metadata:
linkType: hard
"esbuild@npm:^0.27.0":
version: 0.27.3
resolution: "esbuild@npm:0.27.3"
version: 0.27.4
resolution: "esbuild@npm:0.27.4"
dependencies:
"@esbuild/aix-ppc64": "npm:0.27.3"
"@esbuild/android-arm": "npm:0.27.3"
"@esbuild/android-arm64": "npm:0.27.3"
"@esbuild/android-x64": "npm:0.27.3"
"@esbuild/darwin-arm64": "npm:0.27.3"
"@esbuild/darwin-x64": "npm:0.27.3"
"@esbuild/freebsd-arm64": "npm:0.27.3"
"@esbuild/freebsd-x64": "npm:0.27.3"
"@esbuild/linux-arm": "npm:0.27.3"
"@esbuild/linux-arm64": "npm:0.27.3"
"@esbuild/linux-ia32": "npm:0.27.3"
"@esbuild/linux-loong64": "npm:0.27.3"
"@esbuild/linux-mips64el": "npm:0.27.3"
"@esbuild/linux-ppc64": "npm:0.27.3"
"@esbuild/linux-riscv64": "npm:0.27.3"
"@esbuild/linux-s390x": "npm:0.27.3"
"@esbuild/linux-x64": "npm:0.27.3"
"@esbuild/netbsd-arm64": "npm:0.27.3"
"@esbuild/netbsd-x64": "npm:0.27.3"
"@esbuild/openbsd-arm64": "npm:0.27.3"
"@esbuild/openbsd-x64": "npm:0.27.3"
"@esbuild/openharmony-arm64": "npm:0.27.3"
"@esbuild/sunos-x64": "npm:0.27.3"
"@esbuild/win32-arm64": "npm:0.27.3"
"@esbuild/win32-ia32": "npm:0.27.3"
"@esbuild/win32-x64": "npm:0.27.3"
"@esbuild/aix-ppc64": "npm:0.27.4"
"@esbuild/android-arm": "npm:0.27.4"
"@esbuild/android-arm64": "npm:0.27.4"
"@esbuild/android-x64": "npm:0.27.4"
"@esbuild/darwin-arm64": "npm:0.27.4"
"@esbuild/darwin-x64": "npm:0.27.4"
"@esbuild/freebsd-arm64": "npm:0.27.4"
"@esbuild/freebsd-x64": "npm:0.27.4"
"@esbuild/linux-arm": "npm:0.27.4"
"@esbuild/linux-arm64": "npm:0.27.4"
"@esbuild/linux-ia32": "npm:0.27.4"
"@esbuild/linux-loong64": "npm:0.27.4"
"@esbuild/linux-mips64el": "npm:0.27.4"
"@esbuild/linux-ppc64": "npm:0.27.4"
"@esbuild/linux-riscv64": "npm:0.27.4"
"@esbuild/linux-s390x": "npm:0.27.4"
"@esbuild/linux-x64": "npm:0.27.4"
"@esbuild/netbsd-arm64": "npm:0.27.4"
"@esbuild/netbsd-x64": "npm:0.27.4"
"@esbuild/openbsd-arm64": "npm:0.27.4"
"@esbuild/openbsd-x64": "npm:0.27.4"
"@esbuild/openharmony-arm64": "npm:0.27.4"
"@esbuild/sunos-x64": "npm:0.27.4"
"@esbuild/win32-arm64": "npm:0.27.4"
"@esbuild/win32-ia32": "npm:0.27.4"
"@esbuild/win32-x64": "npm:0.27.4"
dependenciesMeta:
"@esbuild/aix-ppc64":
optional: true
@@ -3492,7 +3492,7 @@ __metadata:
optional: true
bin:
esbuild: bin/esbuild
checksum: 10c0/fdc3f87a3f08b3ef98362f37377136c389a0d180fda4b8d073b26ba930cf245521db0a368f119cc7624bc619248fff1439f5811f062d853576f8ffa3df8ee5f1
checksum: 10c0/2a1c2bcccda279f2afd72a7f8259860cb4483b32453d17878e1ecb4ac416b9e7c1001e7aa0a25ba4c29c1e250a3ceaae5d8bb72a119815bc8db4e9b5f5321490
languageName: node
linkType: hard
@@ -3906,6 +3906,7 @@ __metadata:
"@types/react": "npm:^18.2.0"
oxlint: "npm:^0.16.0"
react: "npm:^18.2.0"
twenty-client-sdk: "portal:../../twenty-client-sdk"
twenty-sdk: "portal:../../twenty-sdk"
typescript: "npm:^5.9.3"
vite-tsconfig-paths: "npm:^4.2.1"
@@ -4952,9 +4953,9 @@ __metadata:
linkType: hard
"preact@npm:^10.28.3":
version: 10.28.4
resolution: "preact@npm:10.28.4"
checksum: 10c0/712384e92d6c676c8f4c230eab81329a66acfecf700390aebfe5fc46168fa3686345d47f2292068dcc0a6d86425a3d9a3d743730e356e867a8402442afb989db
version: 10.29.0
resolution: "preact@npm:10.29.0"
checksum: 10c0/d111381e5b48335e3a797a03adb83521cf5e9bdf880570fb2eff4fe9da9c82e6dedcbdf54538b1ed8f60bf813a0df0f4891b03dc32140ad93f8f720a8812dd5c
languageName: node
linkType: hard
@@ -5750,6 +5751,17 @@ __metadata:
languageName: node
linkType: hard
"twenty-client-sdk@portal:../../twenty-client-sdk::locator=hello-world%40workspace%3A.":
version: 0.0.0-use.local
resolution: "twenty-client-sdk@portal:../../twenty-client-sdk::locator=hello-world%40workspace%3A."
dependencies:
"@genql/cli": "npm:^3.0.3"
"@genql/runtime": "npm:^2.10.0"
esbuild: "npm:^0.25.0"
graphql: "npm:^16.8.1"
languageName: node
linkType: soft
"twenty-sdk@portal:../../twenty-sdk::locator=hello-world%40workspace%3A.":
version: 0.0.0-use.local
resolution: "twenty-sdk@portal:../../twenty-sdk::locator=hello-world%40workspace%3A."
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote add --local
yarn twenty remote add http://localhost:2020 --as local
```
Then, start development mode to sync your app and watch for changes:
@@ -22,7 +22,7 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote add --local # Authenticate with Twenty
yarn twenty remote add http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
+47
View File
@@ -0,0 +1,47 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist", "generated"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
]
}
}
@@ -0,0 +1,2 @@
dist
generated
+60
View File
@@ -0,0 +1,60 @@
{
"name": "twenty-client-sdk",
"version": "0.8.0-canary.5",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
"build": "npx rimraf dist && npx vite build && tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist"
},
"exports": {
"./core": {
"types": "./dist/core/index.d.ts",
"import": "./dist/core.mjs",
"require": "./dist/core.cjs"
},
"./metadata": {
"types": "./dist/metadata/index.d.ts",
"import": "./dist/metadata.mjs",
"require": "./dist/metadata.cjs"
},
"./generate": {
"types": "./dist/generate/index.d.ts",
"import": "./dist/generate.mjs",
"require": "./dist/generate.cjs"
}
},
"typesVersions": {
"*": {
"core": [
"dist/core/index.d.ts"
],
"metadata": [
"dist/metadata/index.d.ts"
],
"generate": [
"dist/generate/index.d.ts"
]
}
},
"files": [
"dist"
],
"dependencies": {
"@genql/cli": "^3.0.3",
"@genql/runtime": "^2.10.0",
"esbuild": "^0.25.0",
"graphql": "^16.8.1"
},
"devDependencies": {
"twenty-shared": "workspace:*",
"typescript": "^5.9.2",
"vite": "^7.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.18"
},
"engines": {
"node": "^24.5.0",
"yarn": "^4.0.2"
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"name": "twenty-client-sdk",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-client-sdk/src",
"projectType": "library",
"tags": ["scope:sdk"],
"targets": {
"build": {
"executor": "nx:run-commands",
"cache": true,
"inputs": ["production", "^production"],
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/dist"],
"options": {
"cwd": "{projectRoot}",
"commands": [
"npx rimraf dist && npx vite build",
"tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist"
],
"parallel": false
}
},
"generate-metadata-client": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/src/metadata/generated"],
"options": {
"cwd": "packages/twenty-client-sdk",
"command": "tsx scripts/generate-metadata-client.ts"
}
},
"test": {
"executor": "@nx/vitest:test",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"config": "{projectRoot}/vitest.config.ts"
}
},
"set-local-version": {},
"typecheck": {},
"lint": {}
}
}
@@ -0,0 +1,65 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getIntrospectionQuery, buildClientSchema, printSchema } from 'graphql';
import { generateMetadataClient } from '../src/generate/generate-metadata-client';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TEMPLATE_PATH = path.resolve(
__dirname,
'..',
'src',
'generate',
'twenty-client-template.ts',
);
const introspectSchema = async (url: string): Promise<string> => {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: getIntrospectionQuery() }),
});
const json = await response.json();
if (json.errors) {
throw new Error(
`GraphQL introspection errors: ${JSON.stringify(json.errors)}`,
);
}
return printSchema(buildClientSchema(json.data));
};
const main = async () => {
const serverUrl = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const schema = await introspectSchema(`${serverUrl}/metadata`);
const clientWrapperTemplateSource = await readFile(TEMPLATE_PATH, 'utf-8');
const outputPath = path.resolve(
__dirname,
'..',
'src',
'metadata',
'generated',
);
await generateMetadataClient({
schema,
outputPath,
clientWrapperTemplateSource,
});
console.log(`Metadata client generated at ${outputPath}`);
};
main().catch((error) => {
console.error('Failed to generate metadata client:', error);
process.exit(1);
});
@@ -0,0 +1,10 @@
// Stub — replaced at runtime by the generated client when the app
// is installed on a Twenty instance or during `twenty app:dev`.
export class CoreApiClient {
constructor() {
throw new Error(
'CoreApiClient was not generated. ' +
'Install this app on a Twenty instance or run `twenty app:dev`.',
);
}
}
@@ -0,0 +1,2 @@
export { CoreApiClient } from './generated/index';
export * as CoreSchema from './generated/schema';
@@ -1,8 +1,10 @@
import { transform } from 'esbuild';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { transform } from 'esbuild';
import {
afterAll,
beforeAll,
@@ -13,17 +15,12 @@ import {
vi,
} from 'vitest';
vi.mock('@/cli/constants/clients-dir', () => ({
CLIENTS_GENERATED_DIR: 'src/clients/generated',
}));
import { buildClientWrapperSource } from '../client-wrapper';
vi.mock('twenty-shared/application', () => ({
DEFAULT_APP_ACCESS_TOKEN_NAME: 'TWENTY_APP_ACCESS_TOKEN',
DEFAULT_API_KEY_NAME: 'TWENTY_API_KEY',
DEFAULT_API_URL_NAME: 'TWENTY_API_URL',
}));
import { ClientService } from '@/cli/utilities/client/client-service';
const twentyClientTemplateSource = readFileSync(
join(__dirname, '..', 'twenty-client-template.ts'),
'utf-8',
);
type TwentyClassType = new (options?: {
url?: string;
@@ -110,59 +107,33 @@ const getAuthorizationHeaderValue = (requestInit: RequestInit | undefined) => {
return new Headers(requestInit?.headers).get('Authorization');
};
describe('ClientService generated Twenty auth behavior', () => {
let temporaryGeneratedClientDirectory: string;
describe('Generated client wrapper auth behavior', () => {
let temporaryDir: string;
let TwentyClass: TwentyClassType;
beforeAll(async () => {
temporaryGeneratedClientDirectory = await mkdtemp(
join(tmpdir(), 'twenty-generated-client-'),
);
temporaryDir = await mkdtemp(join(tmpdir(), 'twenty-generated-client-'));
const temporaryGeneratedIndexTsPath = join(
temporaryGeneratedClientDirectory,
'index.ts',
);
await writeFile(temporaryGeneratedIndexTsPath, stubGeneratedIndexSource);
const clientService = new ClientService();
await (
clientService as unknown as {
injectClientWrapper: (
output: string,
options: {
apiClientName: string;
defaultUrl: string;
includeUploadFile: boolean;
},
) => Promise<void>;
}
).injectClientWrapper(temporaryGeneratedClientDirectory, {
const wrapperSource = buildClientWrapperSource(twentyClientTemplateSource, {
apiClientName: 'MetadataApiClient',
defaultUrl: '`${process.env.TWENTY_API_URL}/metadata`',
includeUploadFile: true,
});
const generatedIndexContent = await readFile(
temporaryGeneratedIndexTsPath,
'utf-8',
);
const fullSource = stubGeneratedIndexSource + wrapperSource;
const transpiledModule = await transform(generatedIndexContent, {
const transpiledModule = await transform(fullSource, {
loader: 'ts',
format: 'esm',
target: 'es2022',
});
const temporaryGeneratedIndexMjsPath = join(
temporaryGeneratedClientDirectory,
'index.mjs',
);
await writeFile(temporaryGeneratedIndexMjsPath, transpiledModule.code);
const outputPath = join(temporaryDir, 'index.mjs');
await writeFile(outputPath, transpiledModule.code);
const generatedModule = await import(
`${pathToFileURL(temporaryGeneratedIndexMjsPath).href}?t=${Date.now()}`
`${pathToFileURL(outputPath).href}?t=${Date.now()}`
);
TwentyClass = generatedModule.MetadataApiClient as TwentyClassType;
@@ -176,11 +147,8 @@ describe('ClientService generated Twenty auth behavior', () => {
});
afterAll(async () => {
if (temporaryGeneratedClientDirectory) {
await rm(temporaryGeneratedClientDirectory, {
recursive: true,
force: true,
});
if (temporaryDir) {
await rm(temporaryDir, { recursive: true, force: true });
}
});
@@ -303,6 +271,7 @@ describe('ClientService generated Twenty auth behavior', () => {
.fn<() => Promise<string>>()
.mockImplementation(async () => {
await Promise.resolve();
return 'fresh-token';
});
@@ -0,0 +1,51 @@
type ClientWrapperOptions = {
apiClientName: string;
defaultUrl: string;
includeUploadFile: boolean;
};
const STRIPPED_TYPES_START = '// __STRIPPED_DURING_INJECTION_START__';
const STRIPPED_TYPES_END = '// __STRIPPED_DURING_INJECTION_END__';
const UPLOAD_FILE_START = '// __UPLOAD_FILE_START__';
const UPLOAD_FILE_END = '// __UPLOAD_FILE_END__';
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const buildClientWrapperSource = (
templateSource: string,
options: ClientWrapperOptions,
): string => {
let source = templateSource;
source = source.replace(
new RegExp(
`${escapeRegExp(STRIPPED_TYPES_START)}[\\s\\S]*?${escapeRegExp(STRIPPED_TYPES_END)}\\n?`,
),
'',
);
source = source.replace("'__TWENTY_DEFAULT_URL__'", options.defaultUrl);
source = source.replace(/TwentyGeneratedClient/g, options.apiClientName);
if (!options.includeUploadFile) {
source = source.replace(
new RegExp(
`\\s*${escapeRegExp(UPLOAD_FILE_START)}[\\s\\S]*?${escapeRegExp(UPLOAD_FILE_END)}\\n?`,
),
'\n',
);
} else {
source = source.replace(
new RegExp(`\\s*${escapeRegExp(UPLOAD_FILE_START)}\\n`),
'\n',
);
source = source.replace(
new RegExp(`\\s*${escapeRegExp(UPLOAD_FILE_END)}\\n`),
'\n',
);
}
return `\n// ${options.apiClientName} (auto-injected by twenty-client-sdk)\n${source}`;
};
@@ -0,0 +1,41 @@
import { cp, mkdir, readdir, rename as fsRename, rm } from 'node:fs/promises';
import { join } from 'node:path';
export const ensureDir = (dirPath: string) =>
mkdir(dirPath, { recursive: true });
export const emptyDir = async (dirPath: string): Promise<void> => {
let entries: string[];
try {
entries = await readdir(dirPath);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
await mkdir(dirPath, { recursive: true });
return;
}
throw error;
}
await Promise.all(
entries.map((entry) =>
rm(join(dirPath, entry), { recursive: true, force: true }),
),
);
};
export const move = async (src: string, dest: string): Promise<void> => {
try {
await fsRename(src, dest);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'EXDEV') {
await cp(src, dest, { recursive: true });
await rm(src, { recursive: true, force: true });
} else {
throw error;
}
}
};
export const remove = (filePath: string) =>
rm(filePath, { recursive: true, force: true });
@@ -0,0 +1,122 @@
import { appendFile, copyFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { generate } from '@genql/cli';
import { build } from 'esbuild';
import { DEFAULT_API_URL_NAME } from 'twenty-shared/application';
import { buildClientWrapperSource } from './client-wrapper';
import { emptyDir, ensureDir, move, remove } from './fs-utils';
import twentyClientTemplateSource from './twenty-client-template.ts?raw';
const COMMON_SCALAR_TYPES = {
DateTime: 'string',
JSON: 'Record<string, unknown>',
UUID: 'string',
};
export const GENERATED_CORE_DIR = 'core/generated';
// Generates the core API client from a GraphQL schema string.
// Produces both TypeScript source and compiled ESM/CJS bundles.
export const generateCoreClientFromSchema = async ({
schema,
outputPath,
clientWrapperTemplateSource,
}: {
schema: string;
outputPath: string;
clientWrapperTemplateSource?: string;
}): Promise<void> => {
const templateSource =
clientWrapperTemplateSource ?? twentyClientTemplateSource;
const tempPath = `${outputPath}.tmp`;
await ensureDir(tempPath);
await emptyDir(tempPath);
try {
await generate({
schema,
output: tempPath,
scalarTypes: COMMON_SCALAR_TYPES,
});
const clientContent = buildClientWrapperSource(templateSource, {
apiClientName: 'CoreApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\``,
includeUploadFile: true,
});
await appendFile(join(tempPath, 'index.ts'), clientContent);
await remove(outputPath);
await move(tempPath, outputPath);
await compileGeneratedClient(outputPath);
} catch (error) {
await remove(tempPath);
throw error;
}
};
// Generates the core client and replaces the pre-built stub inside
// an installed twenty-client-sdk package (dist/core.mjs and dist/core.cjs).
// Generated source files are kept in dist/generated-core/ for consumers
// that need the raw .ts files (e.g. the app:dev upload step).
export const replaceCoreClient = async ({
packageRoot,
schema,
}: {
packageRoot: string;
schema: string;
}): Promise<void> => {
const generatedPath = join(packageRoot, 'dist', GENERATED_CORE_DIR);
await generateCoreClientFromSchema({ schema, outputPath: generatedPath });
await copyFile(
join(generatedPath, 'index.mjs'),
join(packageRoot, 'dist', 'core.mjs'),
);
await copyFile(
join(generatedPath, 'index.cjs'),
join(packageRoot, 'dist', 'core.cjs'),
);
};
const compileGeneratedClient = async (generatedDir: string): Promise<void> => {
const entryPoint = join(generatedDir, 'index.ts');
const outfile = join(generatedDir, 'index.mjs');
await build({
entryPoints: [entryPoint],
outfile,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node18',
sourcemap: false,
minify: false,
});
await build({
entryPoints: [entryPoint],
outfile: join(generatedDir, 'index.cjs'),
bundle: true,
format: 'cjs',
platform: 'node',
target: 'node18',
sourcemap: false,
minify: false,
});
await writeFile(
join(generatedDir, 'package.json'),
JSON.stringify(
{ type: 'module', main: 'index.mjs', module: 'index.mjs' },
null,
2,
),
);
};
@@ -0,0 +1,48 @@
import { appendFile } from 'node:fs/promises';
import { join } from 'node:path';
import { generate } from '@genql/cli';
import { DEFAULT_API_URL_NAME } from 'twenty-shared/application';
import { buildClientWrapperSource } from './client-wrapper';
import { emptyDir, ensureDir } from './fs-utils';
import twentyClientTemplateSource from './twenty-client-template.ts?raw';
const COMMON_SCALAR_TYPES = {
DateTime: 'string',
JSON: 'Record<string, unknown>',
UUID: 'string',
};
export const generateMetadataClient = async ({
schema,
outputPath,
clientWrapperTemplateSource,
}: {
schema: string;
outputPath: string;
clientWrapperTemplateSource?: string;
}): Promise<void> => {
const templateSource =
clientWrapperTemplateSource ?? twentyClientTemplateSource;
await ensureDir(outputPath);
await emptyDir(outputPath);
await generate({
schema,
output: outputPath,
scalarTypes: {
...COMMON_SCALAR_TYPES,
Upload: 'File',
},
});
const clientContent = buildClientWrapperSource(templateSource, {
apiClientName: 'MetadataApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/metadata\``,
includeUploadFile: true,
});
await appendFile(join(outputPath, 'index.ts'), clientContent);
};
@@ -0,0 +1,6 @@
export {
GENERATED_CORE_DIR,
generateCoreClientFromSchema,
replaceCoreClient,
} from './generate-core-client';
export { generateMetadataClient } from './generate-metadata-client';
@@ -0,0 +1,4 @@
declare module '*.ts?raw' {
const content: string;
export default content;
}
@@ -0,0 +1,434 @@
// Ambient type stubs for the genql-generated code this template gets
// injected into. They enable full typecheck/lint on this file.
// __STRIPPED_DURING_INJECTION_START__
type QueryGenqlSelection = Record<string, unknown>;
type MutationGenqlSelection = Record<string, unknown>;
type GraphqlOperation = Record<string, unknown>;
type ClientOptions = {
url?: string;
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
fetcher?: (
operation: GraphqlOperation | GraphqlOperation[],
) => Promise<unknown>;
fetch?: typeof globalThis.fetch;
batch?: unknown;
};
type Client = {
query: (
request: QueryGenqlSelection & { __name?: string },
) => Promise<unknown>;
mutation: (
request: MutationGenqlSelection & { __name?: string },
) => Promise<unknown>;
};
declare function createClient(options: ClientOptions): Client;
declare class GenqlError extends Error {
constructor(errors: unknown, data: unknown);
}
// __STRIPPED_DURING_INJECTION_END__
const APP_ACCESS_TOKEN_ENV_KEY = 'TWENTY_APP_ACCESS_TOKEN';
const API_KEY_ENV_KEY = 'TWENTY_API_KEY';
type TwentyGeneratedClientOptions = ClientOptions;
type ProcessEnvironment = Record<string, string | undefined>;
type GraphqlErrorPayloadEntry = {
message?: string;
extensions?: { code?: string };
};
type GraphqlResponsePayload = {
data?: Record<string, unknown>;
errors?: GraphqlErrorPayloadEntry[];
};
type GraphqlResponse = {
status: number;
statusText: string;
payload: GraphqlResponsePayload | null;
rawBody: string;
};
const getProcessEnvironment = (): ProcessEnvironment => {
const processObject = (
globalThis as { process?: { env?: ProcessEnvironment } }
).process;
return processObject?.env ?? {};
};
const getTokenFromAuthorizationHeader = (
authorizationHeader: string | undefined,
): string | null => {
if (typeof authorizationHeader !== 'string') {
return null;
}
const trimmedAuthorizationHeader = authorizationHeader.trim();
if (trimmedAuthorizationHeader.length === 0) {
return null;
}
if (trimmedAuthorizationHeader === 'Bearer') {
return null;
}
if (trimmedAuthorizationHeader.startsWith('Bearer ')) {
return trimmedAuthorizationHeader.slice('Bearer '.length).trim();
}
return trimmedAuthorizationHeader;
};
const getTokenFromHeaders = (
headers: HeadersInit | undefined,
): string | null => {
if (!headers) {
return null;
}
if (headers instanceof Headers) {
return getTokenFromAuthorizationHeader(
headers.get('Authorization') ?? undefined,
);
}
if (Array.isArray(headers)) {
const matchedAuthorizationHeader = headers.find(
([headerName]) => headerName.toLowerCase() === 'authorization',
);
return getTokenFromAuthorizationHeader(matchedAuthorizationHeader?.[1]);
}
const headersRecord = headers as Record<string, string | undefined>;
return getTokenFromAuthorizationHeader(
headersRecord.Authorization ?? headersRecord.authorization,
);
};
const hasAuthenticationErrorInGraphqlPayload = (
payload: GraphqlResponsePayload | null,
): boolean => {
if (!payload?.errors) {
return false;
}
return payload.errors.some((graphqlError) => {
return (
graphqlError.extensions?.code === 'UNAUTHENTICATED' ||
graphqlError.message?.toLowerCase() === 'unauthorized'
);
});
};
const defaultOptions: TwentyGeneratedClientOptions = {
url: '__TWENTY_DEFAULT_URL__',
headers: {
'Content-Type': 'application/json',
},
};
export class TwentyGeneratedClient {
private client: Client;
private url: string;
private requestOptions: RequestInit;
private headers: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
private fetchImplementation: typeof globalThis.fetch | null;
private authorizationToken: string | null;
private refreshAccessTokenPromise: Promise<string | null> | null = null;
constructor(options?: TwentyGeneratedClientOptions) {
const merged: TwentyGeneratedClientOptions = {
...defaultOptions,
...options,
};
const {
url,
headers,
fetch: customFetchImplementation,
fetcher: _fetcher,
batch: _batch,
...requestOptions
} = merged;
this.url = url ?? '';
this.requestOptions = requestOptions;
this.headers = headers ?? {};
this.fetchImplementation =
customFetchImplementation ?? globalThis.fetch ?? null;
const processEnvironment = getProcessEnvironment();
const tokenFromHeaders = getTokenFromHeaders(
typeof headers === 'function' ? undefined : headers,
);
// Priority: explicit header > app access token > api key (legacy).
this.authorizationToken =
tokenFromHeaders ??
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] ??
processEnvironment[API_KEY_ENV_KEY] ??
null;
this.client = createClient({
...merged,
headers: undefined,
fetcher: async (operation) =>
this.executeGraphqlRequestWithOptionalRefresh({
operation,
}),
});
}
query<R extends QueryGenqlSelection>(request: R & { __name?: string }) {
return this.client.query(request);
}
mutation<R extends MutationGenqlSelection>(request: R & { __name?: string }) {
return this.client.mutation(request);
}
// __UPLOAD_FILE_START__
async uploadFile(
fileBuffer: Buffer,
filename: string,
contentType: string = 'application/octet-stream',
fieldMetadataUniversalIdentifier: string,
): Promise<{
id: string;
path: string;
size: number;
createdAt: string;
url: string;
}> {
const form = new FormData();
form.append(
'operations',
JSON.stringify({
query: `mutation UploadFilesFieldFileByUniversalIdentifier($file: Upload!, $fieldMetadataUniversalIdentifier: String!) {
uploadFilesFieldFileByUniversalIdentifier(file: $file, fieldMetadataUniversalIdentifier: $fieldMetadataUniversalIdentifier) { id path size createdAt url }
}`,
variables: {
file: null,
fieldMetadataUniversalIdentifier,
},
}),
);
form.append('map', JSON.stringify({ '0': ['variables.file'] }));
form.append(
'0',
new Blob([fileBuffer as BlobPart], { type: contentType }),
filename,
);
const result = await this.executeGraphqlRequestWithOptionalRefresh({
operation: form,
headers: {},
requestInit: {
method: 'POST',
},
});
if (result.errors) {
throw new GenqlError(result.errors, result.data);
}
const data = result.data as Record<string, unknown>;
return data.uploadFilesFieldFileByUniversalIdentifier as {
id: string;
path: string;
size: number;
createdAt: string;
url: string;
};
}
// __UPLOAD_FILE_END__
private async executeGraphqlRequestWithOptionalRefresh({
operation,
headers,
requestInit,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
}) {
const firstResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: this.authorizationToken,
});
if (this.shouldRefreshToken(firstResponse)) {
const refreshedAccessToken = await this.requestRefreshedAccessToken();
if (refreshedAccessToken) {
const retryResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: refreshedAccessToken,
});
return this.assertResponseIsSuccessful(retryResponse);
}
}
return this.assertResponseIsSuccessful(firstResponse);
}
private async executeGraphqlRequest({
operation,
headers,
requestInit,
token,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
token: string | null;
}): Promise<GraphqlResponse> {
if (!this.fetchImplementation) {
throw new Error(
'Global `fetch` function is not available, ' +
'pass a fetch implementation to the Twenty client',
);
}
const resolvedHeaders = await this.resolveHeaders();
const requestHeaders = new Headers(resolvedHeaders);
if (headers) {
new Headers(headers).forEach((value, key) =>
requestHeaders.set(key, value),
);
}
if (operation instanceof FormData) {
requestHeaders.delete('Content-Type');
} else {
requestHeaders.set('Content-Type', 'application/json');
}
if (token) {
requestHeaders.set('Authorization', `Bearer ${token}`);
} else {
requestHeaders.delete('Authorization');
}
const response = await this.fetchImplementation.call(globalThis, this.url, {
...this.requestOptions,
...requestInit,
method: requestInit?.method ?? 'POST',
headers: requestHeaders,
body:
operation instanceof FormData ? operation : JSON.stringify(operation),
});
const rawBody = await response.text();
let payload: GraphqlResponsePayload | null = null;
if (rawBody.trim().length > 0) {
try {
payload = JSON.parse(rawBody) as GraphqlResponsePayload;
} catch {
payload = null;
}
}
return {
status: response.status,
statusText: response.statusText,
payload,
rawBody,
};
}
private async resolveHeaders(): Promise<HeadersInit> {
if (typeof this.headers === 'function') {
return (await this.headers()) ?? {};
}
return this.headers ?? {};
}
private shouldRefreshToken(response: GraphqlResponse): boolean {
if (response.status === 401) {
return true;
}
return hasAuthenticationErrorInGraphqlPayload(response.payload);
}
private assertResponseIsSuccessful(response: GraphqlResponse) {
if (response.status < 200 || response.status >= 300) {
throw new Error(`${response.statusText}: ${response.rawBody}`);
}
if (response.payload === null) {
throw new Error('Invalid JSON response');
}
return response.payload;
}
private async requestRefreshedAccessToken(): Promise<string | null> {
const refreshAccessTokenFunction = (
globalThis as {
frontComponentHostCommunicationApi?: {
requestAccessTokenRefresh?: () => Promise<string>;
};
}
).frontComponentHostCommunicationApi?.requestAccessTokenRefresh;
if (typeof refreshAccessTokenFunction !== 'function') {
return null;
}
if (!this.refreshAccessTokenPromise) {
this.refreshAccessTokenPromise = refreshAccessTokenFunction()
.then((refreshedAccessToken) => {
if (
typeof refreshedAccessToken !== 'string' ||
refreshedAccessToken.length === 0
) {
return null;
}
this.setAuthorizationToken(refreshedAccessToken);
return refreshedAccessToken;
})
.catch((refreshError: unknown) => {
console.error('Twenty client: token refresh failed', refreshError);
return null;
})
.finally(() => {
this.refreshAccessTokenPromise = null;
});
}
return this.refreshAccessTokenPromise;
}
private setAuthorizationToken(token: string) {
this.authorizationToken = token;
const processEnvironment = getProcessEnvironment();
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] = token;
}
}
@@ -78,7 +78,7 @@ export const generateSubscriptionOp: (
)
}
// MetadataApiClient (auto-injected by twenty-sdk)
// MetadataApiClient (auto-injected by twenty-client-sdk)
// Ambient type stubs for the genql-generated code this template gets
// injected into. They enable full typecheck/lint on this file.
@@ -0,0 +1,2 @@
export { MetadataApiClient } from './generated/index';
export * as MetadataSchema from './generated/schema';
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strictNullChecks": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"noEmit": true,
"types": ["node"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "vite.config.ts"],
"exclude": ["node_modules", "dist"]
}
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
+78
View File
@@ -0,0 +1,78 @@
import path from 'path';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import packageJson from './package.json';
const entries = [
'src/core/index.ts',
'src/metadata/index.ts',
'src/generate/index.ts',
];
const entryFileNames = (chunk: any, extension: 'cjs' | 'mjs') => {
if (!chunk.isEntry) {
throw new Error(
`Should never occur, encountered a non entry chunk ${chunk.facadeModuleId}`,
);
}
const splitFaceModuleId = chunk.facadeModuleId?.split('/');
if (splitFaceModuleId === undefined) {
throw new Error(
`Should never occur, splitFaceModuleId is undefined ${chunk.facadeModuleId}`,
);
}
const moduleDirectory = splitFaceModuleId[splitFaceModuleId?.length - 2];
if (moduleDirectory === 'src') {
return `${chunk.name}.${extension}`;
}
return `${moduleDirectory}.${extension}`;
};
export default defineConfig(() => {
return {
root: __dirname,
cacheDir: '../../node_modules/.vite/packages/twenty-client-sdk',
resolve: {
alias: {
'@/': path.resolve(__dirname, 'src') + '/',
},
},
plugins: [
tsconfigPaths({
root: __dirname,
}),
],
build: {
emptyOutDir: false,
outDir: 'dist',
lib: { entry: entries, name: 'twenty-client-sdk' },
rollupOptions: {
external: [
...Object.keys((packageJson as any).dependencies || {}),
...Object.keys((packageJson as any).devDependencies || {}).filter(
(dep: string) => dep !== 'twenty-shared',
),
'node:fs/promises',
'node:fs',
'node:path',
],
output: [
{
format: 'es',
entryFileNames: (chunk) => entryFileNames(chunk, 'mjs'),
},
{
format: 'cjs',
interop: 'auto',
esModule: true,
exports: 'named',
entryFileNames: (chunk) => entryFileNames(chunk, 'cjs'),
},
],
},
},
logLevel: 'warn',
};
});
@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
name: 'twenty-client-sdk',
environment: 'node',
include: [
'src/**/__tests__/**/*.{test,spec}.{ts,tsx}',
'src/**/*.{test,spec}.{ts,tsx}',
],
exclude: ['**/node_modules/**', '**/.git/**'],
globals: true,
},
});
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -5,7 +5,7 @@
"description": "Twenty meeting recorder",
"main": ".webpack/main",
"scripts": {
"start": "concurrently \"npm run start:server\" \"npm run start:electron\"",
"start": "concurrently \"yarn start:server\" \"yarn start:electron\"",
"start:electron": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
@@ -37,10 +37,8 @@
"node-loader": "^2.1.0",
"style-loader": "^3.3.4"
},
"overrides": {
"@electron/packager": {
"@electron/osx-sign": "github:recallai/osx-sign"
}
"resolutions": {
"@electron/packager/@electron/osx-sign": "github:recallai/osx-sign"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.40.1",
+2
View File
@@ -16,3 +16,5 @@ STORAGE_TYPE=local
# STORAGE_S3_REGION=eu-west3
# STORAGE_S3_NAME=my-bucket
# STORAGE_S3_ENDPOINT=
# STORAGE_S3_ACCESS_KEY_ID=
# STORAGE_S3_SECRET_ACCESS_KEY=
+1 -1
View File
@@ -18,7 +18,7 @@ DOCKER_NETWORK=twenty_network
# =============================================================================
prod-build:
@cd ../.. && docker build -f ./packages/twenty-docker/twenty/Dockerfile --platform $(PLATFORM) --tag twenty:$(TAG) . && cd -
@cd ../.. && docker build --target twenty -f ./packages/twenty-docker/twenty/Dockerfile --platform $(PLATFORM) --tag twenty:$(TAG) . && cd -
prod-run:
@docker run -d -p 3000:3000 --name twenty twenty:$(TAG)
@@ -89,6 +89,15 @@ password
{{- end -}}
{{- end -}}
{{/* Check if using external secret for redis password */}}
{{- define "twenty.redis.useExternalSecret" -}}
{{- if and (not .Values.redisInternal.enabled) .Values.redis.external.secretName .Values.redis.external.passwordKey -}}
true
{{- else -}}
false
{{- end -}}
{{- end -}}
{{/* Compose Redis URL */}}
{{- define "twenty.redisUrl" -}}
{{- if .Values.server.env.REDIS_URL -}}
@@ -99,9 +108,14 @@ password
{{- else -}}
{{- $host := .Values.redis.external.host | default "redis" -}}
{{- $port := .Values.redis.external.port | default 6379 -}}
{{- if or (eq (include "twenty.redis.useExternalSecret" .) "true") (.Values.redis.external.password) -}}
{{- $auth := ":$(REDIS_PASSWORD)@" -}}
{{- printf "redis://%s%s:%v" $auth $host $port -}}
{{- else -}}
{{- printf "redis://%s:%v" $host $port -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/* Compose Server URL from override, ingress, or service */}}
{{- define "twenty.serverUrl" -}}
@@ -46,12 +46,12 @@ spec:
volumeMounts:
- name: redis-data
mountPath: /data
volumes:
- name: redis-data
{{- if .Values.redisInternal.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Values.redisInternal.persistence.existingClaim | default (printf "%s-redis" (include "twenty.fullname" .)) }}
{{- else }}
emptyDir: {}
{{- end }}
volumes:
- name: redis-data
{{- if .Values.redisInternal.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Values.redisInternal.persistence.existingClaim | default (printf "%s-redis" (include "twenty.fullname" .)) }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
@@ -83,16 +83,16 @@ spec:
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v db="${DBNAME}" -Atc "SELECT 1 FROM pg_database WHERE datname = :'db'" | grep -q 1 || \
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v db="${DBNAME}" -c 'CREATE DATABASE :"db";'
echo "Creating app user ${APP_USER} if it doesn't exist..."
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v app_user="${APP_USER}" -v app_password="${APP_PASSWORD}" <<'EOSQL'
DO
$do$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_user') THEN
EXECUTE format('CREATE USER %I WITH PASSWORD %L', :'app_user', :'app_password');
END IF;
END
$do$;
EOSQL
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v app_user="${APP_USER}" -v app_password="${APP_PASSWORD}" <<'EOSQL'
DO
$do$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_user') THEN
EXECUTE format('CREATE USER %I WITH PASSWORD %L', :'app_user', :'app_password');
END IF;
END
$do$;
EOSQL
echo "Creating core schema and granting permissions..."
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'CREATE SCHEMA IF NOT EXISTS core'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v db="${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON DATABASE :"db" TO :"app_user";'
@@ -106,31 +106,6 @@ spec:
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO :"app_user";'
echo "Database ${DBNAME} is ready."
{{- end }}
- name: run-migrations
{{- $img := include "twenty.server.image" . }}
image: {{ include "twenty.image.repository" $img }}:{{ include "twenty.image.tag" $img }}
imagePullPolicy: {{ include "twenty.image.pullPolicy" $img }}
command:
- sh
- -c
- >-
npx -y typeorm migration:run -d dist/database/typeorm/core/core.datasource
env:
{{- if eq (include "twenty.db.useExternalSecret" .) "true" }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbPassword.secretName" . }}
key: {{ include "twenty.dbPassword.secretKey" . }}
- name: PG_DATABASE_URL
value: {{ include "twenty.dbUrl.template" . | quote }}
{{- else }}
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbUrl.secretName" . }}
key: url
{{- end }}
containers:
- name: server
{{- $img := include "twenty.server.image" . }}
@@ -154,6 +129,16 @@ spec:
name: {{ include "twenty.dbUrl.secretName" . }}
key: url
{{- end }}
{{- if eq (include "twenty.redis.useExternalSecret" .) "true" }}
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.redis.external.secretName }}
key: {{ .Values.redis.external.passwordKey }}
{{- else if .Values.redis.external.password }}
- name: REDIS_PASSWORD
value: {{ .Values.redis.external.password | quote }}
{{- end }}
- name: REDIS_URL
value: {{ include "twenty.redisUrl" . | quote }}
- name: SIGN_IN_PREFILLED
@@ -171,7 +156,10 @@ spec:
key: accessToken
{{- $storageEnv := (include "twenty.storageEnv" .) }}
{{- if $storageEnv }}
{{ $storageEnv | nindent 12 }}
{{- $storageEnv | nindent 12 }}
{{- end }}
{{- with .Values.server.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http-tcp
@@ -67,6 +67,16 @@ spec:
name: {{ include "twenty.dbUrl.secretName" . }}
key: url
{{- end }}
{{- if eq (include "twenty.redis.useExternalSecret" .) "true" }}
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.redis.external.secretName }}
key: {{ .Values.redis.external.passwordKey }}
{{- else if .Values.redis.external.password }}
- name: REDIS_PASSWORD
value: {{ .Values.redis.external.password | quote }}
{{- end }}
- name: REDIS_URL
value: {{ include "twenty.redisUrl" . | quote }}
- name: STORAGE_TYPE
@@ -76,9 +86,12 @@ spec:
secretKeyRef:
name: {{ include "twenty.secret.tokens.name" . }}
key: accessToken
{{- with .Values.worker.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- $storageEnv := (include "twenty.storageEnv" .) }}
{{- if $storageEnv }}
{{ $storageEnv | nindent 12 }}
{{- $storageEnv | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.worker.resources | nindent 12 }}
@@ -0,0 +1,20 @@
{{- if and .Values.redisInternal.enabled .Values.redisInternal.persistence.enabled (not .Values.redisInternal.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "twenty.fullname" . }}-redis
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
spec:
accessModes:
{{ toYaml .Values.redisInternal.persistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.redisInternal.persistence.size }}
{{- if .Values.redisInternal.persistence.storageClass }}
storageClassName: {{ .Values.redisInternal.persistence.storageClass }}
{{- end }}
{{- end }}
@@ -0,0 +1,58 @@
suite: env mapping
templates:
- templates/deployment-server.yaml
- templates/deployment-worker.yaml
release:
name: my-twenty
namespace: default
tests:
- it: renders server extraEnv with plain value
template: templates/deployment-server.yaml
set:
server.extraEnv:
- name: FEATURE_X_ENABLED
value: "true"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: FEATURE_X_ENABLED
value: "true"
- it: renders server extraEnv with valueFrom
template: templates/deployment-server.yaml
set:
server.extraEnv:
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: smtp-creds
key: password
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: smtp-creds
key: password
- it: renders worker extraEnv with valueFrom
template: templates/deployment-worker.yaml
set:
worker.extraEnv:
- name: CUSTOM_SECRET
valueFrom:
secretKeyRef:
name: my-secret
key: custom
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: CUSTOM_SECRET
valueFrom:
secretKeyRef:
name: my-secret
key: custom
@@ -0,0 +1,89 @@
suite: redis external authentication
templates:
- templates/deployment-server.yaml
- templates/deployment-worker.yaml
release:
name: my-twenty
namespace: default
tests:
- it: injects REDIS_PASSWORD from external secret into server
template: templates/deployment-server.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.secretName: redis-creds
redis.external.passwordKey: password
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: redis-creds
key: password
- it: injects REDIS_PASSWORD from external secret into worker
template: templates/deployment-worker.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.secretName: redis-creds
redis.external.passwordKey: password
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: redis-creds
key: password
- it: injects plaintext REDIS_PASSWORD when password set directly in server
template: templates/deployment-server.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.password: "s3cr3t"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
value: "s3cr3t"
- it: injects plaintext REDIS_PASSWORD when password set directly in worker
template: templates/deployment-worker.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.password: "s3cr3t"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
value: "s3cr3t"
- it: does not inject REDIS_PASSWORD into server when using internal redis
template: templates/deployment-server.yaml
set:
redisInternal.enabled: true
asserts:
- notContains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
any: true
- it: does not inject REDIS_PASSWORD into worker when using internal redis
template: templates/deployment-worker.yaml
set:
redisInternal.enabled: true
asserts:
- notContains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
any: true
@@ -105,18 +105,3 @@ tests:
path: spec.template.spec.initContainers[?(@.name=="ensure-database-exists")].command[2]
pattern: ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES
# TypeORM Migration Tests
# ======================
# TypeORM migrations are configured to use the core.datasource which targets the
# 'core' schema. This ensures the _typeorm_migrations table and all application
# tables use the dedicated core schema.
- it: migrations run against core datasource
template: templates/deployment-server.yaml
set:
db.enabled: true
asserts:
- matchRegex:
path: spec.template.spec.initContainers[?(@.name=="run-migrations")].command[2]
pattern: core\.datasource

Some files were not shown because too many files have changed in this diff Show More