Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code d2746d2778 UpdateWorkflowVersionStep returns unvalidated position object causing GraphQL non-nullable field error
https://sonarly.com/issue/4115?type=bug

The UpdateWorkflowVersionStep mutation returns a step object with an invalid `position` field (non-null object with null/undefined `x` and `y`), causing GraphQL to throw a non-nullable field violation on `WorkflowStepPosition.x`.

Fix: Added a `sanitizeStepPosition` private method and position normalization logic to `WorkflowVersionStepUpdateWorkspaceService.updateWorkflowVersionStep()`.

**What changed:**

1. **Position fallback** (line 76-78): After obtaining `updatedStep` from either the type-changed or settings-only path, the code now resolves position using `updatedStep.position ?? existingStep.position`. This means if the client input doesn't include position (common for settings-only updates), the existing position from the database is preserved rather than lost.

2. **Position sanitization** (lines 108-120): A new `sanitizeStepPosition` method validates that if position is defined, both `x` and `y` must be actual numbers (`typeof === 'number'`). If either is missing, null, undefined, or non-numeric, the entire position is set to `undefined` — which GraphQL correctly resolves as `null` for the nullable `position` field on `WorkflowActionDTO`. This prevents the `Cannot return null for non-nullable field WorkflowStepPosition.x` error.

3. **Normalized step used everywhere** (lines 80-105): The `normalizedUpdatedStep` (with sanitized position) is used both for saving to the database and as the GraphQL return value, ensuring consistency between what's persisted and what's returned.

The fix is applied at the single return point of the public `updateWorkflowVersionStep` method, covering both the type-changed path (where `existingStep.position` from DB could be corrupted) and the settings-only path (where client input typically omits position).
2026-03-06 10:55:27 +00:00
Sonarly Claude Code cb05c538b3 chore: improve monitoring for Missing exception filter causes expected FORBIDDEN
Registered the new `WorkflowQueryValidationGraphqlApiExceptionFilter` on two resolvers that call code paths throwing `WorkflowQueryValidationException`:

1. **`workflow-version-step.resolver.ts`** — handles `UpdateWorkflowVersionStep` (the mutation in this Sentry error)
2. **`workflow-version-edge.resolver.ts`** — handles edge operations that also call `getValidatedDraftWorkflowVersion`

With the filter registered, `WorkflowQueryValidationException` is now converted to a `ForbiddenError` (`BaseGraphQLError` with `ErrorCode.FORBIDDEN`). The `shouldCaptureException` function in the GraphQL error handler hook will now correctly filter this out (since `FORBIDDEN` is in `graphQLErrorCodesToFilter`), eliminating the Sentry noise from this expected validation error.
2026-03-06 10:54:05 +00:00
Sonarly Claude Code 0d151571c8 Missing exception filter causes expected FORBIDDEN validation error to be captured by Sentry
https://sonarly.com/issue/3892?type=bug

The `WorkflowQueryValidationException` thrown by `assertWorkflowVersionIsDraft` has no NestJS exception filter to convert it to a proper GraphQL error, causing it to bypass Sentry's error filtering and be incorrectly reported as an unhandled error. The underlying trigger is a frontend race condition where stale React state sends a step update mutation for a workflow version that is no longer in DRAFT status.

Fix: Created a new `WorkflowQueryValidationGraphqlApiExceptionFilter` that catches `WorkflowQueryValidationException` and converts it to a proper `ForbiddenError` (a `BaseGraphQLError`). This follows the exact same pattern as the three existing workflow exception filters in the codebase.

**Why this fixes the issue:** The `WorkflowQueryValidationException` (thrown by `assertWorkflowVersionIsDraft`) was not caught by any exception filter on the workflow resolvers. Without a filter, the exception bypassed the `shouldCaptureException` logic in the global error handler (which only filters `HttpException`, `GraphQLError`, and `BaseGraphQLError` types), causing every instance to be captured by Sentry as if it were an unhandled server error.

The new filter converts `WorkflowQueryValidationException` → `ForbiddenError` (which extends `BaseGraphQLError` with `ErrorCode.FORBIDDEN`). Since `ErrorCode.FORBIDDEN` is in `graphQLErrorCodesToFilter`, the `shouldCaptureException` function will now correctly return `false`, preventing Sentry capture.

The user-facing behavior remains correct: the `ForbiddenError` is still returned as a GraphQL error with the user-friendly message, so the frontend still receives and handles it properly.

**Note:** The underlying frontend race condition (stale React closure in `useGetUpdatableWorkflowVersionOrThrow`) still exists. This fix correctly classifies the exception as an expected validation error rather than masking it.
2026-03-06 10:54:05 +00:00
BugIsGodandGitHub 1c898f36d6 Fix workflow nodes color in dark mode (#18456)
## Why
`ThemeProvider` already exposes `colorScheme` (`'light' | 'dark'`) via
`ThemeContext`, but `WorkflowDiagramCanvasBase` was only
extracting`theme` and never passing `colorMode` to `ReactFlow`.
Fix: #18453 

## Before
<img width="1384" height="745" alt="image"
src="https://github.com/user-attachments/assets/e50db287-c27b-4157-a5c6-59f2d6eab656"
/>

## After

<img width="1413" height="747" alt="image"
src="https://github.com/user-attachments/assets/db98f048-29fd-41bc-a3ae-cec7435dc93b"
/>
2026-03-06 11:06:52 +01:00
Charles BochetandGitHub 364c944ca6 Improve build performance 2x (#18449)
## Summary

Front Before:
<img width="1199" height="670" alt="image"
src="https://github.com/user-attachments/assets/b978f67c-c0a6-49fc-bedd-a443f11c365d"
/>

Front After:
<img width="1199" height="670" alt="image"
src="https://github.com/user-attachments/assets/a4939dbb-a8b4-4c74-978c-daa7f27d00f3"
/>


Server Before:
<img width="1199" height="670" alt="image"
src="https://github.com/user-attachments/assets/da53e97f-ec65-4224-a656-ca41040aef6e"
/>


Server After:
<img width="1199" height="670" alt="image"
src="https://github.com/user-attachments/assets/8cdf3885-f515-4d6c-989f-a421a4e8206c"
/>


### CI Server Pipeline Restructuring
- Split monolithic `server-setup` job into three parallel jobs:
`server-build`, `server-lint-typecheck`, and `server-validation`
- `server-build` only handles build + Nx cache save (~1m vs old 3.5m),
unblocking downstream jobs faster
- `server-lint-typecheck` runs in parallel with no DB dependency
- `server-validation` handles DB setup, migration checks, and GraphQL
generation checks in parallel with tests
- Make `server-test` (unit tests) fully independent — no longer waits
for server-setup, builds its own artifacts
- Increase integration test shards from 8 to 10 for better parallelism
- Expected critical path reduction: ~10m → ~7m (~30% faster)

### CI Front Pipeline Improvements
- Use artifact upload/download for storybook build instead of rebuilding
in test shards
- Serve pre-built storybook via `http-server` in test jobs, with
`STORYBOOK_URL` env var
- Update `vitest.config.ts` to use `storybookUrl` when `STORYBOOK_URL`
is set
- Remove redundant `twenty-shared`, `twenty-ui`, `twenty-sdk` builds
from storybook test shards

### Vite Build Optimizations
- Conditionally enable `rollup-plugin-visualizer` behind `ANALYZE=true`
env var (not loaded by default)
- Broaden Istanbul coverage exclusions to skip test files, stories,
mocks, and decorators
- Remove `@tabler/icons-react` alias from twenty-front and storybook
configs
- Bundle `@tabler/icons-react` into twenty-ui instead of treating it as
an external dependency
- Add lazy loading with `React.lazy` + `Suspense` for all page-level
route components in `useCreateAppRouter`
2026-03-06 11:02:26 +01:00
aa062644c8 Fixed scrollbar height issue in Kanban view and adjusted the calendar view to adjust with the new change (#18367)
fix for #18331 

## Issue 
The height of the container for the kanban board and calendar was set
after calculating the offset from the top bar which contains the
filters. The main issue was that it was calculated wrong. To fix this, I
have added flex:1 to ensure that the board and calendar will grow into
the empty space

## Proof of successful change
The video below shows that the scrollbar is accessible now and that the
calendar view is also adjusted to not cause any errors because of the
new change introduced.


https://github.com/user-attachments/assets/7f58342f-6cbf-4d30-878a-ec57f1e6666a

---------

Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
2026-03-06 10:24:49 +01:00
3d7cb4499f i18n - translations (#18454)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-06 08:53:00 +01:00
9d808302aa i18n - docs translations (#18452)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-06 08:52:50 +01:00
Félix MalfaitandGitHub 514d0017ea Refactor application module architecture for clarity and explicitness (#18432)
## Summary

- **Module reorganization**: Moved `ApplicationUpgradeService` and cron
jobs to `application-upgrade/`, `ApplicationSyncService` to
`application-manifest/`, and
`runWorkspaceMigration`/`uninstallApplication` mutations to the manifest
resolver — each module now has a single clear responsibility.
- **Explicit install flow**: Removed implicit `ApplicationEntity`
creation from `ApplicationSyncService`. The install service and dev
resolver now explicitly create the `ApplicationEntity` before syncing.
npm packages are resolved at registration time to extract manifest
metadata (universalIdentifier, name, description, etc.), eliminating the
`reconcileUniversalIdentifier` hack.
- **Better error handling**: Frontend hooks now surface actual server
error messages in snackbars instead of swallowing them. Replaced the
ugly `ConfirmationModal` for transfer ownership with a proper form
modal. Fixed `SettingsAdminTableCard` row height overflow and corrected
the `yarn-engine` asset path.

## Test plan
- [ ] Register an npm package — verify manifest metadata (name,
description, universalIdentifier) is extracted correctly
- [ ] Install a registered npm app on a workspace — verify
ApplicationEntity is created and sync succeeds
- [ ] Test `app:dev` CLI flow — verify local app registration and sync
work
- [ ] Upload a tarball — verify registration and install flow
- [ ] Transfer ownership — verify the new modal UX works
- [ ] Verify error messages appear correctly in snackbars when
operations fail


Made with [Cursor](https://cursor.com)
2026-03-06 08:45:08 +01:00
90cced0e74 i18n - docs translations (#18451)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-06 02:24:32 +01:00
4d0b8a8644 i18n - translations (#18450)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-06 02:23:59 +01:00
Charles BochetandGitHub 9d57bc39e5 Migrate from ESLint to OxLint (#18443)
## Summary

Fully replaces ESLint with OxLint across the entire monorepo:

- **Replaced all ESLint configs** (`eslint.config.mjs`) with OxLint
configs (`.oxlintrc.json`) for every package: `twenty-front`,
`twenty-server`, `twenty-emails`, `twenty-ui`, `twenty-shared`,
`twenty-sdk`, `twenty-zapier`, `twenty-docs`, `twenty-website`,
`twenty-apps/*`, `create-twenty-app`
- **Migrated custom lint rules** from ESLint plugin format to OxLint JS
plugin system (`@oxlint/plugins`), including
`styled-components-prefixed-with-styled`, `no-hardcoded-colors`,
`sort-css-properties-alphabetically`,
`graphql-resolvers-should-be-guarded`,
`rest-api-methods-should-be-guarded`, `max-consts-per-file`, and
Jotai-related rules
- **Migrated custom rule tests** from ESLint `RuleTester` + Jest to
`oxlint/plugins-dev` `RuleTester` + Vitest
- **Removed all ESLint dependencies** from `package.json` files and
regenerated lockfiles
- **Updated Nx targets** (`lint`, `lint:diff-with-main`, `fmt`) in
`nx.json` and per-project `project.json` to use `oxlint` commands with
proper `dependsOn` for plugin builds
- **Updated CI workflows** (`.github/workflows/ci-*.yaml`) — no more
ESLint executor
- **Updated IDE setup**: replaced `dbaeumer.vscode-eslint` with
`oxc.oxc-vscode` extension, configured `source.fixAll.oxc` and
format-on-save with Prettier
- **Replaced all `eslint-disable` comments** with `oxlint-disable`
equivalents across the codebase
- **Updated docs** (`twenty-docs`) to reference OxLint instead of ESLint
- **Renamed** `twenty-eslint-rules` package to `twenty-oxlint-rules`

### Temporarily disabled rules (tracked in `OXLINT_MIGRATION_TODO.md`)

| Rule | Package | Violations | Auto-fixable |
|------|---------|-----------|-------------|
| `twenty/sort-css-properties-alphabetically` | twenty-front | 578 | Yes
|
| `typescript/consistent-type-imports` | twenty-server | 3814 | Yes |
| `twenty/max-consts-per-file` | twenty-server | 94 | No |

### Dropped plugins (no OxLint equivalent)

`eslint-plugin-project-structure`, `lingui/*`, `@stylistic/*`,
`import/order`, `prefer-arrow/prefer-arrow-functions`,
`eslint-plugin-mdx`, `@next/eslint-plugin-next`,
`eslint-plugin-storybook`, `eslint-plugin-react-refresh`. Partial
coverage for `jsx-a11y` and `unused-imports`.

### Additional fixes (pre-existing issues exposed by merge)

- Fixed `EmailThreadPreview.tsx` broken import from main rename
(`useOpenEmailThreadInSidePanel`)
- Restored truthiness guard in `getActivityTargetObjectRecords.ts`
- Fixed `AgentTurnResolver` return types to match entity (virtual
`fileMediaType`/`fileUrl` are resolved via `@ResolveField()`)

## Test plan

- [x] `npx nx lint twenty-front` passes
- [x] `npx nx lint twenty-server` passes
- [x] `npx nx lint twenty-docs` passes
- [x] Custom oxlint rules validated with Vitest: `npx nx test
twenty-oxlint-rules`
- [x] `npx nx typecheck twenty-front` passes
- [x] `npx nx typecheck twenty-server` passes
- [x] CI workflows trigger correctly with `dependsOn:
["twenty-oxlint-rules:build"]`
- [x] IDE linting works with `oxc.oxc-vscode` extension
2026-03-06 01:03:50 +01:00
Charles BochetandGitHub b421efbff7 fix: remove add record on workflow runs/versions (#18448)
## Summary

- Disable manual record creation (add button, + header button, add new
row) for **WorkflowRun** and **WorkflowVersion** objects since these are
system-managed and should not be created manually
- Fix vertical centering of the record table empty state placeholder
(regression from `styled(Component)` refactor in #18430 — the wrapper
lost `height: 100%` / `width: 100%`)

## Test plan

- [ ] Navigate to Workflow Runs index page → empty state should show
centered placeholder **without** "Add a Workflow Run" button
- [ ] Navigate to Workflow Versions index page → empty state should show
centered placeholder **without** "Add a Workflow Version" button
- [ ] Navigate to any other object index page (e.g. People, Companies) →
empty state should still show the "Add a ..." button and be centered
- [ ] Verify the + button in the record table header is hidden for
workflow runs/versions
- [ ] Verify the "Add New" row at the bottom of the table is hidden for
workflow runs/versions
2026-03-05 23:57:34 +01:00
Charles BochetandGitHub e27a8b5107 Fix Workflow layout show page (#18447)
## Summary

Fixes the workflow show page being blank after the `styled(Component)`
removal in #18430.

- PR #18430 replaced `styled(PageBody)` with a plain `div` wrapper
(`StyledPageBodyForDesktopContainer`) around `PageBody`, but the wrapper
defaulted to `display: block`
- `PageBody`'s internal container uses `flex: 1 1 auto` to size itself,
which requires a flex parent — the block wrapper broke height
propagation, causing React Flow's container to have 0 height
- Added `display: flex; flex-direction: column` to both
`StyledPageBodyForDesktopContainer` and
`StyledPageBodyForMobileContainer` to restore the flex chain

## Test plan

- [x] Open a workflow record show page → diagram nodes are visible
- [x] Open a company/person record show page → fields, tabs, and content
render correctly
- [x] Lint and typecheck pass
2026-03-05 23:02:32 +01:00
Charles BochetandGitHub 4797f97a95 fix: vertical alignment of +N More tab overflow button (#18446)
## Summary
- Add `align-items: center` to tab list `StyledContainer` so the
overflow button aligns vertically with tabs
- Remove ineffective `> * { height }` hack from `TabMoreButton` (was
being reset by `all: unset` in `StyledTabButton`)

## Test plan
- Open a record detail page with enough tabs to trigger the "+N More"
overflow
- Verify the overflow button is vertically centered with the visible
tabs
2026-03-05 22:20:14 +01:00
2f3399fd5f i18n - docs translations (#18445)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-05 21:33:59 +01:00
61f9cf9260 i18n - translations (#18442)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-05 20:37:14 +01:00
ef003fb929 fix blocklist (#18332)
- The schema generator marked both the FK scalar and connect relation
input as required for non-nullable `MANY_TO_ONE` relations, but the
resolver rejects when both are provided making create mutations
impossible
- Fixed by making the connect input always optional in create input
types (the FK scalar still enforces the constraint)
- Added `createOne` pre-query hook for blocklist with ownership
validation


https://github.com/user-attachments/assets/aaae83d4-4747-4d16-a87c-8d8cad79d25d

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-05 20:26:04 +01:00
Baptiste DevessierandGitHub 62a634831d feat: create specialized component for header (#18438)
## Before



https://github.com/user-attachments/assets/afb6ff1d-2489-42b8-80ea-8f6dbc032629



## After


https://github.com/user-attachments/assets/6cf87609-bb0f-4bc2-8273-bce2f226aec2
2026-03-05 19:57:46 +01:00
1ec7244d1b i18n - docs translations (#18440)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-05 19:54:32 +01:00
Lucas BordeauandGitHub 13e569eaac Removed z-index dynamic logic for table (#18436)
This PR removes the complex logic that was used to manage z-index
switching to have the hovered cell portal correctly behave when its
borders were overlaping cells ones.

We now have the hovered portal inside a cell, thus removing the need for
a z-index dynamic logic.

The code has been simplified in the parts where the logic was
implemented and the constant that holds the all z indices for the tables
is still needed but with less options.
2026-03-05 19:38:05 +01:00
Charles BochetandGitHub 1affa1e004 chore(front): remove vite-plugin-checker background TS/ESLint checks (#18437)
## Summary

Removes `vite-plugin-checker` and all references to
`VITE_DISABLE_TYPESCRIPT_CHECKER` / `VITE_DISABLE_ESLINT_CHECKER`.

These background checks are no longer needed because our dev experience
now relies on **independent** linters and type-checkers:
- `npx nx lint:diff-with-main twenty-front` for ESLint
- `npx nx typecheck twenty-front` for TypeScript

Running these as separate processes (rather than inside Vite) is faster,
gives cleaner output, and avoids the significant memory overhead that
`vite-plugin-checker` introduces during `vite dev` and `vite build`. The
old env vars to disable them are removed from `vite.config.ts`,
`package.json` scripts, `nx.json`, `.env.example`, and all translated
docs.
2026-03-05 18:55:04 +01:00
Charles BochetandGitHub c53a13417e Remove all styled(Component) patterns in favor of parent wrappers and props (#18430)
## Summary

Eliminates all ~350 `styled(Component)` usages across `twenty-front` and
`twenty-ui` (212 files changed). Each was replaced following these
rules:

- **Margin/layout CSS** (margin, padding, flex, align-self, width) →
wrapped in a `styled.div`/`styled.span` parent container
- **Third-party components** (Link, TextareaAutosize,
ReactPhoneNumberInput, Handle, etc.) → parent container with child CSS
selectors (`> a`, `> textarea`, `> input`, etc.)
- **Intrinsic behavior via existing props** (TableRow
`gridTemplateColumns`, TableCell `color`/`align`) → replaced
`styled(TableRow)` / `styled(TableCell)` with direct prop usage
- **Other visual overrides on twenty-ui components** (Card, Section,
TabList, Button, MenuItem, ScrollWrapper, etc.) → parent wrappers with
`> div` / `> *` child selectors
- **Extending styled.div/span** → merged all CSS into a single
`styled.div`/`styled.span`

Also adds `overflow: hidden` to parent containers wrapping
`ScrollWrapper` so scroll activates correctly with the new wrapper
structure.

### Migration patterns

| Before | After |
|--------|-------|
| `styled(Avatar)` with `margin-right` | `<StyledAvatarContainer><Avatar
/></StyledAvatarContainer>` |
| `styled(Link)` with `text-decoration: none` |
`<StyledLinkContainer><Link /></StyledLinkContainer>` with `> a { ... }`
|
| `styled(TableRow)` with `grid-template-columns` | `<TableRow
gridTemplateColumns="..." />` |
| `styled(TableCell)` with `color` / `align` | `<TableCell color={...}
align="right" />` |
| `styled(Card)` with `margin-top` | `<StyledCardContainer><Card
/></StyledCardContainer>` |
| `styled(TabList)` with `background` |
`<StyledTabListContainer><TabList /></StyledTabListContainer>` with `>
div { ... }` |
| `styled(StyledBase)` extending a `styled.div` | Single merged
`styled.div` with all styles inlined |
2026-03-05 18:16:25 +01:00
Lucas BordeauandGitHub e5e3132ddd Add ESLint rules to disallow jotaiStore and direct atomFamily usage in selectors (#18422)
Introduce two new ESLint rules that prevent the use of `jotaiStore` and
direct calls to `.atomFamily()` or `.selectorFamily()` within component
selector `get` callbacks.

These rules promote cleaner and more reactive code practices.

Fixed file touched by those new rules :
`calendarDayRecordIdsComponentFamilySelector`
2026-03-05 17:58:52 +01:00
Baptiste DevessierandGitHub 4965790ecc Backfill record page layouts for custom objects (#18428) 2026-03-05 17:52:21 +01:00
fc2b1de860 fix: composite field sub-menu not showing in advanced filter (#18395)
## Problem
While working on the IS/IS_NOT filter feature (#15317 ), I found this
problem. So I want to submit a separate pr to fix it at first.
In the advanced filter, clicking on a composite field (Emails, Phones,
Links) was not showing the sub-field selection menu.

  ## Root cause
Related to #18178 (Recoil → Jotai migration).
`AdvancedFilterFieldSelectMenu` was writing composite field states using
`advancedFilterFieldSelectDropdownId` as the instance ID. But the reader
components (`AdvancedFilterFieldSelectDropdownContent`,
`AdvancedFilterSubFieldSelectMenu`) resolve the instance ID from React
context, which has a different value — so they were reading from a
different Jotai atom instance and `isSelectingCompositeField` was always
`false`.

  ## Fix
Remove the 3 explicit instance IDs so the writer uses context, matching
the readers.

 ## Before


https://github.com/user-attachments/assets/dde54077-0eaf-453c-a638-bec6d6fe4d55

## After


https://github.com/user-attachments/assets/01916bcf-0ee8-49ad-bb54-9d8f10571069


Hope I understood it correctly.

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-03-05 17:51:59 +01:00
12257f4cc7 i18n - translations (#18429)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-05 17:51:21 +01:00
Paul RastoinandGitHub 57d8954973 [SDK] Pure ESM (#18427)
# Introduction
While testing the sdk and overall apps in
https://github.com/prastoin/twenty-app-hello-world
Faced a lot of pure `CJS` external dependencies import issue

Replaced all the cjs deps to either esm equivalent or node native
replacement
2026-03-05 17:19:01 +01:00
WeikoandGitHub cfeea43eaf Improve workspace auth context surface (#18164) 2026-03-05 15:13:59 +00:00
nitinandGitHub 5853891b02 refactor!: rename Command Menu page/navigation layer to Side Panel (#18393) 2026-03-05 15:46:31 +01:00
Paul RastoinandGitHub 38ad0820c0 Fix server logs leak (#18423)
# Introduction

Previously the auth jwt stragegy would lod the whole user entity in the
auth user context
On an exception it would completely get logged on the pods


## Security layer
- 0/ Updating the type system ( devxp only though )
- 1/ The jwt auth stragegy only load a specific sub set of the user
entity
- 2/ Sanitizing at the exception log level directly in case of a user
context
- 3/ Sanitizing at the console driver

The last two sanitization could sound a bit redundant though they're
still good fallback to keep in case new path occurs in the cb
2026-03-05 14:40:23 +01:00
Charles BochetandGitHub 647c32ff3e Deprecate runtime theme objects in favor of CSS variables (#18402)
## Summary

- **Eliminate `ICON_SIZES` / `ICON_STROKES` constants**: all icon
dimensions are now resolved at runtime via
`resolveThemeVariableAsNumber(themeCssVariables.icon.size.X)`, ensuring
values always come from computed CSS variables
- **No more consumer imports from `twenty-ui/theme`**: moved
`ColorSchemeContext`, `ColorSchemeProvider`, `ThemeColor`,
`MAIN_COLOR_NAMES`, `getNextThemeColor`, `AnimationDuration` to
`twenty-ui/theme-constants`
- **Remove `ThemeContext` / `ThemeContextProvider` / `ThemeProvider` /
`ThemeType`**: replaced across ~300 files with `themeCssVariables` (for
CSS contexts) or `resolveThemeVariable` / `resolveThemeVariableAsNumber`
(for JS runtime values)
- **Simplify provider chain**: only `ColorSchemeProvider` remains — it
toggles `light`/`dark` class on `document.documentElement` and provides
`colorScheme` via React context
- **Fix pre-existing test failures**: `useIcons.test.ts`
(non-configurable ES module spy) and
`turnRecordFilterGroupIntoGqlOperationFilter.test.ts`
(`Omit<RecordFilter, 'id'>` type mismatch)

### Theme access pattern (before → after)

| Context | Before | After |
|---------|--------|-------|
| CSS (Linaria) | `${({ theme }) => theme.font.color.primary}` |
`${themeCssVariables.font.color.primary}` |
| JS runtime (icon size, animation) | `theme.icon.size.md` /
`ICON_SIZES.md` |
`resolveThemeVariableAsNumber(themeCssVariables.icon.size.md)` |
| Color scheme check | `theme.name === 'dark'` |
`useContext(ColorSchemeContext).colorScheme === 'dark'` |
2026-03-05 14:39:01 +01:00
martmullandGitHub 7293d4c1f8 Fix missing test input values (#18424)
- refactor
- fix issue
2026-03-05 14:36:36 +01:00
martmullandGitHub 1acbf28316 Only update value at creation (#18350)
as title
2026-03-05 13:08:17 +00:00
Charles BochetandGitHub 1decd40eea Remove unecessary queries for aggregate (#18421)
As per title.
2026-03-05 13:24:56 +01:00
1b9d188e4a Added SSE effect for view relations objects (#18386)
This PR adds what is necessary for having SSE working for view relations
: fields, filters, filter groups and sorts.

This should allow to have AI working well while creating views with
detailed filtering and sorting.

## Demo


https://github.com/user-attachments/assets/026c7fb5-8e1a-4498-b7f4-d16993e5a7c4

## Fixes

Also fixed in this PR while working on the filter area : 
- Advanced filter does not update
- Advanced filter sub field selection is broken (due to Jotai migration)
- No view fields when creating a new view
- Error on advanced filter deletion (cascade delete wasn't taken into
account on the frontend)
- Bug advanced filter creation

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-03-05 12:20:33 +01:00
Baptiste DevessierandGitHub 57499342f1 Set widget position's type according to parent tab (#18411)
Fixes workspaces seeded a few weeks ago and containing position=NULL
widgets
2026-03-05 11:48:40 +01:00
ecbc0ac013 i18n - translations (#18419)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-05 11:47:29 +01:00
Abdullah.andGitHub c571473d67 fix: SVGO DoS through entity expansion in DOCTYPE (#18416)
Resolves [Dependabot Alert
604](https://github.com/twentyhq/twenty/security/dependabot/604) and
[Dependabot Alert
605](https://github.com/twentyhq/twenty/security/dependabot/605).
2026-03-05 11:43:17 +01:00
2a82df7073 AI tools to create a demo workspace (#18236)
This PR adds the necessary tool to create a demo workspace with :
relevant custom objects and fields, mock data and a real dashboard with
graph widgets.

It is still a bit under-optimized and slow but it works.

This PR also adds an AI tool that allows to see what happens in real
time, it navigates the app and waits when necessary.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-03-05 11:39:31 +01:00
Thomas TrompetteandGitHub a2f80d882b Stop catching all workflow errors (#18392)
Steps now throw WorkflowStepExecutorException. Then workflow executor
decides if error should be catch or not.

Since tools are not only used in workflow and these do not throw, we may
still miss errors here.

Workflow jobs now only catch errors to end the workflow run and throw.
2026-03-05 11:24:36 +01:00
Raphaël BosiandGitHub abd9709291 Update Command Menu Item entity (#18391)
Closes https://github.com/twentyhq/core-team-issues/issues/2256
2026-03-05 11:21:56 +01:00
9d4ff7820d i18n - translations (#18415)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-05 11:14:03 +01:00
nitinandGitHub 4cfd738312 Headless action modal (#18270)
https://github.com/user-attachments/assets/809a281f-3c38-41df-99db-e780941acf9f
2026-03-05 11:13:46 +01:00
Félix MalfaitandGitHub 0e89c96170 feat: add npm and tarball app distribution with upgrade mechanism (#18358)
## Summary

- **npm + tarball app distribution**: Apps can be installed from the npm
registry (public or private) or uploaded as `.tar.gz` tarballs, with
`AppRegistrationSourceType` tracking the origin
- **Upgrade mechanism**: `AppUpgradeService` checks for newer versions,
supports rollback for npm-sourced apps, and a cron job runs every 6
hours to update `latestAvailableVersion` on registrations
- **Security hardening**: Tarball extraction uses path traversal
protection, and `enableScripts: false` in `.yarnrc.yml` disables all
lifecycle scripts during `yarn install` to prevent RCE
- **Frontend**: "Install from npm" and "Upload tarball" modals, upgrade
button on app detail page, blue "Update" badge on installed apps table
when a newer version is available
- **Marketplace catalog sync**: Hourly cron job syncs a hardcoded
catalog index into `ApplicationRegistration` entities
- **Integration tests**: Coverage for install, upgrade, tarball upload,
and catalog sync flows

## Backend changes

| Area | Files |
|------|-------|
| Entity & migration | `ApplicationRegistrationEntity` (sourceType,
sourcePackage, latestAvailableVersion), `ApplicationEntity`
(applicationRegistrationId), migration |
| Services | `AppPackageResolverService`, `ApplicationInstallService`,
`AppUpgradeService`, `MarketplaceCatalogSyncService` |
| Cron jobs | `MarketplaceCatalogSyncCronJob` (hourly),
`AppVersionCheckCronJob` (every 6h) |
| REST endpoint | `AppRegistrationUploadController` — tarball upload
with secure extraction |
| Resolver | `MarketplaceResolver` — simplified `installMarketplaceApp`
(removed redundant `sourcePackage` arg) |
| Security | `.yarnrc.yml` — `enableScripts: false` to block postinstall
RCE |

## Frontend changes

| Area | Files |
|------|-------|
| Modals | `SettingsInstallNpmAppModal`, `SettingsUploadTarballModal`,
`SettingsAppModalLayout` |
| Hooks | `useUploadAppTarball`, `useInstallMarketplaceApp` (cleaned up)
|
| Upgrade UI | `SettingsApplicationVersionContainer`,
`SettingsApplicationDetailAboutTab` |
| Badge | `SettingsApplicationTableRow` — blue "Update" tag,
`SettingsApplicationsInstalledTab` — fetches registrations for version
comparison |
| Styling | Migrated to Linaria (matching main) |

## Test plan

- [ ] Install an app from npm via the "Install from npm" modal
- [ ] Upload a `.tar.gz` tarball via the "Upload tarball" modal
- [ ] Verify upgrade badge appears when `latestAvailableVersion >
version`
- [ ] Verify upgrade flow from app detail page
- [ ] Run integration tests: `app-distribution.integration-spec.ts`,
`marketplace-catalog-sync.integration-spec.ts`
- [ ] Verify `enableScripts: false` blocks postinstall scripts during
yarn install


Made with [Cursor](https://cursor.com)
2026-03-05 10:34:08 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Abdullah
bfa50f566e Bump @clickhouse/client from 1.11.0 to 1.18.1 (#18410)
Bumps [@clickhouse/client](https://github.com/ClickHouse/clickhouse-js)
from 1.11.0 to 1.18.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/ClickHouse/clickhouse-js/releases"><code>@​clickhouse/client</code>'s
releases</a>.</em></p>
<blockquote>
<h2>1.18.1</h2>
<h2>Improvements</h2>
<ul>
<li>Setting <code>log.level</code> default value to
<code>ClickHouseLogLevel.WARN</code> instead of
<code>ClickHouseLogLevel.OFF</code> to provide better visibility into
potential issues without overwhelming users with too much information by
default.</li>
</ul>
<pre lang="ts"><code>const client = createClient({
  // ...
  log: {
level: ClickHouseLogLevel.WARN, // default is now
ClickHouseLogLevel.WARN instead of ClickHouseLogLevel.OFF
  },
})
</code></pre>
<ul>
<li>Logging is now lazy, which means that the log messages will only be
constructed if the log level is appropriate for the message. This can
improve performance in cases where constructing the log message is
expensive, and the log level is set to ignore such messages. See
<code>ClickHouseLogLevel</code> enum for the complete list of log
levels. (<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/520">#520</a>)</li>
</ul>
<pre lang="ts"><code>const client = createClient({
  // ...
  log: {
level: ClickHouseLogLevel.TRACE, // to log everything available down to
the network level events
  },
})
</code></pre>
<ul>
<li>Enhanced the logging of the HTTP request / socket lifecycle with
additional trace messages and context such as Connection ID (UUID) and
Request ID and Socket ID that embed the connection ID for ease of
tracing the logs of a particular request across the connection
lifecycle. To enable such logs, set the <code>log.level</code> config
option to <code>ClickHouseLogLevel.TRACE</code>. (<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/567">#567</a>)</li>
</ul>
<pre
lang="console"><code>[2026-02-25T09:19:13.511Z][TRACE][@clickhouse/client][Connection]
Insert: received 'close' event, 'free' listener removed
Arguments: {
  operation: 'Insert',
  connection_id: 'da3c9796-5dc5-46ef-83b0-ed1f4422094c',
  query_id: '9dfda627-39a2-41a6-9fc9-8f8716574826',
  request_id: 'da3c9796-5dc5-46ef-83b0-ed1f4422094c:3',
  socket_id: 'da3c9796-5dc5-46ef-83b0-ed1f4422094c:2',
  event: 'close'
}
[2026-02-25T09:19:13.502Z][TRACE][@clickhouse/client][Connection] Query:
reusing socket
Arguments: {
  operation: 'Query',
  connection_id: 'da3c9796-5dc5-46ef-83b0-ed1f4422094c',
  query_id: 'ad0127e8-b1c7-4ed6-9681-c0162f7a0ea9',
  request_id: 'da3c9796-5dc5-46ef-83b0-ed1f4422094c:4',
  socket_id: 'da3c9796-5dc5-46ef-83b0-ed1f4422094c:2',
  usage_count: 1
}
</code></pre>
<ul>
<li>A step towards structured logging: the client now passes rich
context to the logger <code>args</code> parameter (e.g.
<code>connection_id</code>, <code>query_id</code>,
<code>request_id</code>, <code>socket_id</code>). (<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/576">#576</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ClickHouse/clickhouse-js/blob/main/CHANGELOG.md"><code>@​clickhouse/client</code>'s
changelog</a>.</em></p>
<blockquote>
<h1>1.18.1</h1>
<h2>Improvements</h2>
<ul>
<li>Setting <code>log.level</code> default value to
<code>ClickHouseLogLevel.WARN</code> instead of
<code>ClickHouseLogLevel.OFF</code> to provide better visibility into
potential issues without overwhelming users with too much information by
default.</li>
</ul>
<pre lang="ts"><code>const client = createClient({
  // ...
  log: {
level: ClickHouseLogLevel.WARN, // default is now
ClickHouseLogLevel.WARN instead of ClickHouseLogLevel.OFF
  },
})
</code></pre>
<ul>
<li>Logging is now lazy, which means that the log messages will only be
constructed if the log level is appropriate for the message. This can
improve performance in cases where constructing the log message is
expensive, and the log level is set to ignore such messages. See
<code>ClickHouseLogLevel</code> enum for the complete list of log
levels. (<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/520">#520</a>)</li>
</ul>
<pre lang="ts"><code>const client = createClient({
  // ...
  log: {
level: ClickHouseLogLevel.TRACE, // to log everything available down to
the network level events
  },
})
</code></pre>
<ul>
<li>Enhanced the logging of the HTTP request / socket lifecycle with
additional trace messages and context such as Connection ID (UUID) and
Request ID and Socket ID that embed the connection ID for ease of
tracing the logs of a particular request across the connection
lifecycle. To enable such logs, set the <code>log.level</code> config
option to <code>ClickHouseLogLevel.TRACE</code>. (<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/567">#567</a>)</li>
</ul>
<pre
lang="console"><code>[2026-02-25T09:19:13.511Z][TRACE][@clickhouse/client][Connection]
Insert: received 'close' event, 'free' listener removed
Arguments: {
  operation: 'Insert',
  connection_id: 'da3c9796-5dc5-46ef-83b0-ed1f4422094c',
  query_id: '9dfda627-39a2-41a6-9fc9-8f8716574826',
  request_id: 'da3c9796-5dc5-46ef-83b0-ed1f4422094c:3',
  socket_id: 'da3c9796-5dc5-46ef-83b0-ed1f4422094c:2',
  event: 'close'
}
[2026-02-25T09:19:13.502Z][TRACE][@clickhouse/client][Connection] Query:
reusing socket
Arguments: {
  operation: 'Query',
  connection_id: 'da3c9796-5dc5-46ef-83b0-ed1f4422094c',
  query_id: 'ad0127e8-b1c7-4ed6-9681-c0162f7a0ea9',
  request_id: 'da3c9796-5dc5-46ef-83b0-ed1f4422094c:4',
  socket_id: 'da3c9796-5dc5-46ef-83b0-ed1f4422094c:2',
  usage_count: 1
}
</code></pre>
<ul>
<li>A step towards structured logging: the client now passes rich
context to the logger <code>args</code> parameter (e.g.
<code>connection_id</code>, <code>query_id</code>,
<code>request_id</code>, <code>socket_id</code>). (<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/576">#576</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/ClickHouse/clickhouse-js/commit/cbdd7bf20904626956e0ff7808d17015813400c1"><code>cbdd7bf</code></a>
Release 1.18.1 (<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/590">#590</a>)</li>
<li><a
href="https://github.com/ClickHouse/clickhouse-js/commit/c9f61ebb3a2ec6201f87417e30c4fc4271451ae8"><code>c9f61eb</code></a>
Beta 1.18.0 (<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/588">#588</a>)</li>
<li><a
href="https://github.com/ClickHouse/clickhouse-js/commit/d0f67b71ef896d47fc3d8d0942612ced22aa79dc"><code>d0f67b7</code></a>
Split public and internal <code>drainStream</code> and cover with tests
(<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/578">#578</a>)</li>
<li><a
href="https://github.com/ClickHouse/clickhouse-js/commit/535e9b726e328ce8468c159f935c27910731f4bb"><code>535e9b7</code></a>
Remove <code>unsafeLogUnredactedQueries</code> for now (<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/580">#580</a>)</li>
<li><a
href="https://github.com/ClickHouse/clickhouse-js/commit/44e73c73019a3956c1fac67ce0f4f170b3a4f19a"><code>44e73c7</code></a>
Default log level to <code>WARN</code> (<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/581">#581</a>)</li>
<li><a
href="https://github.com/ClickHouse/clickhouse-js/commit/5146fbc13e5c23d08e2cc5773bea0adf58d83a5c"><code>5146fbc</code></a>
Focus AI on security and API stability (<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/579">#579</a>)</li>
<li><a
href="https://github.com/ClickHouse/clickhouse-js/commit/b7b1d8d7ffe9b6786c9e883379d3edc5a5ed5c58"><code>b7b1d8d</code></a>
Trivial E2E test against <code>beta</code> (<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/577">#577</a>)</li>
<li><a
href="https://github.com/ClickHouse/clickhouse-js/commit/761e29ebb5d1bd7a107d2535b385b6565b7120f5"><code>761e29e</code></a>
Structured logs, part 1 (<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/576">#576</a>)</li>
<li><a
href="https://github.com/ClickHouse/clickhouse-js/commit/fd23dd7fc9e91ff810a7bb45984a24682ba25482"><code>fd23dd7</code></a>
Provide more context in logs for connection and request handling (<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/567">#567</a>)</li>
<li><a
href="https://github.com/ClickHouse/clickhouse-js/commit/a7866e72e356244cae9d20d9fef38a6aafe68ba8"><code>a7866e7</code></a>
Adjusting CI DevX (<a
href="https://redirect.github.com/ClickHouse/clickhouse-js/issues/574">#574</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/ClickHouse/clickhouse-js/compare/1.11.0...1.18.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/~4b819b88c84b">4b819b88c84b</a>, a new
releaser for <code>@​clickhouse/client</code> since your current
version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@clickhouse/client&package-manager=npm_and_yarn&previous-version=1.11.0&new-version=1.18.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>
Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
2026-03-05 10:11:32 +01:00
Abdullah.andGitHub 338a38682d feat: upgrade nx to latest (#18404)
Upgraded NX to resolve some dependabot alerts caused by transitive
dependencies, but after the upgrade, it appears those transitive
dependency issues were not fixed by NX in the first place.

Creating this PR with the upgrade regardless to avoid wasted work. Used
`npx nx@latest migrate latest` from the documentation to automate the
upgrade and it bumped all the dependencies changed in `package.json` for
compatibility - `react-router-dom` and `swc` ones too.

Ran tests, ran builds, started the development server and used the
application - everything looks good after the upgrade.
2026-03-05 09:36:33 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Abdullah
6a2e0182ab Bump @blocknote/server-util from 0.47.0 to 0.47.1 (#18408)
Bumps
[@blocknote/server-util](https://github.com/TypeCellOS/BlockNote/tree/HEAD/packages/server-util)
from 0.47.0 to 0.47.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/TypeCellOS/BlockNote/releases"><code>@​blocknote/server-util</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v0.47.1</h2>
<h2>0.47.1 (2026-03-02)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li>typeerror cannot read properties of undefined (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2522">#2522</a>)</li>
<li>handle more delete key cases (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2126">#2126</a>)</li>
<li>add delay for <code>data-active</code> in collab cursors (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2383">#2383</a>)</li>
<li>disable slash menu in table content <a
href="https://github.com/TypeCellOS/BlockNote/tree/HEAD/packages/server-util/issues/2408">#2408</a>
(<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2504">#2504</a>,
<a
href="https://redirect.github.com/TypeCellOS/BlockNote/issues/2408">#2408</a>)</li>
<li><strong>ai:</strong> selections broken due to floating-ui focus
manager (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2527">#2527</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>Matthew Lipski <a
href="https://github.com/matthewlipski"><code>@​matthewlipski</code></a></li>
<li>Nick Perez</li>
<li>Yousef</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/TypeCellOS/BlockNote/blob/main/CHANGELOG.md"><code>@​blocknote/server-util</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>0.47.1 (2026-03-02)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li>typeerror cannot read properties of undefined (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2522">#2522</a>)</li>
<li>handle more delete key cases (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2126">#2126</a>)</li>
<li>add delay for <code>data-active</code> in collab cursors (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2383">#2383</a>)</li>
<li>disable slash menu in table content <a
href="https://github.com/TypeCellOS/BlockNote/tree/HEAD/packages/server-util/issues/2408">#2408</a>
(<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2504">#2504</a>,
<a
href="https://redirect.github.com/TypeCellOS/BlockNote/issues/2408">#2408</a>)</li>
<li><strong>ai:</strong> selections broken due to floating-ui focus
manager (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2527">#2527</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>Matthew Lipski <a
href="https://github.com/matthewlipski"><code>@​matthewlipski</code></a></li>
<li>Nick Perez</li>
<li>Yousef</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/TypeCellOS/BlockNote/commit/d5d056fe3d5362e73fb72e3e3bf1f839aee3e875"><code>d5d056f</code></a>
chore(release): publish 0.47.1</li>
<li>See full diff in <a
href="https://github.com/TypeCellOS/BlockNote/commits/v0.47.1/packages/server-util">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@blocknote/server-util&package-manager=npm_and_yarn&previous-version=0.47.0&new-version=0.47.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>
Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
2026-03-05 09:22:06 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Abdullah
72086fe111 Bump @dagrejs/dagre from 1.1.3 to 1.1.8 (#18409)
Bumps [@dagrejs/dagre](https://github.com/dagrejs/dagre) from 1.1.3 to
1.1.8.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/dagrejs/dagre/commit/7e4d15f191678f7f05f3c86d9071a193230e7e00"><code>7e4d15f</code></a>
Building for release</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/d3908e2c13148c9143db585accc10ae0b6634657"><code>d3908e2</code></a>
Bumping version</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/ce295f8e073c4fe96c9e36ecf08ae2940e5e6a10"><code>ce295f8</code></a>
Build for release</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/b64b9057726eee17f24f73579ba0668527276448"><code>b64b905</code></a>
Bumping the version</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/de169d24c13d06c1e9c560f4f4f8f98650109b94"><code>de169d2</code></a>
Merge pull request <a
href="https://redirect.github.com/dagrejs/dagre/issues/481">#481</a>
from Nathan-Fenner/nf/improve-network-simplex-perform...</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/065e0d8374f4c1c35a7cb4b84df37aaa31598d86"><code>065e0d8</code></a>
improve performance of graph node ranking</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/00d3178d671e49de9c032e3abd281dc9f2739e73"><code>00d3178</code></a>
Typo</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/3982a69d2b323b06aa969a4ec09829d37fe6e7bd"><code>3982a69</code></a>
Bump version and set as pre-release</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/1339f5516508dba0cbcc4ef1c0587e7384bec23d"><code>1339f55</code></a>
Building for release</li>
<li><a
href="https://github.com/dagrejs/dagre/commit/9459f01bc815f16b87db727821d8401acbad2cd3"><code>9459f01</code></a>
Bumping the version</li>
<li>Additional commits viewable in <a
href="https://github.com/dagrejs/dagre/compare/v1.1.3...v1.1.8">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@dagrejs/dagre&package-manager=npm_and_yarn&previous-version=1.1.3&new-version=1.1.8)](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>
Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
2026-03-05 09:21:45 +01:00
228865bd94 i18n - translations (#18398)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-04 23:55:59 +01:00
Abdullah.andGitHub 2493adbb87 fix: minimatch related dependabot alerts (#18396)
This PR fixes a good number of dependabot alerts associated to minimatch
transitive import.

There are a total of 28 alerts, merging this shall confirm which ones
remain open afterwards and make it easier to diagnose.
2026-03-04 23:47:02 +01:00
26f0a416a1 File storage cleaning (#18381)
- Remove feature flag
- Remove legacy methods in file-upload and file-service
- Migrate AI Chat to new file management

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-04 23:46:03 +01:00
Charles BochetandGitHub c41a8e2b23 [DevXP] Simplify twenty-ui theme system: replace auto-generated files with static CSS variables (#18389)
## Summary

Now that Twenty has fully migrated from Emotion to Linaria, the theme
system has been simplified to remove unnecessary complexity that existed
only to support the old runtime injection pattern.

### What changed

- **Deleted** `generateThemeConstants.ts` script and the entire
`generated/` directory — no more auto-generation
- **Added** `theme-light.css` and `theme-dark.css`: static CSS files
with 991 custom properties each, scoped under `.light` and `.dark`
selectors respectively
- **Moved** `themeCssVariables.ts` out of `generated/` and hand-maintain
it as a static `as const` object of `var(--t-*)` references (Linaria can
statically evaluate these at build time)
- **Extracted** numeric constants (`MOBILE_VIEWPORT`, `ICON_SIZES`,
`ICON_STROKES`) into a new `constants.ts` — CSS variables can't be used
in media queries or as numeric icon size props
- **Simplified** `ThemeContextProvider`: removed
`ThemeCssVariableInjectorEffect` entirely; now uses a single
`useLayoutEffect` to toggle `.light`/`.dark` class on `<html>`
- **Added** `class="light"` to `index.html` as default to prevent FOUC
before React hydration

### Why

The previous setup maintained a dual system: JS theme objects
(`THEME_LIGHT`/`THEME_DARK`) used at runtime, plus a generation script
that produced CSS variable entry arrays, which were then injected into
the DOM by `ThemeCssVariableInjectorEffect`. With Linaria, theme values
only need to be CSS custom properties — the JS objects were redundant.
This PR removes ~250 lines of infrastructure while keeping the same
theming capabilities.
2026-03-04 23:30:25 +01:00
neo773GitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>Charles Bochetcubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
4c001778c2 fix google signup edge case (#18365)
Fixes an edge case when a user signs up with Google and the profile
avatar network request times out, we crash instead of creating the user
without an avatar.

Added `axios-retry` to retry max 2 times and if it still fails we
gracefully skip avatar image instead of crashing

Fixes
Sentry TWENTY-SERVER-FDQ
Sonarly https://sonarly.com/issue/6564

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-03-04 22:51:55 +01:00
Thomas TrompetteandGitHub 911a46aa45 Improve workflow perfs (#18376)
Workflow crons take a few minutes to run. Loading each repo takes ~200
to 300ms locally. Adding a lite mode so it takes less than 100ms.
Also doing batch promises.

Finally, cleaning runs timeout when there are too many. Doing batches as
well.
2026-03-04 18:29:12 +01:00
Paul RastoinandGitHub c53d281960 Fix invalid universal identifier format command cache flush (#18385)
# Introduction
Invalidating command impacted metadata and related metadata caches
entries
2026-03-04 17:43:01 +01:00
7f6b270a76 i18n - translations (#18388)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-04 17:32:14 +01:00
Abdul RahmanandGitHub c94657dc0a Navbar AI chats followup (#18336)
Addresses review comments from
[PR#18161](https://github.com/twentyhq/twenty/pull/18161)
2026-03-04 15:15:43 +00:00
aeedcf3353 Enable password reset from app.twenty.com with workspace fallback (#18271)
## Summary
- add a working `Forgot your password?` flow on `app.twenty.com` sign-in
- keep existing workspace-domain reset behavior
- when triggered without workspace context, resolve a workspace from the
user when possible, otherwise fallback to `app.twenty.com` reset URL

## Backend
- make `workspaceId` optional in `emailPasswordResetLink` input
- allow nullable `workspaceId` in password reset token DTO
- update reset token generation to accept optional `workspaceId`
- when missing, resolve first workspace by user membership
- if no workspace is found, persist token with `workspaceId = null`
- send reset links via:
  - workspace URL when `workspaceId` exists
  - app front URL + reset path when `workspaceId` is null

## Frontend
- make reset-link mutation `workspaceId` variable optional
- regenerate/patched generated metadata types accordingly
- add `Forgot your password?` in global password step
- allow reset request without workspace context in
`useHandleResetPassword`
- make reset page auto sign-in domain-aware (`workspace` vs `app`)
- apply design-system spacing above the global forgot-password link
(`theme.spacing(4)`)

## Tests
- extend reset-password service tests for:
  - explicit workspace id
  - inferred workspace when workspace id is missing
  - app-domain fallback when no workspace is found
- extend reset-password hook tests for with/without workspace context
- add focused global form test for forgot-password link rendering/click
behavior

## Product behavior for users with multiple workspaces
- no workspace chooser is shown in this flow
- backend uses the first resolvable workspace membership for the
reset-link domain
- password change remains account-level and works across all workspaces


Feature has been tested and is working 

<img width="3268" height="2106" alt="CleanShot 2026-02-26 at 14 09
14@2x"
src="https://github.com/user-attachments/assets/b5db3bed-f3aa-4d35-b54e-66e4d99141f9"
/>

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-03-04 15:14:38 +00:00
Charles BochetandGitHub eda905f271 [DevXP] Improve Linaria pre-build speed (#18382)
## Summary

This PR improves Linaria/WYW pre-build speed and continues the migration
of `twenty-ui` components away from runtime `ThemeContext` reads toward
static CSS variables and theme constants.

### Linaria/WYW profiling plugin improvements (`twenty-shared`)

- **Babel JIT warmup**: added a `buildStart` warmup step that triggers
WYW's Babel JIT compilation before the real build starts, so the first
real file doesn't pay the cold-start penalty
- **`configResolved` hook**: detects dev vs prod mode and resolves the
correct warmup file path relative to `config.root`
- **Dev-only per-file logging**: slow file warnings are now gated behind
`isDevMode`, keeping production/CI build output clean
- **`closeBundle` summary**: moved the final top-slow-files report to
`closeBundle` for accurate end-of-build reporting
- **Removed noisy progress interval logging** in favor of the warmup log
+ final summary

### Migration from `ThemeContext` to static CSS variables / constants

Across `twenty-ui`, replaced runtime `useTheme()` reads with:
- `themeCssVariables` CSS custom properties (colors, spacing)
- Hard-coded design-system constants (`ICON.size.md` → `16`,
`ICON.stroke.sm` → `1.6`) so components no longer need a React context
at render time — enabling Linaria static extraction

**Components migrated:**
- `Button`, `AnimatedButton`, `LightButton`, `LightIconButton`,
`AnimatedLightIconButton`, `ButtonIcon`, `ButtonSoon`
- `ProgressBar` (Framer Motion width animation → CSS `transition`)
- `Info`, `HorizontalSeparator`, `LinkChip`
- `MenuPicker`, `MenuItemLeftContent`, `MenuItemIconWithGripSwap`,
`NavigationBarItem`
- `JsonArrow`, `JsonNestedNode`
- `ModalHeader`

### Other
- Added `aria-valuenow` to `ProgressBar` for accessibility
- `VisibilityHidden` component updated to inline accessibility styles
2026-03-04 17:04:16 +01:00
be01a85d67 i18n - translations (#18387)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-04 17:03:54 +01:00
999dcd4468 Increase size of input in test setting logic function tab (#18369)
## Before
<img width="1031" height="836" alt="image"
src="https://github.com/user-attachments/assets/475ca1be-f7c4-49d0-b329-649dbe8da489"
/>


## After

<img width="1195" height="862" alt="image"
src="https://github.com/user-attachments/assets/b1bac131-e562-4439-8f8e-bda4d6e2a646"
/>

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-04 16:49:13 +01:00
Raphaël BosiandGitHub b11f77df2a [FRONT COMPONENTS] Introduce conditionalAvailabilityExpression to command menu items (#18319)
## PR Description

- Uses `expr-eval` to enable front components (SDK plugins) to define
conditional availability as declarative expressions.
- Moves shared types and constants to `twenty-shared`
- Introduces a `conditionalAvailabilityExpression` field on
`CommandMenuItemEntity`, allowing command menu items to store an
`expr-eval` compatible expression string that is evaluated against a
CommandMenuContext to determine if the item should be shown.
- Creates an esbuild transform plugin
`conditional-availability-transform-plugin` in `twenty-sdk` that
converts TypeScript conditional availability expressions into
`expr-eval` compatible syntax at build time, so SDK developers can write
natural TS expressions that get transformed to evaluable strings.
- Removes deprecated `forceRegisteredActionsByKey` state and its usage.
- Creates `useCommandMenuContext` hook that builds the full
`CommandMenuContext` object from React state, which is then passed to
`useCommandMenuItemFrontComponentActions` for evaluating conditional
availability expressions.
2026-03-04 16:33:58 +01:00
Thomas TrompetteandGitHub f09a9cc25a Replace align-center with padding (#18384)
Toggle using align-self prevents the use of align-items

Before
<img width="323" height="54" alt="Capture d’écran 2026-03-04 à 16 11
22"
src="https://github.com/user-attachments/assets/08154592-7901-4cb4-84b8-d3091c7d5555"
/>

After
<img width="323" height="54" alt="Capture d’écran 2026-03-04 à 16 11
14"
src="https://github.com/user-attachments/assets/2a6071f6-50cd-4947-85e0-0c0a5b1685cd"
/>
2026-03-04 16:15:56 +01:00
d59d2efb8b i18n - translations (#18383)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-04 15:46:01 +01:00
nitinandGitHub 07803f232f fix: use ForbiddenException in DevelopmentGuard to prevent Sentry noise (#18378) 2026-03-04 15:36:11 +01:00
aaa483c020 i18n - translations (#18380)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-04 15:35:13 +01:00
Paul RastoinGitHubThomas TrompettebosiraphaelWeikogithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>github-actionsCharles Bochet
845a1934d3 Tt call recording app (#18281)
Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-04 14:11:57 +00:00
c97d872b9f [BREAKING_CHANGE_VIEW_SORT] Refactor view sort to v2 (#17609)
Fixes https://github.com/twentyhq/core-team-issues/issues/2187

---------

Co-authored-by: prastoin <paul@twenty.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
2026-03-04 12:48:58 +00:00
EtienneandGitHub 906a0aed38 Common API - Filter validation layer (#18187)
Closes https://github.com/twentyhq/core-team-issues/issues/1627

**FilterArgProcessor consolidation:**
Refactored to both validate AND transform filter values in a single pass
Coerced string inputs to native types (e.g., "1" → 1, "true" → true -
useful for Rest input)
Returns transformed filter instead of just validating
Removed overrideFilterByFieldMetadata calls from all computeArgs methods
**QueryRunnerArgsFactory cleanup**
**Testing:**
Add unit testing
uncomment integration tests
2026-03-04 12:10:44 +00:00
nitinandGitHub 80d054563e followup: centralize widget common properties and add widget bulk update integration tests (#18225)
followup
https://github.com/twentyhq/twenty/pull/18015#pullrequestreview-3818929035
2026-03-04 11:47:56 +00:00
Baptiste DevessierandGitHub 5b544809f7 Support ungrouped fields + improve edition UX (#18224)
## Demo


https://github.com/user-attachments/assets/59e530ea-1c5b-44be-a012-42551e68221c

## Demo – creating a new group


https://github.com/user-attachments/assets/e8511bc3-d586-422c-aca8-b02794a0c84f

## Demo – ungrouped fields


https://github.com/user-attachments/assets/6ded4a90-fb08-485e-ad08-086f3a970752

Closes https://github.com/twentyhq/core-team-issues/issues/2232
Closes https://github.com/twentyhq/core-team-issues/issues/2237
Closes https://github.com/twentyhq/core-team-issues/issues/2238
2026-03-04 11:45:14 +00:00
3418 changed files with 103241 additions and 43957 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ alwaysApply: true
## Formatting Standards
- **Prettier**: 2-space indentation, single quotes, trailing commas, semicolons
- **Print width**: 80 characters
- **ESLint**: No unused imports, consistent import ordering, prefer const over let
- **Oxlint**: No unused imports, consistent import ordering, prefer const over let
## Naming Conventions
```typescript
+53
View File
@@ -0,0 +1,53 @@
---
description: ESM dependency guidelines for twenty-sdk and create-twenty-app packages
globs: ["packages/twenty-sdk/**", "packages/create-twenty-app/**"]
alwaysApply: false
---
# ESM Dependency Guidelines
## Context
`twenty-sdk` and `create-twenty-app` are published as dual-format npm packages (ESM `.mjs` + CJS `.cjs`). Dependencies listed in `dependencies` are **externalized** by the Vite/Rollup build — they are not bundled, and consumers resolve them from `node_modules` at runtime.
This means **CJS-only dependencies break the ESM output**. When Rollup emits `import { foo } from 'cjs-package'`, Node.js ESM cannot resolve named exports from CommonJS modules, causing `SyntaxError: Named export 'foo' not found`.
## Rules
### Only add ESM-compatible dependencies
Before adding a new dependency to `package.json`, verify it supports ESM:
- Check for `"type": "module"` in its `package.json`
- Or check for an `"exports"` map with ESM entries
- Or check for a `"module"` field pointing to an ESM build
### Use native `node:fs/promises` for standard fs operations
```typescript
// ✅ Import native fs functions directly
import { readFile, writeFile, mkdir, rm, cp } from 'node:fs/promises';
import { createWriteStream, existsSync } from 'node:fs';
// ✅ Import only custom helpers from fs-utils (no native re-exports)
import { pathExists, ensureDir, emptyDir, copy, move, remove, readJson, writeJson, ensureFile } from '@/cli/utilities/file/fs-utils';
// ❌ Don't use fs-extra (CJS-only, breaks ESM bundle)
import * as fs from 'fs-extra';
// ❌ Don't use import * as fs from fs-utils (it doesn't re-export native fs)
import * as fs from '@/cli/utilities/file/fs-utils';
```
### Use `@/cli/utilities/string/kebab-case` instead of lodash
```typescript
// ✅ Use internal utility
import { kebabCase } from '@/cli/utilities/string/kebab-case';
// ❌ Don't use lodash single-function packages (CJS-only, unmaintained)
import kebabCase from 'lodash.kebabcase';
```
### When no ESM alternative exists
If a CJS-only package has no ESM replacement (e.g. `archiver`), add it to the `cjsOnlyPackages` list in `vite.config.node.ts` so it gets inlined into the bundle instead of externalized.
+3
View File
@@ -15,6 +15,9 @@ inputs:
runs:
using: "composite"
steps:
- name: Fetch main branch for diff
shell: bash
run: git fetch origin main --depth=1
- name: Get last successful commit
uses: nrwl/nx-set-shas@v4
- name: Run affected command
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Check for changed files
id: changed-files
uses: tj-actions/changed-files@v45
+1 -1
View File
@@ -70,7 +70,7 @@ jobs:
- name: Checkout current branch
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Try to merge main into current branch
id: merge_attempt
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
+3 -4
View File
@@ -21,7 +21,6 @@ jobs:
files: |
package.json
packages/twenty-docs/**
eslint.config.mjs
docs-lint:
needs: changed-files-check
@@ -37,11 +36,11 @@ jobs:
- name: Fetch local actions
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Docs / Lint English MDX files
run: npx eslint "packages/twenty-docs/{developers,user-guide,twenty-ui,getting-started,snippets}/**/*.mdx" --max-warnings 0
- name: Docs / Lint
run: npx nx lint twenty-docs
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build twenty-emails
+31 -9
View File
@@ -53,7 +53,7 @@ jobs:
- name: Fetch local actions
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Diagnostic disk space issue
@@ -62,6 +62,12 @@ jobs:
run: npx nx reset:env twenty-front
- name: Front / Build storybook
run: npx nx storybook:build twenty-front
- name: Upload storybook build
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: packages/twenty-front/storybook-static
retention-days: 1
- name: Save storybook build cache
uses: ./.github/actions/save-cache
with:
@@ -78,26 +84,41 @@ jobs:
env:
SHARD_COUNTER: 4
REACT_APP_SERVER_BASE_URL: http://localhost:3000
STORYBOOK_URL: http://localhost:6006
steps:
- name: Fetch local actions
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore storybook build cache
uses: ./.github/actions/restore-cache
with:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
- name: Clean stale storybook vitest cache
run: rm -rf packages/twenty-front/node_modules/.cache/storybook
- name: Build dependencies
run: |
npx nx build twenty-shared
npx nx build twenty-ui
npx nx build twenty-sdk
- name: Download storybook build
uses: actions/download-artifact@v4
with:
name: storybook-static
path: packages/twenty-front/storybook-static
- name: Install Playwright
run: |
cd packages/twenty-front
npx playwright install
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Run storybook tests
run: npx nx storybook:test twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
- name: Serve storybook & run tests
run: |
npx http-server packages/twenty-front/storybook-static --port 6006 --silent &
timeout 30 bash -c 'until curl -sf http://localhost:6006 > /dev/null 2>&1; do sleep 1; done'
npx nx storybook:test twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
# - name: Rename coverage file
# run: |
# if [ -f "packages/twenty-front/coverage/storybook/coverage-final.json" ]; then
@@ -125,7 +146,7 @@ jobs:
# steps:
# - uses: actions/checkout@v4
# with:
# fetch-depth: 0
# fetch-depth: 10
# - name: Install dependencies
# uses: ./.github/actions/yarn-install
# - uses: actions/download-artifact@v4
@@ -150,7 +171,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore storybook build cache
@@ -184,7 +205,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore ${{ matrix.task }} cache
@@ -214,6 +235,7 @@ jobs:
runs-on: ubuntu-latest-8-cores
env:
NODE_OPTIONS: "--max-old-space-size=10240"
ANALYZE: "true"
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
@@ -222,7 +244,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Front / Write .env
@@ -268,7 +290,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- uses: actions/setup-node@v4
with:
node-version: lts/*
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
@@ -78,7 +78,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
+76 -30
View File
@@ -13,7 +13,7 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
SERVER_SETUP_CACHE_KEY: server-setup
SERVER_BUILD_CACHE_KEY: server-build
jobs:
changed-files-check:
@@ -27,11 +27,59 @@ jobs:
packages/twenty-front/src/generated-metadata/**
packages/twenty-emails/**
packages/twenty-shared/**
server-setup:
server-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
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: Restore server build cache
id: restore-server-build-cache
uses: ./.github/actions/restore-cache
with:
key: ${{ env.SERVER_BUILD_CACHE_KEY }}
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Server / Write .env
run: npx nx reset:env twenty-server
- name: Server / Build
run: npx nx build twenty-server
- name: Save server build cache
uses: ./.github/actions/save-cache
with:
key: ${{ steps.restore-server-build-cache.outputs.cache-primary-key }}
server-lint-typecheck:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
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 twenty-shared
run: npx nx build twenty-shared
- name: Server / Run lint & typecheck
uses: ./.github/actions/nx-affected
with:
tag: scope:backend
tasks: lint,typecheck
server-validation:
needs: server-build
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -55,21 +103,15 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore server setup
id: restore-server-setup-cache
- name: Restore server build cache
uses: ./.github/actions/restore-cache
with:
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
key: ${{ env.SERVER_BUILD_CACHE_KEY }}
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Server / Run lint & typecheck
uses: ./.github/actions/nx-affected
with:
tag: scope:backend
tasks: lint,typecheck
- name: Server / Write .env
run: npx nx reset:env twenty-server
- name: Server / Build
@@ -84,10 +126,8 @@ jobs:
run: |
timeout 30s npx nx run twenty-server:worker || exit_code=$?
if [ $exit_code -eq 124 ]; then
# If timeout was reached (exit code 124), consider it a success
exit 0
elif [ $exit_code -ne 0 ]; then
# If worker failed for other reasons, fail the build
exit $exit_code
fi
- name: Server / Start
@@ -120,11 +160,9 @@ jobs:
fi
- name: GraphQL / Check for Pending Generation
run: |
# Run GraphQL generation commands
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
# Check if GraphQL generated files were modified
if ! git diff --quiet -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata; then
echo "::error::GraphQL schema changes detected. Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
echo ""
@@ -137,25 +175,24 @@ jobs:
echo ""
exit 1
fi
- name: Save server setup
uses: ./.github/actions/save-cache
with:
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
server-test:
needs: server-build
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
needs: server-setup
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore server setup
- name: Restore server build cache
uses: ./.github/actions/restore-cache
with:
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
key: ${{ env.SERVER_BUILD_CACHE_KEY }}
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Server / Run Tests
uses: ./.github/actions/nx-affected
with:
@@ -165,11 +202,11 @@ jobs:
server-integration-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
needs: server-setup
needs: server-build
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8]
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -207,12 +244,12 @@ jobs:
ANALYTICS_ENABLED: true
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
CLICKHOUSE_PASSWORD: clickhousePassword
SHARD_COUNTER: 8
SHARD_COUNTER: 10
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Update .env.test for integrations tests
@@ -223,10 +260,10 @@ jobs:
echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
- name: Restore server setup
- name: Restore server build cache
uses: ./.github/actions/restore-cache
with:
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
key: ${{ env.SERVER_BUILD_CACHE_KEY }}
- name: Server / Build
run: npx nx build twenty-server
- name: Build dependencies
@@ -247,11 +284,20 @@ jobs:
tasks: 'test:integration'
configuration: 'with-db-reset'
args: --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
ci-server-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, server-setup, server-test, server-integration-test]
needs:
[
changed-files-check,
server-build,
server-lint-typecheck,
server-validation,
server-test,
server-integration-test,
]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
+2 -2
View File
@@ -36,13 +36,13 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
with:
tag: scope:frontend
tag: scope:shared
tasks: ${{ matrix.task }}
ci-shared-status-check:
if: always() && !cancelled()
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
+2 -2
View File
@@ -52,7 +52,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
@@ -107,7 +107,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
+1 -1
View File
@@ -164,4 +164,4 @@ jobs:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: claude-cross-repo-response
client-payload: '{"repo": ${{ toJSON(steps.prompt.outputs.repo) }}, "issue_number": ${{ toJSON(steps.prompt.outputs.issue_number) }}, "run_id": ${{ toJSON(github.run_id) }}, "run_url": ${{ toJSON(format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id)) }}}'
client-payload: '{"repo": ${{ toJSON(steps.prompt.outputs.repo) }}, "issue_number": ${{ toJSON(steps.prompt.outputs.issue_number) }}, "run_id": ${{ toJSON(github.run_id) }}, "run_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}'
+1 -1
View File
@@ -28,7 +28,7 @@ coverage
dist
storybook-static
*.tsbuildinfo
.eslintcache
.oxlintcache
.nyc_output
test-results/
dump.rdb
+1 -1
View File
@@ -1,7 +1,7 @@
{
"recommendations": [
"arcanis.vscode-zipfs",
"dbaeumer.vscode-eslint",
"oxc.oxc-vscode",
"esbenp.prettier-vscode",
"figma.figma-vscode-extension",
"firsttris.vscode-jest-runner",
+10 -7
View File
@@ -4,25 +4,28 @@
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
"[typescript]": {
"editor.formatOnSave": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.oxc": "explicit",
"source.addMissingImports": "always",
"source.organizeImports": "always"
}
},
"[javascript]": {
"editor.formatOnSave": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.oxc": "explicit",
"source.addMissingImports": "always",
"source.organizeImports": "always"
}
},
"[typescriptreact]": {
"editor.formatOnSave": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.oxc": "explicit",
"source.addMissingImports": "always",
"source.organizeImports": "always"
}
@@ -48,7 +51,7 @@
"search.exclude": {
"**/.yarn": true
},
"eslint.debug": true,
"oxc.lint.enable": true,
"files.associations": {
".cursorrules": "markdown"
},
+13 -9
View File
@@ -37,8 +37,8 @@
"path": "../packages/twenty-zapier"
},
{
"name": "tools/eslint-rules",
"path": "../tools/eslint-rules"
"name": "packages/twenty-oxlint-rules",
"path": "../packages/twenty-oxlint-rules"
},
{
"name": "packages/twenty-e2e-testing",
@@ -49,23 +49,26 @@
"editor.formatOnSave": false,
"files.eol": "auto",
"[typescript]": {
"editor.formatOnSave": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.oxc": "explicit",
"source.addMissingImports": "always"
}
},
"[javascript]": {
"editor.formatOnSave": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.oxc": "explicit",
"source.addMissingImports": "always"
}
},
"[typescriptreact]": {
"editor.formatOnSave": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.oxc": "explicit",
"source.addMissingImports": "always"
}
},
@@ -88,7 +91,7 @@
"typescript.preferences.importModuleSpecifier": "non-relative",
"[javascript][typescript][typescriptreact]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.oxc": "explicit",
"source.addMissingImports": "always"
}
},
@@ -98,6 +101,7 @@
"files.exclude": {
"packages/": true
},
"oxc.lint.enable": true,
"jest.runMode": "on-demand",
"jest.disabledWorkspaceFolders": [
"ROOT",
-226
View File
@@ -1,226 +0,0 @@
import js from '@eslint/js';
import nxPlugin from '@nx/eslint-plugin';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import importPlugin from 'eslint-plugin-import';
import linguiPlugin from 'eslint-plugin-lingui';
import * as mdxPlugin from 'eslint-plugin-mdx';
import preferArrowPlugin from 'eslint-plugin-prefer-arrow';
import prettierPlugin from 'eslint-plugin-prettier';
import unicornPlugin from 'eslint-plugin-unicorn';
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
import jsoncParser from 'jsonc-eslint-parser';
const twentyRules = await nxPlugin.loadWorkspaceRules(
'packages/twenty-eslint-rules',
);
export default [
// Base JavaScript configuration
js.configs.recommended,
// Lingui recommended rules
linguiPlugin.configs['flat/recommended'],
// Global ignores
{
ignores: ['**/node_modules/**'],
},
// Base configuration for all files
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
prettier: prettierPlugin,
lingui: linguiPlugin,
'@nx': nxPlugin,
'prefer-arrow': preferArrowPlugin,
import: importPlugin,
'unused-imports': unusedImportsPlugin,
unicorn: unicornPlugin,
},
rules: {
// General rules
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'no-console': [
'warn',
{ allow: ['group', 'groupCollapsed', 'groupEnd'] },
],
'no-control-regex': 0,
'no-debugger': 'error',
'no-duplicate-imports': 'error',
'no-undef': 'off',
'no-unused-vars': 'off',
// Nx rules
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'scope:apps',
onlyDependOnLibsWithTags: ['scope:apps', 'scope:sdk'],
},
{
sourceTag: 'scope:sdk',
onlyDependOnLibsWithTags: ['scope:sdk', 'scope:shared'],
},
{
sourceTag: 'scope:create-app',
onlyDependOnLibsWithTags: ['scope:create-app', 'scope:shared'],
},
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:backend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
},
{
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:zapier'],
},
],
},
],
// Import rules
'import/no-relative-packages': 'error',
'import/no-useless-path-segments': 'error',
'import/no-duplicates': ['error', { considerQueryString: true }],
// Prefer arrow functions
'prefer-arrow/prefer-arrow-functions': [
'error',
{
disallowPrototype: true,
singleReturnOnly: false,
classPropertiesAllowed: false,
},
],
// Unused imports
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
},
// TypeScript specific configuration
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
},
rules: {
// TypeScript rules
'no-redeclare': 'off', // Turn off base rule for TypeScript
'@typescript-eslint/no-redeclare': 'error', // Use TypeScript-aware version
'@typescript-eslint/ban-ts-comment': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
fixStyle: 'inline-type-imports',
},
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/no-empty-object-type': [
'error',
{
allowInterfaces: 'with-single-extends',
},
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
},
// JavaScript specific configuration
{
files: ['*.{js,jsx}'],
rules: {
// JavaScript-specific rules if needed
},
},
// Test files
{
files: [
'*.spec.@(ts|tsx|js|jsx)',
'*.integration-spec.@(ts|tsx|js|jsx)',
'*.test.@(ts|tsx|js|jsx)',
],
languageOptions: {
globals: {
jest: true,
describe: true,
it: true,
expect: true,
beforeEach: true,
afterEach: true,
beforeAll: true,
afterAll: true,
},
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
// JSON files
{
files: ['**/*.json'],
languageOptions: {
parser: jsoncParser,
},
},
// MDX files
{
...mdxPlugin.flat,
plugins: {
...mdxPlugin.flat.plugins,
'@nx': nxPlugin,
twenty: { rules: twentyRules },
},
},
mdxPlugin.flatCodeBlocks,
{
files: ['**/*.mdx'],
rules: {
'no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'off',
'unused-imports/no-unused-vars': 'off',
// Enforce JSX tags on separate lines to prevent Crowdin translation issues
'twenty/mdx-component-newlines': 'error',
// Disallow angle bracket placeholders to prevent Crowdin translation errors
'twenty/no-angle-bracket-placeholders': 'error',
},
},
];
+9 -23
View File
@@ -40,34 +40,30 @@
"dependsOn": ["^build"]
},
"lint": {
"executor": "@nx/eslint:lint",
"executor": "nx:run-commands",
"cache": true,
"outputs": ["{options.outputFile}"],
"options": {
"eslintConfig": "{projectRoot}/eslint.config.mjs",
"cache": true,
"cacheLocation": "{workspaceRoot}/.cache/eslint"
"cwd": "{projectRoot}",
"command": "npx oxlint -c .oxlintrc.json ."
},
"configurations": {
"ci": {
"cacheStrategy": "content"
},
"ci": {},
"fix": {
"fix": true
"command": "npx oxlint --fix -c .oxlintrc.json ."
}
},
"dependsOn": ["^build"]
"dependsOn": ["^build", "twenty-oxlint-rules:build"]
},
"lint:diff-with-main": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"command": "git diff --name-only --diff-filter=d main | grep -E '{args.pattern}' | grep '^{projectRoot}/' | xargs sh -c 'if [ $# -gt 0 ]; then npx eslint --config {projectRoot}/eslint.config.mjs \"$@\"; fi' _",
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || npx oxlint -c {projectRoot}/.oxlintrc.json $FILES",
"pattern": "\\.(ts|tsx|js|jsx)$"
},
"configurations": {
"fix": {
"command": "git diff --name-only --diff-filter=d main | grep -E '{args.pattern}' | grep '^{projectRoot}/' | xargs sh -c 'if [ $# -gt 0 ]; then npx eslint --config {projectRoot}/eslint.config.mjs --fix \"$@\"; fi' _"
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES"
}
}
},
@@ -147,7 +143,7 @@
"outputs": ["{projectRoot}/{options.output-dir}"],
"options": {
"cwd": "{projectRoot}",
"command": "NODE_OPTIONS='--max-old-space-size=10240' VITE_DISABLE_TYPESCRIPT_CHECKER=true storybook build --test",
"command": "NODE_OPTIONS='--max-old-space-size=10240' storybook build --test",
"output-dir": "storybook-static",
"config-dir": ".storybook"
},
@@ -255,14 +251,6 @@
}
}
},
"@nx/eslint:lint": {
"cache": true,
"inputs": [
"default",
"{workspaceRoot}/eslint.config.mjs",
"{workspaceRoot}/packages/twenty-eslint-rules/**/*"
]
},
"@nx/vite:build": {
"cache": true,
"dependsOn": ["^build"],
@@ -277,7 +265,6 @@
"@nx/react": {
"application": {
"style": "@linaria/react",
"linter": "eslint",
"bundler": "vite",
"compiler": "swc",
"unitTestRunner": "jest",
@@ -285,7 +272,6 @@
},
"library": {
"style": "@linaria/react",
"linter": "eslint",
"bundler": "vite",
"compiler": "swc",
"unitTestRunner": "jest",
+14 -35
View File
@@ -46,7 +46,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-responsive": "^9.0.2",
"react-router-dom": "^6.4.4",
"react-router-dom": "^6.30.3",
"react-tooltip": "^5.13.1",
"remark-gfm": "^4.0.1",
"rxjs": "^7.2.0",
@@ -72,14 +72,13 @@
"@graphql-codegen/typescript": "^3.0.4",
"@graphql-codegen/typescript-operations": "^3.0.4",
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
"@nx/eslint": "22.3.3",
"@nx/eslint-plugin": "22.3.3",
"@nx/jest": "22.3.3",
"@nx/js": "22.3.3",
"@nx/react": "22.3.3",
"@nx/storybook": "22.3.3",
"@nx/vite": "22.3.3",
"@nx/web": "22.3.3",
"@nx/jest": "22.5.4",
"@nx/js": "22.5.4",
"@nx/react": "22.5.4",
"@nx/storybook": "22.5.4",
"@nx/vite": "22.5.4",
"@nx/web": "22.5.4",
"@oxlint/plugins": "^1.51.0",
"@sentry/types": "^8",
"@storybook-community/storybook-addon-cookie": "^5.0.0",
"@storybook/addon-coverage": "^3.0.0",
@@ -89,11 +88,10 @@
"@storybook/icons": "^2.0.1",
"@storybook/react-vite": "^10.2.13",
"@storybook/test-runner": "^0.24.2",
"@stylistic/eslint-plugin": "^1.5.0",
"@swc-node/register": "1.11.1",
"@swc/cli": "^0.3.12",
"@swc/core": "1.15.11",
"@swc/helpers": "~0.5.18",
"@swc-node/register": "^1.11.1",
"@swc/cli": "^0.7.10",
"@swc/core": "^1.15.11",
"@swc/helpers": "~0.5.19",
"@swc/jest": "^0.2.39",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -132,9 +130,6 @@
"@types/react-dom": "^18.2.15",
"@types/supertest": "^2.0.11",
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"@typescript-eslint/utils": "^8.39.0",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"@vitejs/plugin-react-swc": "4.2.3",
"@vitest/browser-playwright": "^4.0.18",
@@ -146,22 +141,6 @@
"danger": "^13.0.4",
"dotenv-cli": "^7.4.4",
"esbuild": "^0.25.10",
"eslint": "^9.32.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-lingui": "^0.9.0",
"eslint-plugin-mdx": "^3.6.2",
"eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-prettier": "^5.1.2",
"eslint-plugin-project-structure": "^3.9.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.4",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-storybook": "^10.2.13",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-unused-imports": "^3.0.0",
"http-server": "^14.1.1",
"jest": "29.7.0",
"jest-environment-jsdom": "30.0.0-beta.3",
@@ -170,7 +149,7 @@
"jsdom": "~22.1.0",
"msw": "^2.12.7",
"msw-storybook-addon": "^2.0.6",
"nx": "22.3.3",
"nx": "22.5.4",
"prettier": "^3.1.1",
"raw-loader": "^4.0.2",
"rimraf": "^5.0.5",
@@ -230,7 +209,7 @@
"packages/twenty-apps",
"packages/twenty-cli",
"packages/create-twenty-app",
"packages/twenty-eslint-rules"
"packages/twenty-oxlint-rules"
]
},
"prettier": {
+38
View File
@@ -0,0 +1,38 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"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": "^_"
}]
}
}
+1 -1
View File
@@ -98,7 +98,7 @@ In interactive mode, you can pick from:
- `roles/default-role.ts` — Default role for logic functions
- `logic-functions/pre-install.ts` — Pre-install logic function (runs before app installation)
- `logic-functions/post-install.ts` — Post-install logic function (runs after app installation)
- TypeScript configuration, ESLint, package.json, .gitignore
- TypeScript configuration, Oxlint, package.json, .gitignore
- A prewired `twenty` script that delegates to the `twenty` CLI from twenty-sdk
**Example files (controlled by scaffolding mode):**
@@ -1,20 +0,0 @@
import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
{
ignores: ['**/dist/**'],
},
{
files: ['**/*.{js,jsx,ts,tsx}'],
rules: {
'prettier/prettier': 'error',
},
},
{
rules: {
'no-console': 'off',
},
ignores: ['src/**/*.ts', '!src/cli/**/*.ts'],
},
];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "0.6.3",
"version": "0.6.4",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
+1 -13
View File
@@ -25,19 +25,7 @@
}
},
"typecheck": {},
"lint": {
"options": {
"lintFilePatterns": ["{projectRoot}/src/**/*.{ts,json}"],
"maxWarnings": 0
},
"configurations": {
"ci": {
"lintFilePatterns": ["{projectRoot}/src/**/*.{ts,json}"],
"maxWarnings": 0
},
"fix": {}
}
},
"lint": {},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
@@ -0,0 +1,16 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": ["warn", {
"argsIgnorePattern": "^_"
}],
"typescript/no-explicit-any": "off"
}
}
@@ -1,29 +0,0 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default [
// Base JS recommended rules
js.configs.recommended,
// TypeScript recommended rules
...tseslint.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Common TypeScript-friendly tweaks
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'no-unused-vars': 'off', // handled by TS rule
},
},
];
@@ -553,8 +553,8 @@ const createPackageJson = async ({
}) => {
const scripts: Record<string, string> = {
twenty: 'twenty',
lint: 'eslint',
'lint:fix': 'eslint --fix',
lint: 'oxlint -c .oxlintrc.json .',
'lint:fix': 'oxlint --fix -c .oxlintrc.json .',
};
const devDependencies: Record<string, string> = {
@@ -562,8 +562,7 @@ const createPackageJson = async ({
'@types/node': '^24.7.2',
'@types/react': '^18.2.0',
react: '^18.2.0',
eslint: '^9.32.0',
'typescript-eslint': '^8.50.0',
oxlint: '^0.16.0',
'twenty-sdk': createTwentyAppPackageJson.version,
};
@@ -0,0 +1,38 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"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": "^_"
}]
}
}
@@ -1,29 +0,0 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default [
// Base JS recommended rules
js.configs.recommended,
// TypeScript recommended rules
...tseslint.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Common TypeScript-friendly tweaks
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'no-unused-vars': 'off', // handled by TS rule
},
},
];
@@ -10,8 +10,8 @@
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "eslint",
"lint:fix": "eslint --fix"
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
"dependencies": {
"twenty-sdk": "latest"
@@ -19,9 +19,8 @@
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"eslint": "^9.32.0",
"oxlint": "^0.16.0",
"react": "^18.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.0"
"typescript": "^5.9.3"
}
}
@@ -1,7 +1,6 @@
import { defineLogicFunction, RoutePayload } from "twenty-sdk";
import { MetadataApiClient } from 'twenty-sdk/generated';
export const OAUTH_TOKEN_PAIRS_PATH = '/oauth/token-pairs';
type ApolloTokenResponse = {
@@ -53,16 +52,8 @@ const handler = async (event: RoutePayload): Promise<any> => {
const apolloClientSecret = process.env.APOLLO_CLIENT_SECRET ?? '';
const applicationId = process.env.APPLICATION_ID ?? '';
const metadataClient = new MetadataApiClient({});
const tokenPairs = await getAuthenticationTokenPairs(
code,
apolloClientId,
@@ -5,8 +5,6 @@ import {
} from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
type CompanyRecord = {
id: string;
name?: string;
@@ -38,8 +36,6 @@ type ApolloEnrichResponse = {
organization?: ApolloOrganization;
};
const extractDomain = (
domainName?: CompanyRecord['domainName'],
): string | undefined => {
@@ -172,7 +168,6 @@ const updateCompanyInTwenty = async (
}
};
type CompanyUpdateEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<CompanyRecord>
>;
@@ -181,7 +176,6 @@ const handler = async (
event: CompanyUpdateEvent,
): Promise<object | undefined> => {
const { recordId, properties } = event;
const { after: companyAfter } = properties;
@@ -206,7 +200,6 @@ const handler = async (
return { skipped: true, reason: 'no enrichment data to apply' };
}
await updateCompanyInTwenty(recordId, updateData);
const result = {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"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": "^_"
}]
}
}
@@ -26,21 +26,6 @@
"typecheck": {
"dependsOn": ["^build"]
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": [
"{options.outputFile}"
],
"options": {
"lintFilePatterns": [
"packages/twenty-apps/community/fireflies/**/*.{ts,tsx,js,jsx}"
]
},
"configurations": {
"fix": {
"fix": true
}
}
}
"lint": {}
}
}
@@ -5,7 +5,7 @@
* Usage: yarn setup:fields
*/
/* eslint-disable no-console */
/* oxlint-disable no-console */
import * as dotenv from 'dotenv';
import * as path from 'path';
@@ -1,4 +1,4 @@
/* eslint-disable no-console */
/* oxlint-disable no-console */
import * as dotenv from 'dotenv';
import * as path from 'path';
import { fileURLToPath } from 'url';
@@ -1,4 +1,4 @@
/* eslint-disable no-console */
/* oxlint-disable no-console */
import * as dotenv from 'dotenv';
import * as path from 'path';
import { fileURLToPath } from 'url';
@@ -1,4 +1,4 @@
/* eslint-disable no-console */
/* oxlint-disable no-console */
/**
* Fetch historical Fireflies meetings and insert into Twenty.
*
@@ -219,4 +219,3 @@ main().catch((error) => {
process.exit(1);
});
@@ -1,4 +1,4 @@
/* eslint-disable no-console */
/* oxlint-disable no-console */
/**
* Fetch a Fireflies meeting by ID and insert it into Twenty using the same path
* as the webhook handler.
@@ -1,4 +1,4 @@
/* eslint-disable no-console */
/* oxlint-disable no-console */
/**
* Test script for Fireflies webhook against local Twenty instance
*
@@ -81,4 +81,3 @@ describe('HistoricalImporter', () => {
});
});
@@ -158,4 +158,3 @@ export class HistoricalImporter {
}
}
@@ -72,7 +72,7 @@ export class AppLogger {
debug(message: string, ...args: unknown[]): void {
this.captureLog('debug', message, ...args);
if (this.shouldLog('debug')) {
// eslint-disable-next-line no-console
// oxlint-disable-next-line no-console
console.log(`[${this.context}] ${message}`, ...args);
}
}
@@ -80,7 +80,7 @@ export class AppLogger {
info(message: string, ...args: unknown[]): void {
this.captureLog('info', message, ...args);
if (this.shouldLog('info')) {
// eslint-disable-next-line no-console
// oxlint-disable-next-line no-console
console.log(`[${this.context}] ${message}`, ...args);
}
}
@@ -88,7 +88,7 @@ export class AppLogger {
warn(message: string, ...args: unknown[]): void {
this.captureLog('warn', message, ...args);
if (this.shouldLog('warn')) {
// eslint-disable-next-line no-console
// oxlint-disable-next-line no-console
console.warn(`[${this.context}] ${message}`, ...args);
}
}
@@ -96,7 +96,7 @@ export class AppLogger {
error(message: string, ...args: unknown[]): void {
this.captureLog('error', message, ...args);
if (this.shouldLog('error')) {
// eslint-disable-next-line no-console
// oxlint-disable-next-line no-console
console.error(`[${this.context}] ${message}`, ...args);
}
}
@@ -104,7 +104,7 @@ export class AppLogger {
// For fatal errors, security issues, or data corruption - always visible
critical(message: string, ...args: unknown[]): void {
this.captureLog('error', `CRITICAL: ${message}`, ...args);
// eslint-disable-next-line no-console
// oxlint-disable-next-line no-console
console.error(`[${this.context}] CRITICAL: ${message}`, ...args);
}
@@ -1,3 +1,2 @@
export { Meeting } from './meeting';
@@ -330,7 +330,6 @@ export class WebhookHandler {
}
}
private async createFailedMeetingRecord(params: unknown, error: string): Promise<void> {
try {
const twentyApiKey = process.env.TWENTY_API_KEY || '';
@@ -0,0 +1,54 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": [
"node_modules"
],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"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",
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/jsx-key": "off",
"react/display-name": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-no-useless-fragment": "off",
"react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"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": "^_"
}]
}
}
@@ -1,69 +0,0 @@
import typescriptParser from '@typescript-eslint/parser';
import path from 'path';
import { fileURLToPath } from 'url';
import reactConfig from '../../../../twenty-eslint-rules/eslint.config.react.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default [
// Extend shared React configuration
...reactConfig,
// Global ignores
{
ignores: [
'**/node_modules/**',
],
},
// TypeScript project-specific configuration
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
project: [path.resolve(__dirname, 'tsconfig.*.json')],
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'scope:sdk',
onlyDependOnLibsWithTags: ['scope:sdk'],
},
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:backend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
},
{
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:browser-extension',
onlyDependOnLibsWithTags: ['scope:twenty-ui', 'scope:browser-extension']
}
],
},
],
}
},
];
@@ -43,7 +43,6 @@ export default defineContentScript({
},
});
ctx.addEventListener(window, 'wxt:locationchange', ({newUrl, }) => {
const injectedBtn = document.querySelector('[data-id="twenty-btn"]');
if(personPattern.includes(newUrl) && !injectedBtn) ui.mount();
@@ -8,7 +8,6 @@ const StyledButton = styled.button`
--text-color: #0a66c2;
--bg-color: #00000000;
font-size: ${({theme}) => theme.spacing(3.5)};
font-weight: ${({theme}) => theme.font.weight.semiBold};
font-family: ${({theme}) => theme.font.family};
@@ -26,7 +25,6 @@ const StyledButton = styled.button`
transition-timing-function: cubic-bezier(.4, 0, .2, 1);
transition-duration: 167ms;
&:hover {
background-color: var(--hover-bg-color);
color: var(--hover-color);
@@ -1,6 +1,5 @@
import { ThemeProvider } from '@emotion/react';
import type React from 'react';
import { THEME_DARK, ThemeContextProvider } from 'twenty-ui/theme';
import { ColorSchemeProvider } from 'twenty-ui/theme-constants';
type ThemeContextProps = {
children: React.ReactNode;
@@ -8,10 +7,6 @@ type ThemeContextProps = {
export const ThemeContext = ({ children }: ThemeContextProps) => {
return (
<ThemeProvider theme={THEME_DARK}>
<ThemeContextProvider theme={THEME_DARK}>
{children}
</ThemeContextProvider>
</ThemeProvider>
<ColorSchemeProvider colorScheme="dark">{children}</ColorSchemeProvider>
);
};
@@ -1,5 +0,0 @@
import type { ThemeType } from 'twenty-ui/theme';
declare module '@emotion/react' {
export interface Theme extends ThemeType {}
}
@@ -0,0 +1,38 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"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": "^_"
}]
}
}
@@ -1,29 +0,0 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default [
// Base JS recommended rules
js.configs.recommended,
// TypeScript recommended rules
...tseslint.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Common TypeScript-friendly tweaks
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'no-unused-vars': 'off', // handled by TS rule
},
},
];
@@ -1,6 +1,6 @@
{
"name": "hello-world",
"version": "0.1.0",
"name": "@twentyhq/hello-world",
"version": "0.2.2",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -10,19 +10,18 @@
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "eslint",
"lint:fix": "eslint --fix",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"eslint": "^9.32.0",
"oxlint": "^0.16.0",
"react": "^18.2.0",
"twenty-sdk": "0.6.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.0",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty/*
!.twenty/output/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
# Remove once prod ready
.twenty
@@ -0,0 +1 @@
24.5.0
@@ -0,0 +1,38 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"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 @@
nodeLinker: node-modules
@@ -0,0 +1,9 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/cli/__tests__/apps/rich-app
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
@@ -0,0 +1,51 @@
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Getting Started
First, authenticate to your workspace:
```bash
yarn twenty auth:login
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty app:dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
## Available Commands
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Authentication
yarn twenty auth:login # Authenticate with Twenty
yarn twenty auth:logout # Remove credentials
yarn twenty auth:status # Check auth status
yarn twenty auth:switch # Switch default workspace
yarn twenty auth:list # List all configured workspaces
# Application
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty function:logs # Stream function logs
yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
```
## LLMs instructions
Main docs and pitfalls are available in LLMS.md file.
## Learn More
To learn more about Twenty applications, take a look at the following resources:
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
@@ -0,0 +1,30 @@
{
"name": "call-recording",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"react-loading-skeleton": "^3.5.0",
"react-markdown": "^10.1.0",
"twenty-sdk": "0.6.3-alpha"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"oxlint": "^0.16.0",
"react": "^18.2.0",
"typescript": "^5.9.3"
}
}
@@ -0,0 +1,9 @@
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { defineApplication } from 'twenty-sdk';
export default defineApplication({
universalIdentifier: '4daa5147-7e70-4e43-b091-c27e1e8a32e3',
displayName: 'Call recording',
description: 'Allows to record calls',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,54 @@
import styled from '@emotion/styled';
import { SerializedEventData } from 'twenty-sdk/dist/sdk/front-component-api';
const StyledAudioWrapper = styled.div`
background: linear-gradient(135deg, #f8f9fb 0%, #eef0f4 100%);
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
`;
const StyledAudio = styled.audio`
width: 100%;
height: 36px;
border-radius: 8px;
outline: none;
&::-webkit-media-controls-panel {
background: transparent;
}
`;
type AudioPlayerProps = {
src: string;
extension: string;
onTimeUpdate?: (currentTimeSeconds: number) => void;
};
export const AudioPlayer = ({
src,
extension,
onTimeUpdate,
}: AudioPlayerProps) => {
return (
<StyledAudioWrapper>
<StyledAudio
controls
onTimeUpdate={(event: unknown) => {
const currentTime = (event as CustomEvent<SerializedEventData>)
.detail.currentTime;
if (typeof currentTime === 'number') {
onTimeUpdate?.(currentTime);
}
}}
>
<source src={src} type={`audio/${extension}`} />
</StyledAudio>
</StyledAudioWrapper>
);
};
@@ -0,0 +1,46 @@
import styled from '@emotion/styled';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import {
SKELETON_BASE_COLOR,
SKELETON_HIGHLIGHT_COLOR,
StyledSummarySkeletonContainer,
StyledViewerSkeletonContainer,
} from 'src/constants/skeleton-constants';
const StyledMediaSkeletonCard = styled.div`
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
`;
const StyledTranscriptSkeletonCard = styled.div`
background: #ffffff;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
overflow: hidden;
`;
export const CallRecordingViewerSkeleton = () => {
return (
<SkeletonTheme
baseColor={SKELETON_BASE_COLOR}
highlightColor={SKELETON_HIGHLIGHT_COLOR}
borderRadius={4}
>
<StyledViewerSkeletonContainer>
<StyledMediaSkeletonCard>
<Skeleton height={54} width="100%" borderRadius={0} />
</StyledMediaSkeletonCard>
<StyledTranscriptSkeletonCard>
<StyledSummarySkeletonContainer>
<Skeleton height={14} count={4} style={{ marginBottom: 6 }} />
<Skeleton height={14} width="60%" />
</StyledSummarySkeletonContainer>
</StyledTranscriptSkeletonCard>
</StyledViewerSkeletonContainer>
</SkeletonTheme>
);
};
@@ -0,0 +1,40 @@
import { AudioPlayer } from 'src/components/AudioPlayer';
import { VideoPlayer } from 'src/components/VideoPlayer';
import { isAudioExtension } from 'src/utils/is-audio-extension';
import { isVideoExtension } from 'src/utils/is-video-extension';
type MediaPlayerProps = {
url: string;
extension: string;
onTimeUpdate?: (currentTimeSeconds: number) => void;
};
export const MediaPlayer = ({
url,
extension,
onTimeUpdate,
}: MediaPlayerProps) => {
const normalizedExtension = extension.toLowerCase().replace(/^\./, '');
if (isAudioExtension(normalizedExtension)) {
return (
<AudioPlayer
src={url}
extension={normalizedExtension}
onTimeUpdate={onTimeUpdate}
/>
);
}
if (isVideoExtension(normalizedExtension)) {
return (
<VideoPlayer
src={url}
extension={normalizedExtension}
onTimeUpdate={onTimeUpdate}
/>
);
}
throw new Error('Unsupported file extension');
};
@@ -0,0 +1,107 @@
import styled from '@emotion/styled';
import Markdown from 'react-markdown';
import { isDefined } from 'twenty-shared/utils';
type SummaryViewerProps = {
markdown: string | null | undefined;
};
const StyledSummaryCard = styled.div`
background: #ffffff;
border-radius: 12px;
overflow: hidden;
`;
const StyledSummaryContent = styled.div`
line-height: 1.7;
padding: 20px 24px;
font-size: 13.5px;
color: #333;
max-height: 500px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.12);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 1.2em;
margin-bottom: 0.5em;
color: #1a1a1a;
font-weight: 600;
&:first-child {
margin-top: 0;
}
}
p {
margin: 0.6em 0;
}
strong {
color: #1a1a1a;
font-weight: 600;
}
ul,
ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
code {
background-color: rgba(0, 0, 0, 0.04);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.88em;
font-family: 'SF Mono', 'Fira Code', monospace;
}
pre {
background-color: #f6f8fa;
padding: 14px 16px;
border-radius: 8px;
overflow-x: auto;
border: 1px solid rgba(0, 0, 0, 0.06);
}
blockquote {
border-left: 3px solid #d0d7de;
margin: 0.6em 0;
padding-left: 1em;
color: #57606a;
}
`;
export const SummaryViewer = ({ markdown }: SummaryViewerProps) => {
if (!isDefined(markdown) || markdown.trim().length === 0) {
return;
}
return (
<StyledSummaryCard>
<StyledSummaryContent>
<Markdown>{markdown}</Markdown>
</StyledSummaryContent>
</StyledSummaryCard>
);
};
@@ -0,0 +1,36 @@
import styled from '@emotion/styled';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import {
SKELETON_BASE_COLOR,
SKELETON_HIGHLIGHT_COLOR,
StyledSummarySkeletonContainer,
} from 'src/constants/skeleton-constants';
const StyledSkeletonCard = styled.div`
background: #ffffff;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
overflow: hidden;
`;
export const SummaryViewerSkeleton = () => {
return (
<SkeletonTheme
baseColor={SKELETON_BASE_COLOR}
highlightColor={SKELETON_HIGHLIGHT_COLOR}
borderRadius={4}
>
<StyledSkeletonCard>
<StyledSummarySkeletonContainer>
<Skeleton height={16} width="40%" />
<Skeleton height={14} count={3} style={{ marginBottom: 6 }} />
<Skeleton height={16} width="55%" />
<Skeleton height={14} count={2} style={{ marginBottom: 6 }} />
<Skeleton height={14} width="75%" />
</StyledSummarySkeletonContainer>
</StyledSkeletonCard>
</SkeletonTheme>
);
};
@@ -0,0 +1,136 @@
import styled from '@emotion/styled';
import {
type TranscriptEntry,
type TranscriptWord,
} from 'src/hooks/useTranscript';
import { isDefined } from 'twenty-shared/utils';
type TranscriptViewerProps = {
entries: TranscriptEntry[];
currentTimeSeconds: number;
};
const StyledTranscriptCard = styled.div`
background: #ffffff;
border-radius: 8px;
overflow: hidden;
`;
const StyledTranscriptContent = styled.div`
max-height: 500px;
overflow-y: auto;
`;
const StyledEntry = styled.div<{ isActive: boolean }>`
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s ease;
background-color: ${({ isActive }) =>
isActive ? '#f1f1f1' : 'transparent'};
& + & {
margin-top: 2px;
}
`;
const StyledSpeaker = styled.span`
font-weight: 600;
color: #333333;
font-size: 0.92rem;
`;
const StyledTextContent = styled.div`
line-height: 1.5;
`;
const StyledWord = styled.span<{ isSpoken: boolean }>`
font-size: 0.92rem;
line-height: 1.5;
transition: color 0.15s ease;
color: ${({ isSpoken }) => (isSpoken ? '#333333' : '#b3b3b3')};
`;
const getEntryTimeRange = (entry: TranscriptEntry) => {
const firstWord = entry.words[0];
const lastWord = entry.words[entry.words.length - 1];
const start = firstWord?.start_timestamp?.relative;
const end = lastWord?.end_timestamp?.relative;
return { start, end };
};
const findActiveEntryIndex = (
entries: TranscriptEntry[],
currentTimeSeconds: number,
): number => {
for (let index = entries.length - 1; index >= 0; index--) {
const { start, end } = getEntryTimeRange(entries[index]);
if (!isDefined(start) || !isDefined(end)) {
continue;
}
if (currentTimeSeconds >= start && currentTimeSeconds <= end) {
return index;
}
}
return -1;
};
const isWordSpoken = (
word: TranscriptWord,
currentTimeSeconds: number,
): boolean => {
const start = word.start_timestamp?.relative;
if (!isDefined(start)) {
return false;
}
return currentTimeSeconds >= start;
};
export const TranscriptViewer = ({
entries,
currentTimeSeconds,
}: TranscriptViewerProps) => {
if (entries.length === 0) {
return;
}
const activeEntryIndex = findActiveEntryIndex(entries, currentTimeSeconds);
return (
<StyledTranscriptCard>
<StyledTranscriptContent>
{entries.map((entry, index) => {
const speaker = entry.participant?.name ?? 'Unknown';
const isActive = index === activeEntryIndex;
return (
<StyledEntry key={index} isActive={isActive}>
<StyledSpeaker>{speaker}</StyledSpeaker>
<StyledTextContent>
{entry.words.map((word, wordIndex) => (
<StyledWord
key={wordIndex}
isSpoken={isWordSpoken(word, currentTimeSeconds)}
>
{wordIndex > 0 ? ' ' : ''}
{word.text}
</StyledWord>
))}
</StyledTextContent>
</StyledEntry>
);
})}
</StyledTranscriptContent>
</StyledTranscriptCard>
);
};
@@ -0,0 +1,46 @@
import styled from '@emotion/styled';
import { SerializedEventData } from 'twenty-sdk/dist/sdk/front-component-api';
const StyledVideoWrapper = styled.div`
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
background: #000;
`;
const StyledVideo = styled.video`
width: 100%;
display: block;
`;
type VideoPlayerProps = {
src: string;
extension: string;
onTimeUpdate?: (currentTimeSeconds: number) => void;
};
export const VideoPlayer = ({
src,
extension,
onTimeUpdate,
}: VideoPlayerProps) => {
return (
<StyledVideoWrapper>
<StyledVideo
controls
onTimeUpdate={(event: unknown) => {
const currentTime = (event as CustomEvent<SerializedEventData>)
.detail.currentTime;
if (typeof currentTime === 'number') {
onTimeUpdate?.(currentTime);
}
}}
>
<source src={src} type={`video/${extension}`} />
</StyledVideo>
</StyledVideoWrapper>
);
};
@@ -0,0 +1,8 @@
export const AUDIO_EXTENSIONS = [
'mp3',
'wav',
'ogg',
'aac',
'flac',
'webm',
] as const;
@@ -0,0 +1,2 @@
export const CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'43aae2d4-396a-4c5e-9f45-0162a2904825';
@@ -0,0 +1,2 @@
export const CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'9f3bbb39-042d-4216-b8fc-bedfc3487208';
@@ -0,0 +1,5 @@
export const SEED_CALL_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'0894353c-86d2-484c-84f2-802cf5c4d22b';
export const SEED_CALL_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER =
'e0e5b7a1-d472-4897-88d0-ce5f1bf6a5ea';
@@ -0,0 +1,24 @@
import styled from '@emotion/styled';
export const SKELETON_BASE_COLOR = '#f0f1f3';
export const SKELETON_HIGHLIGHT_COLOR = '#f8f9fb';
export const StyledSummarySkeletonContainer = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px 24px;
width: 100%;
box-sizing: border-box;
`;
export const StyledViewerSkeletonContainer = styled.div`
display: flex;
flex-direction: column;
gap: 24px;
padding: 20px;
max-width: 960px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
`;
@@ -0,0 +1,5 @@
export const SUMMARIZE_PERSON_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'958c796e-baf0-472c-838f-8d0a7f572774';
export const SUMMARIZE_PERSON_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER =
'30e73886-18e7-476f-9c55-b19c59397a81';
@@ -0,0 +1,8 @@
export const VIDEO_EXTENSIONS = [
'mp4',
'webm',
'ogv',
'avi',
'mov',
'mkv',
] as const;
@@ -0,0 +1,574 @@
export type MockCallRecording = {
name: string;
createdAt: string;
endedAt: string;
status: 'ENDED';
transcript: { blocknote: null; markdown: string };
summary: { blocknote: null; markdown: string };
};
export const MOCK_CALL_RECORDINGS: MockCallRecording[] = [
{
name: 'Call Sarah Chen / Mike Johnson',
createdAt: '2025-01-08T10:00:00.000Z',
endedAt: '2025-01-08T10:32:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Mike Johnson:** Hi Sarah, thanks for taking the time today. I wanted to walk you through how we handle pipeline management and see if there might be a fit for your team.',
'**Sarah Chen:** Of course, Mike. We\'ve been looking at several CRM options. Right now we\'re using spreadsheets and it\'s becoming unmanageable with the team growing.',
'**Mike Johnson:** That\'s a common pain point. How many reps are on your team currently?',
'**Sarah Chen:** We have twelve sales reps and three managers. The biggest issue is visibility. Managers can\'t see deal progress in real time, and reps are spending too much time on data entry.',
'**Mike Johnson:** I hear that a lot. One thing that sets us apart is our approach to reducing manual entry. We automatically capture emails, meeting notes, and even call data. Would that address your main concern?',
'**Sarah Chen:** That would be huge. Our reps probably spend an hour a day just logging activities. What about reporting? We need weekly pipeline reviews with accurate forecasting.',
'**Mike Johnson:** Absolutely. I can set up a demo environment for your team. We have built-in forecasting that uses historical win rates and deal velocity. Should we schedule a more in-depth demo with your managers next week?',
'**Sarah Chen:** Yes, let\'s do that. Tuesday or Wednesday afternoon would work best for us.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Initial discovery call with Sarah Chen (VP Sales at prospect company) to assess CRM needs and pipeline management requirements.',
'',
'## Key Discussion Points',
'- Current setup: spreadsheets, 12 reps + 3 managers',
'- Main pain points: lack of real-time visibility, excessive manual data entry (~1 hour/day per rep)',
'- Interest in automated activity capture (emails, meetings, calls)',
'- Need for weekly pipeline reporting and accurate forecasting',
'',
'## Action Items',
'- [ ] Schedule in-depth demo with Sarah\'s managers for Tuesday or Wednesday afternoon',
'- [ ] Prepare demo environment with pipeline forecasting features',
'- [ ] Send follow-up email with product overview deck',
].join('\n'),
},
},
{
name: 'Call David Park / Emily Rivera',
createdAt: '2025-01-22T14:30:00.000Z',
endedAt: '2025-01-22T15:05:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Emily Rivera:** David, good to connect again. I wanted to follow up on the demo we did last week. How did the team feel about it?',
'**David Park:** The feedback was really positive overall. The interface is clean and the automation features impressed everyone. A couple of questions came up though.',
'**Emily Rivera:** Great to hear! What questions did the team have?',
'**David Park:** First, our legal team wants to know about data residency. We need our data hosted in the EU. Second, we\'re wondering about the integration with Salesforce — we still have some legacy data there.',
'**Emily Rivera:** Both great questions. We offer EU data residency with our Business plan. For Salesforce, we have a native migration tool that can import your historical data including contacts, deals, and activities. It typically takes about a day for a dataset your size.',
'**David Park:** That\'s reassuring. And what about pricing for our team size? We\'d be looking at about forty users.',
'**Emily Rivera:** For forty users on the Business plan with EU residency, I can put together a custom proposal. We also offer annual billing discounts. Let me send that over by Friday.',
'**David Park:** Perfect. If the pricing works, I think we\'re ready to move forward. We\'d want to start onboarding in March.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Follow-up call with David Park post-demo to address team feedback and move toward closing.',
'',
'## Key Discussion Points',
'- Demo feedback was positive across the team',
'- EU data residency requirement — available on Business plan',
'- Salesforce migration needed for legacy data (contacts, deals, activities)',
'- Team size: ~40 users',
'- Target onboarding start: March',
'',
'## Action Items',
'- [ ] Send custom pricing proposal for 40 users on Business plan by Friday',
'- [ ] Include EU data residency details and Salesforce migration timeline',
'- [ ] Prepare onboarding plan for March start',
].join('\n'),
},
},
{
name: 'Call Lisa Wong / James Martinez',
createdAt: '2025-02-05T09:00:00.000Z',
endedAt: '2025-02-05T09:28:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**James Martinez:** Lisa, thanks for joining. This is your first quarterly review since onboarding. How has the first month been going?',
'**Lisa Wong:** It\'s been great honestly. The team adopted it much faster than I expected. We\'re already seeing about a thirty percent reduction in time spent on data entry.',
'**James Martinez:** That\'s fantastic. Are there any areas where you feel we could improve the experience?',
'**Lisa Wong:** One thing that came up is custom fields. We have some industry-specific data points we track — like compliance status and license numbers — and we\'d like to add those as fields on our contact records.',
'**James Martinez:** You can absolutely do that. I\'ll send you a guide on creating custom fields. You can add text, dropdown, date, or number fields to any object. Any other requests?',
'**Lisa Wong:** Our marketing team is asking about the API. They want to push lead data from our website forms directly into the CRM.',
'**James Martinez:** Our REST API and GraphQL API both support that. I\'ll connect you with our developer relations team for a quick walkthrough. They can have your marketing team set up in about an hour.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'First quarterly review with Lisa Wong, one month post-onboarding. Team adoption is strong with measurable productivity gains.',
'',
'## Key Discussion Points',
'- 30% reduction in data entry time reported',
'- Need for custom fields: compliance status, license numbers',
'- Marketing team interest in API integration for website lead forms',
'',
'## Action Items',
'- [ ] Send custom fields documentation to Lisa',
'- [ ] Connect Lisa\'s marketing team with developer relations for API walkthrough',
'- [ ] Schedule next quarterly review for May',
].join('\n'),
},
},
{
name: 'Call Robert Taylor / Anna Schmidt',
createdAt: '2025-02-19T16:00:00.000Z',
endedAt: '2025-02-19T16:45:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Anna Schmidt:** Robert, I appreciate you making time. I wanted to understand your current sales process before we put together a proposal.',
'**Robert Taylor:** Sure. We\'re a B2B SaaS company, about two hundred employees. Our sales cycle is typically three to six months. We have SDRs doing outbound, AEs closing, and a small customer success team.',
'**Anna Schmidt:** What tools are you using today across those teams?',
'**Robert Taylor:** It\'s a mess honestly. SDRs use one tool for outreach, AEs use a different CRM, and customer success has their own platform. Nothing talks to each other.',
'**Anna Schmidt:** That fragmentation is really common. How does it impact your day-to-day?',
'**Robert Taylor:** The biggest issue is handoffs. When an SDR qualifies a lead and passes it to an AE, context gets lost. Same thing when a deal closes and moves to customer success. We lose notes, meeting history, everything.',
'**Anna Schmidt:** That\'s exactly what we solve. A single platform for the entire customer lifecycle — from first touch through renewal. All the context travels with the record. Let me show you a quick overview of how that handoff looks in practice.',
'**Robert Taylor:** That would be great. And can you also show me how your workflow automation works? We want to automate some of our internal notifications and task assignments.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Discovery call with Robert Taylor (B2B SaaS, ~200 employees) to map current sales process and identify pain points.',
'',
'## Key Discussion Points',
'- Sales cycle: 3-6 months with SDR → AE → CS handoffs',
'- Tool fragmentation: separate tools for outreach, CRM, and customer success',
'- Critical pain: context loss during handoffs (notes, meeting history)',
'- Interest in workflow automation for notifications and task assignments',
'',
'## Action Items',
'- [ ] Prepare demo focused on lifecycle handoff workflows',
'- [ ] Include workflow automation examples for internal notifications',
'- [ ] Schedule demo for next week',
].join('\n'),
},
},
{
name: 'Call Priya Patel / Tom Wilson',
createdAt: '2025-03-12T11:00:00.000Z',
endedAt: '2025-03-12T11:38:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Tom Wilson:** Hi Priya, I wanted to touch base about the pricing proposal we sent over. Have you had a chance to review it with your CFO?',
'**Priya Patel:** Yes, we went through it yesterday. The per-seat pricing is within our budget, but we\'re concerned about the implementation cost. Fifty thousand for onboarding feels steep.',
'**Tom Wilson:** I understand. That fee covers dedicated onboarding support, data migration from your three existing systems, custom training sessions, and sixty days of post-launch support. What would make it work for your budget?',
'**Priya Patel:** If we could break the implementation into two phases, that would help. Phase one would be the core sales team migration, and phase two would be marketing and customer success. That way we spread the cost over two quarters.',
'**Tom Wilson:** We can absolutely structure it that way. In fact, phased rollouts often lead to better adoption. We could do phase one for thirty thousand and phase two for twenty-five, with phase two starting three months later.',
'**Priya Patel:** That works much better. I think we can get sign-off on that. Can you send a revised proposal with those terms?',
'**Tom Wilson:** I\'ll have it in your inbox by end of day. If everything looks good, we could target April first for kickoff.',
'**Priya Patel:** Let\'s plan on that. I\'ll schedule an internal alignment meeting for Friday to finalize.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Pricing negotiation call with Priya Patel. Agreed on a phased implementation to fit budget constraints.',
'',
'## Key Discussion Points',
'- Per-seat pricing approved, implementation cost ($50K) was a concern',
'- Agreed on phased approach: Phase 1 ($30K, core sales) + Phase 2 ($25K, marketing & CS)',
'- Phase 2 starts 3 months after Phase 1',
'- Target kickoff: April 1st',
'',
'## Action Items',
'- [ ] Send revised proposal with phased implementation terms by end of day',
'- [ ] Prepare Phase 1 kickoff plan targeting April 1st',
'- [ ] Priya to schedule internal alignment meeting for Friday',
].join('\n'),
},
},
{
name: 'Call Marcus Lee / Sophie Dubois',
createdAt: '2025-04-03T13:00:00.000Z',
endedAt: '2025-04-03T13:22:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Sophie Dubois:** Marcus, welcome to your onboarding kickoff. I\'m your dedicated customer success manager and I\'ll be guiding you through the setup process over the next four weeks.',
'**Marcus Lee:** Great, the team is excited to get started. We have twenty users ready to go on day one.',
'**Sophie Dubois:** Perfect. Let me walk you through the onboarding timeline. Week one is data migration and system configuration. Week two is user training. Week three is a guided pilot where your team uses it in parallel with your old system. Week four is full cutover and go-live.',
'**Marcus Lee:** That sounds structured. For the data migration, we have about fifty thousand contacts and ten thousand deals in our current system. Is that going to be an issue?',
'**Sophie Dubois:** Not at all. That\'s well within our standard migration capacity. I\'ll need your team to export the data in CSV format and I\'ll handle the mapping and import. We typically complete migrations of that size in two to three days.',
'**Marcus Lee:** And what about our custom deal stages? We have a pretty specific pipeline with eight stages.',
'**Sophie Dubois:** We\'ll configure that during week one. You can have as many stages as you need, and we can set up automation rules for each transition. I\'ll send you a configuration worksheet to fill out before our next session.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Onboarding kickoff with Marcus Lee. Covered the 4-week timeline and initial setup requirements.',
'',
'## Key Discussion Points',
'- 20 users ready for day one',
'- Data migration: ~50K contacts, ~10K deals (CSV export needed)',
'- Custom pipeline: 8 deal stages with automation rules',
'- 4-week onboarding plan: migration → training → pilot → go-live',
'',
'## Action Items',
'- [ ] Send configuration worksheet for custom pipeline stages',
'- [ ] Marcus to prepare CSV exports of contacts and deals',
'- [ ] Schedule week 1 check-in for data migration review',
].join('\n'),
},
},
{
name: 'Call Jennifer Brooks / Carlos Mendez',
createdAt: '2025-05-14T15:30:00.000Z',
endedAt: '2025-05-14T16:02:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Carlos Mendez:** Jennifer, thanks for the demo request. I see you\'re interested in our analytics and reporting capabilities. Can you tell me what you\'re looking for specifically?',
'**Jennifer Brooks:** We need better visibility into rep performance. Right now I\'m pulling data from three different sources to build my weekly report. It takes me half a day every Monday.',
'**Carlos Mendez:** That\'s painful. What metrics do you track in those reports?',
'**Jennifer Brooks:** Calls made, emails sent, meetings booked, pipeline generated, deals closed, and average deal size. I also need to compare rep-to-rep and track trends over time.',
'**Carlos Mendez:** All of those are available out of the box in our analytics dashboard. Let me share my screen and show you. Here you can see a real-time dashboard with all those metrics. You can filter by rep, team, date range, and even deal stage.',
'**Jennifer Brooks:** Oh wow, that\'s exactly what I need. Can I schedule these reports to be sent automatically?',
'**Carlos Mendez:** Yes, you can set up scheduled email reports — daily, weekly, or monthly. You can also create custom dashboards and share them with your team or leadership. Each person only sees data they have access to.',
'**Jennifer Brooks:** This would save me so much time. What does pricing look like for a team of twenty-five?',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Demo call with Jennifer Brooks focused on analytics and reporting capabilities. Strong interest in automated reporting.',
'',
'## Key Discussion Points',
'- Current report building takes half a day weekly across 3 data sources',
'- Key metrics: calls, emails, meetings, pipeline, deals closed, avg deal size',
'- Rep-to-rep comparison and trend tracking required',
'- Demonstrated real-time dashboard, scheduled reports, and custom dashboards',
'',
'## Action Items',
'- [ ] Send pricing proposal for 25-user team',
'- [ ] Share sample analytics dashboard templates',
'- [ ] Schedule follow-up to review proposal with Jennifer\'s leadership',
].join('\n'),
},
},
{
name: 'Call Alex Nguyen / Rachel Foster',
createdAt: '2025-06-20T10:00:00.000Z',
endedAt: '2025-06-20T10:41:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Rachel Foster:** Alex, this is your six-month review. Let\'s go over the numbers. How are things going since you fully migrated in January?',
'**Alex Nguyen:** Really well. Our sales cycle has shortened by about twenty percent. Reps are closing deals faster because they have all the context in one place.',
'**Rachel Foster:** That\'s a significant improvement. What about adoption? Are all teams using the platform regularly?',
'**Alex Nguyen:** The sales team is fully on board, probably ninety-five percent daily usage. Marketing is at about eighty percent. The one area where we\'re struggling is getting our field sales team to use the mobile app consistently.',
'**Rachel Foster:** Mobile adoption is often the trickiest. We recently launched offline mode which helps a lot for field reps with spotty connectivity. I can set up a quick training session specifically for your field team.',
'**Alex Nguyen:** That would be great. Also, we\'re looking at expanding to our APAC team next quarter. That would add about thirty more users. What does that look like from a licensing perspective?',
'**Rachel Foster:** I\'ll work with your account executive to prepare an expansion quote. We can usually offer volume discounts when scaling beyond fifty users. I\'ll have that ready for your budget planning meeting.',
'**Alex Nguyen:** Perfect timing. Our planning cycle starts in August so if we can have numbers by mid-July that would be ideal.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Six-month review with Alex Nguyen. Strong results with 20% shorter sales cycles. Planning APAC expansion.',
'',
'## Key Discussion Points',
'- Sales cycle reduced by 20% since migration',
'- Adoption: 95% daily (sales), 80% (marketing), field team needs mobile training',
'- New offline mode could help field sales adoption',
'- APAC expansion planned: +30 users next quarter',
'',
'## Action Items',
'- [ ] Schedule mobile app training for field sales team',
'- [ ] Prepare APAC expansion quote with volume discounts by mid-July',
'- [ ] Connect with account executive on expansion pricing',
].join('\n'),
},
},
{
name: 'Call Kevin O\'Brien / Maria Santos',
createdAt: '2025-07-09T14:00:00.000Z',
endedAt: '2025-07-09T14:35:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Maria Santos:** Kevin, I understand your team is evaluating us alongside two other CRM vendors. I\'d love to understand what criteria are most important to you.',
'**Kevin O\'Brien:** Right, we\'re in the final stages of evaluation. The three biggest factors for us are ease of use, integration ecosystem, and total cost of ownership over three years.',
'**Maria Santos:** Makes sense. On ease of use, our average onboarding time is two weeks and we consistently score highest in user satisfaction surveys. What integrations are most critical for you?',
'**Kevin O\'Brien:** We need Slack, Google Workspace, Zoom, and our billing system which runs on Stripe. We also use Notion for internal documentation.',
'**Maria Santos:** All of those have native integrations except Notion, which we support through our Zapier and Make connectors. For Stripe, our integration syncs billing data bidirectionally so your sales team can see payment status directly on the deal record.',
'**Kevin O\'Brien:** The Stripe integration is a big differentiator actually. The other vendors we\'re looking at don\'t offer that natively. What about the three-year cost comparison?',
'**Maria Santos:** I\'ll put together a total cost of ownership analysis that includes licensing, implementation, training, and ongoing support. We\'re typically fifteen to twenty percent lower than our main competitors when you factor in everything.',
'**Kevin O\'Brien:** Send that over and I\'ll present it to our executive team next Thursday. We\'re planning to make a decision by end of month.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Competitive evaluation call with Kevin O\'Brien. In final stages, comparing against two other vendors.',
'',
'## Key Discussion Points',
'- Evaluation criteria: ease of use, integrations, 3-year TCO',
'- Required integrations: Slack, Google Workspace, Zoom, Stripe (native), Notion (via Zapier/Make)',
'- Stripe bidirectional sync is a key differentiator vs competitors',
'- Decision timeline: end of month, executive presentation next Thursday',
'',
'## Action Items',
'- [ ] Prepare 3-year TCO analysis vs competitors',
'- [ ] Send integration ecosystem overview document',
'- [ ] Follow up before Thursday executive presentation',
].join('\n'),
},
},
{
name: 'Call Diana Hughes / Ryan Cooper',
createdAt: '2025-08-18T09:30:00.000Z',
endedAt: '2025-08-18T10:05:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Ryan Cooper:** Diana, I noticed some of your team\'s usage metrics dropped last month. Everything okay?',
'**Diana Hughes:** Honestly, we\'ve been struggling with a few things. The email sync stopped working for about half our team two weeks ago and we haven\'t been able to figure out why.',
'**Ryan Cooper:** I\'m sorry to hear that. Let me look into this right now. Can you tell me which email provider you\'re using?',
'**Diana Hughes:** We\'re on Microsoft 365. It was working fine and then suddenly half the team\'s emails stopped syncing. The other half is still fine.',
'**Ryan Cooper:** I think I see the issue. Microsoft recently changed their OAuth token refresh policy. The affected users likely need to re-authenticate. I\'ll send you a step-by-step guide. It should take each user about two minutes.',
'**Diana Hughes:** Okay that\'s a relief, I was worried it was something bigger. The other thing is we need better support response times. We submitted a ticket about this five days ago and didn\'t hear back until yesterday.',
'**Ryan Cooper:** That\'s not acceptable and I apologize. I\'m escalating this with our support team. For your account, I\'m also going to set up a dedicated Slack channel so you can reach me or someone on my team directly for urgent issues.',
'**Diana Hughes:** That would make a huge difference. We need to be able to get help quickly when something breaks.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Issue resolution call with Diana Hughes regarding email sync failures and support response times.',
'',
'## Key Discussion Points',
'- Email sync broken for ~50% of team (Microsoft 365, OAuth token refresh issue)',
'- Support ticket response took 5 days — unacceptable',
'- Setting up dedicated Slack channel for urgent issues',
'',
'## Risks',
'- Customer satisfaction at risk due to slow support response',
'- Usage metrics declining — need to restore confidence quickly',
'',
'## Action Items',
'- [ ] Send OAuth re-authentication guide to Diana immediately',
'- [ ] Escalate support response time issue internally',
'- [ ] Set up dedicated Slack channel for Diana\'s team',
'- [ ] Follow up in 48 hours to confirm email sync is restored',
].join('\n'),
},
},
{
name: 'Call Nathan Kim / Laura Chen / Steve Morris',
createdAt: '2025-09-25T16:00:00.000Z',
endedAt: '2025-09-25T16:42:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Steve Morris:** Alright team, let\'s do our monthly pipeline review. Nathan, kick us off with the numbers.',
'**Nathan Kim:** Sure. Total pipeline is at two point four million, up twelve percent from last month. We have eight deals in the negotiation stage totaling about nine hundred K. Three of those should close this month.',
'**Laura Chen:** Which three are you most confident about?',
'**Nathan Kim:** Meridian Corp at three hundred and twenty K — contract is out for signature. TechFlow at two hundred and ten K — verbal agreement, just working through procurement. And BrightPath at one hundred and fifty K — they want to start before their fiscal year end on October fifteenth.',
'**Steve Morris:** Good. What about the other five? Any at risk?',
'**Nathan Kim:** Two are solid but won\'t close until November. The other three I\'m worried about. DataSync keeps pushing their timeline back and I think they might be evaluating a competitor.',
'**Laura Chen:** Let me jump on a call with the DataSync champion this week. I have a relationship there from a previous company. Maybe I can help move things forward.',
'**Steve Morris:** Great idea. Nathan, can you also do a deep dive on our Q4 pipeline generation? We need to make sure we\'re building enough top-of-funnel to hit our annual target.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Internal monthly pipeline review. Pipeline at $2.4M (+12% MoM), 8 deals in negotiation stage.',
'',
'## Key Discussion Points',
'- 3 deals expected to close this month: Meridian ($320K), TechFlow ($210K), BrightPath ($150K)',
'- 2 deals solid for November close',
'- 3 deals at risk, especially DataSync (may be evaluating competitor)',
'- Need to build Q4 top-of-funnel pipeline',
'',
'## Action Items',
'- [ ] Laura to contact DataSync champion this week',
'- [ ] Nathan to prepare Q4 pipeline generation analysis',
'- [ ] Follow up on Meridian contract signature',
'- [ ] Track BrightPath against Oct 15 fiscal year deadline',
].join('\n'),
},
},
{
name: 'Call Amanda Price / Ben Watson',
createdAt: '2025-10-30T11:00:00.000Z',
endedAt: '2025-10-30T11:25:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Ben Watson:** Amanda, thanks for your interest. I understand you\'re a startup looking for your first CRM. Tell me about your team.',
'**Amanda Price:** We\'re a Series A startup, fifteen people total. Five of us are in go-to-market roles. We\'ve been tracking everything in Airtable and Google Sheets but we need something purpose-built as we scale.',
'**Ben Watson:** What\'s your growth plan? Understanding your trajectory helps me recommend the right package.',
'**Amanda Price:** We plan to triple the sales team by end of next year. So we need something that can grow with us without breaking the bank in the early days.',
'**Ben Watson:** Our startup program is designed exactly for that. You\'d get our full platform at a seventy percent discount for the first year, then fifty percent off the second year, with a gradual step-up to standard pricing.',
'**Amanda Price:** That\'s compelling. What\'s the catch? Do we lose any features on the startup plan?',
'**Ben Watson:** No catch — full feature parity. The only difference is the per-seat price. You also get priority onboarding support included. We want to grow with you.',
'**Amanda Price:** I love that approach. Can you send me the startup program details? I\'ll review it with my co-founder this week.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Discovery call with Amanda Price, Series A startup looking for first CRM. Good fit for startup program.',
'',
'## Key Discussion Points',
'- Series A, 15 people, 5 in GTM roles',
'- Currently using Airtable + Google Sheets',
'- Plan to 3x sales team by end of next year',
'- Startup program: 70% off Y1, 50% off Y2, full features, priority onboarding',
'',
'## Action Items',
'- [ ] Send startup program details and application form',
'- [ ] Amanda to review with co-founder this week',
'- [ ] Schedule follow-up call for next week',
].join('\n'),
},
},
{
name: 'Call Chris Yamamoto / Olivia Barrett',
createdAt: '2025-12-03T10:30:00.000Z',
endedAt: '2025-12-03T11:08:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Olivia Barrett:** Chris, you\'re coming up on your annual renewal. I wanted to check in and see how things are going before we discuss the renewal terms.',
'**Chris Yamamoto:** Overall we\'re happy. The platform has become essential for our team. There are a few enhancements I\'d love to see though.',
'**Olivia Barrett:** I\'d love to hear them. What\'s on your wish list?',
'**Chris Yamamoto:** First, we really need better territory management. We operate in eight regions and right now we\'re managing territories manually with filters. Second, we\'d like more advanced workflow branching — if-then-else logic in automations.',
'**Olivia Barrett:** Good news on both fronts. Territory management is in our Q1 roadmap — we\'re targeting a February launch. Advanced workflow logic is already in beta. I can get your team access to the beta next week if you\'re interested.',
'**Chris Yamamoto:** Definitely, sign us up for the beta. For the renewal, we want to add ten more seats. What does that look like pricing-wise?',
'**Olivia Barrett:** Adding ten seats at your current rate would bring you to sixty users. Given your expansion and commitment, I can offer a five percent loyalty discount on the full renewal. I\'ll draft the renewal proposal and have it ready by next week.',
'**Chris Yamamoto:** That sounds fair. Let\'s aim to have the renewal signed before the holiday break.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Annual renewal discussion with Chris Yamamoto. Expanding from 50 to 60 seats with loyalty discount.',
'',
'## Key Discussion Points',
'- Customer is satisfied, platform is essential to operations',
'- Feature requests: territory management (in Q1 roadmap), advanced workflow logic (beta available)',
'- Expansion: +10 seats (50 → 60 users)',
'- 5% loyalty discount offered on full renewal',
'- Target: sign renewal before holiday break',
'',
'## Action Items',
'- [ ] Enroll Chris\'s team in workflow logic beta next week',
'- [ ] Draft renewal proposal for 60 seats with 5% loyalty discount',
'- [ ] Share Q1 territory management roadmap details',
'- [ ] Get renewal signed before December holidays',
].join('\n'),
},
},
{
name: 'Call Samantha Reed / Derek Chang',
createdAt: '2026-01-15T14:00:00.000Z',
endedAt: '2026-01-15T14:33:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Derek Chang:** Samantha, your team has been on the platform for about two months now. I wanted to do a health check and see how the implementation is going.',
'**Samantha Reed:** The core sales team is doing well. We\'re tracking about ninety percent of our deals in the system now. But I have some concerns about data quality.',
'**Derek Chang:** What kind of data quality issues are you seeing?',
'**Samantha Reed:** Duplicate contacts mostly. When we imported our data we ended up with a lot of duplicates. And our reps sometimes create new contacts instead of linking to existing ones.',
'**Derek Chang:** That\'s a common post-migration issue. We have a built-in deduplication tool that can merge duplicates. I can run an audit on your database this week and present the results. For preventing new duplicates, we can enable duplicate detection rules that alert reps when they\'re about to create a potential duplicate.',
'**Samantha Reed:** Yes, please run that audit. The other thing is we\'re not using the email automation features yet. Can we schedule a training session specifically on email sequences?',
'**Derek Chang:** Absolutely. I\'ll set up a one-hour training session for your team next week. We\'ll cover sequence creation, personalization tokens, A/B testing, and analytics.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'Two-month health check with Samantha Reed. Good deal tracking (90%) but data quality concerns with duplicates.',
'',
'## Key Discussion Points',
'- 90% of deals tracked in system — good adoption',
'- Duplicate contact issue from data migration and manual creation',
'- Built-in deduplication tool and detection rules available',
'- Email automation features not yet adopted — training needed',
'',
'## Action Items',
'- [ ] Run duplicate contact audit this week and present results',
'- [ ] Enable duplicate detection rules for new contact creation',
'- [ ] Schedule 1-hour email sequence training for next week',
].join('\n'),
},
},
{
name: 'Call Michelle Torres / Greg Anderson',
createdAt: '2026-02-10T09:00:00.000Z',
endedAt: '2026-02-10T09:40:00.000Z',
status: 'ENDED',
transcript: {
blocknote: null,
markdown: [
'**Greg Anderson:** Michelle, I\'m reaching out because we noticed your contract is expiring in thirty days and we haven\'t heard from you about renewal. Is everything alright?',
'**Michelle Torres:** To be honest Greg, we\'ve been having some internal discussions about whether to continue. Our new VP of Sales wants to consolidate vendors and he\'s pushing for an all-in-one suite from one of the larger providers.',
'**Greg Anderson:** I appreciate the transparency. Can you help me understand what the all-in-one suite offers that we don\'t currently provide?',
'**Michelle Torres:** They bundle marketing automation, CRM, and customer service into one platform. The appeal is a single vendor relationship and unified data.',
'**Greg Anderson:** I understand the appeal of consolidation. A few things to consider though. You\'d be replacing a tool your team has adopted and loves with something new. Migration costs and ramp-up time are real. And our open API means we integrate deeply with best-of-breed tools for each function. Would it be possible to get fifteen minutes with your VP of Sales? I\'d like to address his concerns directly.',
'**Michelle Torres:** I think that\'s fair. Let me check his calendar. If you can make a compelling case for the best-of-breed approach, I think we have a shot at keeping this.',
'**Greg Anderson:** I\'ll prepare a comparison analysis showing total cost, migration risk, and feature-by-feature breakdown. I want to make sure your VP has all the data he needs to make the right decision for the team.',
].join('\n\n'),
},
summary: {
blocknote: null,
markdown: [
'## Overview',
'At-risk renewal call with Michelle Torres. New VP of Sales pushing for vendor consolidation with all-in-one suite.',
'',
'## Key Discussion Points',
'- Contract expires in 30 days, internal debate about renewal',
'- New VP wants all-in-one suite (marketing + CRM + service from single vendor)',
'- Counter-argument: team adoption, migration costs, best-of-breed approach via API',
'- Requested meeting with VP of Sales to present case',
'',
'## Risks',
'- **High churn risk** — decision-maker change driving vendor review',
'- Timeline is tight (30 days to contract expiry)',
'',
'## Action Items',
'- [ ] Prepare best-of-breed vs all-in-one comparison analysis',
'- [ ] Schedule meeting with Michelle\'s VP of Sales ASAP',
'- [ ] Include total cost, migration risk, and feature comparison',
'- [ ] Escalate internally as at-risk account',
].join('\n'),
},
},
];
@@ -0,0 +1,23 @@
import {
PEOPLE_ON_CALL_RECORDING_ID,
} from 'src/fields/people-on-call-recording.field';
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export const CALL_RECORDING_ON_PERSON_ID =
'c62ae064-88aa-48a7-84b3-c9940e3a5db9';
export default defineField({
universalIdentifier: CALL_RECORDING_ON_PERSON_ID,
objectUniversalIdentifier: '20202020-e674-48e5-a542-72570eee7213',
type: FieldType.RELATION,
name: 'callRecordings',
label: 'Call Recordings',
relationTargetObjectMetadataUniversalIdentifier:
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
PEOPLE_ON_CALL_RECORDING_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,21 @@
import { WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID } from 'src/fields/workspace-members-on-call-recording.field';
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export const CALL_RECORDING_ON_WORKSPACE_MEMBER_ID =
'ae57f5bc-b9f1-4867-887a-834c14737bae';
export default defineField({
universalIdentifier: CALL_RECORDING_ON_WORKSPACE_MEMBER_ID,
objectUniversalIdentifier: '20202020-3319-4234-a34c-82d5c0e881a6',
type: FieldType.RELATION,
name: 'callRecordings',
label: 'Call Recordings',
relationTargetObjectMetadataUniversalIdentifier:
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,23 @@
import { CALL_RECORDING_ON_PERSON_ID } from 'src/fields/call-recording-on-person.field';
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk';
export const PEOPLE_ON_CALL_RECORDING_ID =
'0066e1d2-59f6-4ca7-8073-ca9bd964bfe0';
export default defineField({
universalIdentifier: PEOPLE_ON_CALL_RECORDING_ID,
objectUniversalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'person',
label: 'Person',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
CALL_RECORDING_ON_PERSON_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'personId',
},
icon: 'IconUser',
});
@@ -0,0 +1,23 @@
import { CALL_RECORDING_ON_WORKSPACE_MEMBER_ID } from 'src/fields/call-recording-on-workspace-member.field';
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk';
export const WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID =
'5550e26a-4354-434b-b32e-3f7b04585113';
export default defineField({
universalIdentifier: WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID,
objectUniversalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'workspaceMember',
label: 'Workspace Member',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
CALL_RECORDING_ON_WORKSPACE_MEMBER_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'workspaceMemberId',
},
icon: 'IconUser',
});
@@ -0,0 +1,28 @@
import { SummaryViewer } from 'src/components/SummaryViewer';
import { SummaryViewerSkeleton } from 'src/components/SummaryViewerSkeleton';
import { CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-summary-viewer-front-component-universal-identifier';
import { useCallRecording } from 'src/hooks/useCallRecording';
import { defineFrontComponent } from 'twenty-sdk';
import { isDefined } from 'twenty-shared/utils';
const CallRecordingSummaryViewer = () => {
const { callRecording, loading, error } = useCallRecording();
if (loading) {
return <SummaryViewerSkeleton />;
}
if (isDefined(error)) {
throw error;
}
return <SummaryViewer markdown={callRecording?.summary?.markdown} />;
};
export default defineFrontComponent({
universalIdentifier:
CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Call Recording Summary Viewer',
description: 'Displays the AI-generated summary of a call recording',
component: CallRecordingSummaryViewer,
});
@@ -0,0 +1,78 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { CallRecordingViewerSkeleton } from 'src/components/CallRecordingViewerSkeleton';
import { MediaPlayer } from 'src/components/MediaPlayer';
import { SummaryViewerSkeleton } from 'src/components/SummaryViewerSkeleton';
import { TranscriptViewer } from 'src/components/TranscriptViewer';
import { CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-viewer-front-component-universal-identifier';
import { useCallRecording } from 'src/hooks/useCallRecording';
import { useTranscript } from 'src/hooks/useTranscript';
import { defineFrontComponent } from 'twenty-sdk';
import { isDefined } from 'twenty-shared/utils';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: 24px;
padding: 20px;
max-width: 960px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
`;
export const CallRecordingViewer = () => {
const [currentTimeSeconds, setCurrentTimeSeconds] = useState(0);
const { callRecording, loading, error } = useCallRecording();
const transcriptFileUrl = callRecording?.transcriptFile[0]?.url;
const { entries: transcriptEntries, loading: transcriptLoading } =
useTranscript(transcriptFileUrl);
if (loading) {
return <CallRecordingViewerSkeleton />;
}
if (isDefined(error)) {
throw error;
}
const recordingFile = callRecording?.recordingFile[0];
const recordingFileUrl = recordingFile?.url;
const recordingFileExtension = recordingFile?.extension;
const hasRecording =
isDefined(recordingFileUrl) && isDefined(recordingFileExtension);
return (
<StyledContainer>
{hasRecording && (
<MediaPlayer
url={recordingFileUrl}
extension={recordingFileExtension}
onTimeUpdate={setCurrentTimeSeconds}
/>
)}
{transcriptLoading ? (
<SummaryViewerSkeleton />
) : (
<TranscriptViewer
entries={transcriptEntries}
currentTimeSeconds={currentTimeSeconds}
/>
)}
</StyledContainer>
);
};
export default defineFrontComponent({
universalIdentifier:
CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Call Recording Viewer',
description: 'A viewer for call recordings',
component: CallRecordingViewer,
});
@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react';
import {
SEED_CALL_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER,
SEED_CALL_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/seed-call-recordings-universal-identifiers';
import { MOCK_CALL_RECORDINGS } from 'src/data/mock-call-recordings';
import { defineFrontComponent } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
type SeedStatus = 'seeding' | 'done' | 'error';
const fetchPeopleIds = async (
client: InstanceType<typeof CoreApiClient>,
): Promise<string[]> => {
const result: any = await client.query({
people: {
__args: { first: 50 },
edges: { node: { id: true } },
},
} as any);
return (
result?.people?.edges?.map(
(edge: { node: { id: string } }) => edge.node.id,
) ?? []
);
};
const fetchWorkspaceMemberIds = async (
client: InstanceType<typeof CoreApiClient>,
): Promise<string[]> => {
const result: any = await client.query({
workspaceMembers: {
__args: { first: 50 },
edges: { node: { id: true } },
},
} as any);
return (
result?.workspaceMembers?.edges?.map(
(edge: { node: { id: string } }) => edge.node.id,
) ?? []
);
};
const pickRandom = <T,>(items: T[]): T | undefined =>
items.length > 0 ? items[Math.floor(Math.random() * items.length)] : undefined;
const SeedCallRecordings = () => {
const [status, setStatus] = useState<SeedStatus>('seeding');
const [count, setCount] = useState(0);
useEffect(() => {
const seed = async () => {
try {
const client = new CoreApiClient();
const [personIds, workspaceMemberIds] = await Promise.all([
fetchPeopleIds(client),
fetchWorkspaceMemberIds(client),
]);
const recordsToCreate = MOCK_CALL_RECORDINGS.map((recording) => ({
...recording,
personId: pickRandom(personIds),
workspaceMemberId: pickRandom(workspaceMemberIds),
}));
await client.mutation({
createCallRecordings: {
__args: { data: recordsToCreate as any },
id: true,
},
} as any);
setCount(recordsToCreate.length);
setStatus('done');
} catch {
setStatus('error');
}
};
seed();
}, []);
if (status === 'seeding') {
return <div>Seeding call recordings...</div>;
}
if (status === 'error') {
return <div>Failed to seed call recordings.</div>;
}
return <div>Seeded {count} call recordings.</div>;
};
export default defineFrontComponent({
universalIdentifier:
SEED_CALL_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Seed Call Recordings',
description: 'Seeds the workspace with mock call recordings for testing',
isHeadless: true,
component: SeedCallRecordings,
command: {
universalIdentifier: SEED_CALL_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER,
label: 'Seed call recordings',
icon: 'IconDatabase',
isPinned: false,
availabilityType: 'GLOBAL',
},
});
@@ -0,0 +1,186 @@
import { useEffect, useState } from 'react';
import { SummaryViewer } from 'src/components/SummaryViewer';
import { SummaryViewerSkeleton } from 'src/components/SummaryViewerSkeleton';
import {
SUMMARIZE_PERSON_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER,
SUMMARIZE_PERSON_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/summarize-person-recordings-universal-identifiers';
import { defineFrontComponent, useRecordId } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
import { isDefined } from 'twenty-shared/utils';
const SUMMARIZATION_SYSTEM_PROMPT = [
'You are a helpful assistant that summarizes call transcripts.',
'Provide a concise summary with:',
'1) A brief overview of the calls',
'2) Key themes across all calls',
'3) Action items (if any)',
'4) Risks or opportunities identified',
'Use markdown formatting.',
].join(' ');
type Recording = {
id: string;
name: string | null;
createdAt: string;
summary: { markdown: string | null } | null;
};
const summarizeAllRecordings = async (
recordings: Recording[],
): Promise<string | undefined> => {
const apiBaseUrl = process.env.TWENTY_API_URL;
const token =
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
if (!apiBaseUrl || !token) {
return undefined;
}
const summariesText = recordings
.map(
(recording, index) =>
`### ${index + 1}. ${recording.name ?? 'Untitled'} (${recording.createdAt})\n${recording.summary?.markdown ?? 'No summary available'}`,
)
.join('\n\n---\n\n');
const userPrompt = [
`Here are the summaries of ${recordings.length} call recording(s) linked to this person:`,
'',
summariesText,
'',
'Generate a detailed summary of these calls.',
'Highlight key themes, action items, and any risks or opportunities.',
].join('\n');
const url = `${apiBaseUrl}/rest/ai/generate-text`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
userPrompt,
}),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`AI summarization request failed with status ${response.status}: ${errorBody}`,
);
}
const data = (await response.json()) as { text?: string };
return data.text ?? undefined;
};
const SummarizePersonRecordings = () => {
const personRecordId = useRecordId();
const [summary, setSummary] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!isDefined(personRecordId)) {
setError(new Error('No person record selected'));
setLoading(false);
return;
}
const fetchAndSummarize = async () => {
try {
setLoading(true);
setError(null);
const client = new CoreApiClient();
const result: Record<string, unknown> = await client.query({
callRecordings: {
__args: {
filter: { personId: { eq: personRecordId } },
},
edges: {
node: {
id: true,
name: true,
summary: { markdown: true },
createdAt: true,
},
},
},
});
const recordings: Recording[] =
(
result?.callRecordings as {
edges?: { node: Recording }[];
}
)?.edges?.map((edge) => edge.node) ?? [];
if (recordings.length === 0) {
setError(new Error('No call recordings linked to this person'));
setLoading(false);
return;
}
const generatedSummary = await summarizeAllRecordings(recordings);
setSummary(generatedSummary ?? null);
} catch (fetchError) {
setError(
fetchError instanceof Error
? fetchError
: new Error('Failed to summarize recordings'),
);
}
setLoading(false);
};
fetchAndSummarize();
return () => {
setSummary(null);
setLoading(false);
setError(null);
};
}, [personRecordId]);
if (loading) {
return <SummaryViewerSkeleton />;
}
if (isDefined(error)) {
throw error;
}
return <SummaryViewer markdown={summary} />;
};
export default defineFrontComponent({
universalIdentifier:
SUMMARIZE_PERSON_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Summarize Person Call Recordings',
description:
'Generates and displays a summary of recent call recordings for a person',
component: SummarizePersonRecordings,
command: {
universalIdentifier:
SUMMARIZE_PERSON_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER,
label: 'Summarize call recordings',
icon: 'IconSparkles',
isPinned: false,
availabilityType: 'SINGLE_RECORD',
availabilityObjectUniversalIdentifier:
'20202020-e674-48e5-a542-72570eee7213',
},
});
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { useRecordId } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
import { isDefined } from 'twenty-shared/utils';
type CallRecording = {
id: string;
name: string;
createdAt: string;
endedAt: string | null;
recordingFile: Array<{ fileId: string; label: string; url: string | null; extension: string | null }>;
transcriptFile: Array<{ fileId: string; label: string; url: string | null; extension: string | null }>;
transcript: { markdown: string | null } | null;
summary: { markdown: string | null } | null;
};
export const useCallRecording = () => {
const recordId = useRecordId();
const [callRecording, setCallRecording] = useState<CallRecording | null>(
null,
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!isDefined(recordId)) {
setError(new Error('Record ID is not defined'));
setLoading(false);
return;
}
const fetchRecord = async () => {
try {
setLoading(true);
setError(null);
const client = new CoreApiClient();
const { callRecording } = await client.query({
callRecording: {
__args: {
filter: { id: { eq: recordId } },
},
id: true,
name: true,
createdAt: true,
endedAt: true,
recordingFile: {
fileId: true,
label: true,
url: true,
extension: true,
},
transcriptFile: {
fileId: true,
label: true,
url: true,
extension: true,
},
transcript: {
markdown: true,
},
summary: {
markdown: true,
},
},
});
setCallRecording({
id: callRecording?.id ?? '',
name: callRecording?.name ?? '',
createdAt: callRecording?.createdAt ?? '',
endedAt: callRecording?.endedAt ?? null,
recordingFile: callRecording?.recordingFile?.map((file) => ({
fileId: file.fileId,
label: file.label,
url: file.url ?? null,
extension: file.extension ?? null,
})) ?? [],
transcriptFile: callRecording?.transcriptFile?.map((file) => ({
fileId: file.fileId,
label: file.label,
url: file.url ?? null,
extension: file.extension ?? null,
})) ?? [],
transcript: callRecording?.transcript
? { markdown: callRecording.transcript.markdown ?? null }
: null,
summary: callRecording?.summary
? { markdown: callRecording.summary.markdown ?? null }
: null,
});
} catch (fetchError) {
if (fetchError instanceof Error) {
setError(fetchError);
} else {
setError(new Error('Failed to fetch call recording'));
}
}
setLoading(false);
};
fetchRecord();
return () => {
setCallRecording(null);
setLoading(false);
setError(null);
};
}, [recordId]);
return { callRecording, loading, error };
};
@@ -0,0 +1,69 @@
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
export type TranscriptTimestamp = {
relative: number;
absolute: string;
};
export type TranscriptWord = {
text: string;
start_timestamp?: TranscriptTimestamp;
end_timestamp?: TranscriptTimestamp;
};
export type TranscriptEntry = {
participant: {
name: string | null;
};
words: TranscriptWord[];
};
export const useTranscript = (
transcriptFileUrl: string | null | undefined,
) => {
const [entries, setEntries] = useState<TranscriptEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!isDefined(transcriptFileUrl)) {
return;
}
const fetchTranscript = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(transcriptFileUrl);
if (!response.ok) {
throw new Error(`Failed to fetch transcript: ${response.statusText}`);
}
const data = await response.json();
setEntries(data);
} catch (fetchError) {
if (fetchError instanceof Error) {
setError(fetchError);
} else {
setError(new Error('Failed to fetch transcript'));
}
}
setLoading(false);
};
fetchTranscript();
return () => {
setEntries([]);
setLoading(false);
setError(null);
};
}, [transcriptFileUrl]);
return { entries, loading, error };
};
@@ -0,0 +1,306 @@
import {
RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER,
TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/objects/call-recording';
import {
matchParticipants,
type Participant,
} from 'src/utils/match-participants';
import { summarizeTranscript } from 'src/utils/summarize-transcript';
import { defineLogicFunction } from 'twenty-sdk';
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
import { z } from 'zod';
interface LocalTranscriptWord {
text: string;
start_timestamp?: { relative: number; absolute: string };
end_timestamp?: { relative: number; absolute: string };
}
interface LocalTranscriptEntry {
participant: { name: string };
words: LocalTranscriptWord[];
}
interface EndRecordingBody {
callRecordingId: string;
audioUrl: string;
transcriptUrl?: string;
participants?: Participant[];
localTranscript?: LocalTranscriptEntry[];
}
type UploadedFileRef = { fileId: string; label: string };
const timestampSchema = z.object({
relative: z.number(),
absolute: z.string(),
});
const transcriptEntrySchema = z.object({
participant: z.object({
name: z.string().nullable(),
}),
words: z.array(
z.object({
text: z.string(),
start_timestamp: timestampSchema.optional(),
end_timestamp: timestampSchema.optional(),
}),
),
});
const transcriptSchema = z.array(transcriptEntrySchema);
const transcriptToMarkdown = (
entries: z.infer<typeof transcriptSchema>,
): string =>
entries
.map((entry) => {
const speaker = entry.participant?.name ?? 'Unknown';
const text = entry.words.map((word) => word.text).join(' ');
return `**${speaker}:** ${text}`;
})
.join('\n\n');
const localTranscriptToMarkdown = (
entries: LocalTranscriptEntry[],
): string =>
entries
.map((entry) => {
const speaker = entry.participant?.name ?? 'Unknown';
const text = entry.words.map((word) => word.text).join(' ');
return `**${speaker}:** ${text}`;
})
.join('\n\n');
const downloadFile = async (
url: string,
): Promise<{ buffer: Buffer; contentType: string; fileName: string }> => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download file from ${url}: ${response.status}`);
}
const contentType = response.headers.get('content-type') ?? 'audio/mpeg';
const urlPath = new URL(url).pathname;
const fileName = urlPath.split('/').pop() ?? 'recording.mp4';
const arrayBuffer = await response.arrayBuffer();
return {
buffer: Buffer.from(arrayBuffer),
contentType,
fileName,
};
};
const processTranscript = async (
metadataClient: InstanceType<typeof MetadataApiClient>,
transcriptUrl: string | undefined,
localTranscript?: LocalTranscriptEntry[],
): Promise<
| {
transcriptFile?: UploadedFileRef[];
transcript?: { blocknote: null; markdown: string };
}
| undefined
> => {
// The local transcript already has correct speakers (from isActiveSpeaker
// tracking) and word-level timestamps (from the SDK events). Use it
// directly instead of the Recall file which misattributes speakers.
if (localTranscript?.length) {
const transcriptBuffer = Buffer.from(
JSON.stringify(localTranscript),
'utf-8',
);
const uploadedTranscript = await metadataClient.uploadFile(
transcriptBuffer,
'transcript.json',
'application/json',
TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
);
return {
transcriptFile: [
{ fileId: uploadedTranscript.id, label: 'transcript.json' },
],
transcript: {
blocknote: null,
markdown: localTranscriptToMarkdown(localTranscript),
},
};
}
// Fallback: use the Recall-provided transcript file when no local data
if (!transcriptUrl) {
return undefined;
}
const { buffer, fileName } = await downloadFile(transcriptUrl);
const uploadedTranscript = await metadataClient.uploadFile(
buffer,
fileName,
'application/json',
TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
);
const parsedEntries = transcriptSchema.parse(
JSON.parse(buffer.toString('utf-8')),
);
return {
transcriptFile: [{ fileId: uploadedTranscript.id, label: fileName }],
transcript: { blocknote: null, markdown: transcriptToMarkdown(parsedEntries) },
};
};
const handler = async (event: any) => {
const body = event.body as EndRecordingBody | null;
if (!body?.callRecordingId) {
throw new Error('Missing callRecordingId in request body');
}
if (!body?.audioUrl) {
throw new Error('Missing audioUrl in request body');
}
const client = new CoreApiClient();
const metadataClient = new MetadataApiClient();
const { callRecording } = await client.query({
callRecording: {
__args: {
filter: { id: { eq: body.callRecordingId } },
},
id: true,
name: true,
status: true,
},
});
if (!callRecording) {
throw new Error(`Call recording not found: ${body.callRecordingId}`);
}
if (callRecording.status === 'ENDED') {
throw new Error(`Call recording already ended: ${body.callRecordingId}`);
}
const { buffer, contentType, fileName } = await downloadFile(body.audioUrl);
const uploadedRecording = await metadataClient.uploadFile(
buffer,
fileName,
contentType,
RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER,
);
const transcriptData = await processTranscript(
metadataClient,
body.transcriptUrl,
body.localTranscript,
);
const callName = body.participants?.length
? `Call ${body.participants
.map((participant) => participant.name)
.join(' / ')}`
: undefined;
const updateData: Record<string, unknown> = {
status: 'ENDED',
endedAt: new Date().toISOString(),
recordingFile: [{ fileId: uploadedRecording.id, label: fileName }],
...transcriptData,
...(callName ? { name: callName } : {}),
};
delete updateData.createdAt;
await client.mutation({
updateCallRecording: {
__args: {
id: callRecording.id,
data: updateData,
},
id: true,
endedAt: true,
status: true,
},
});
// TODO: remove `as any` after running `yarn twenty app:dev` to regenerate the typed client
const updateSummary = async (markdown: string) => {
await client.mutation({
updateCallRecording: {
__args: {
id: callRecording.id,
data: {
summary: { blocknote: null, markdown },
} as any,
},
id: true,
},
});
};
if (transcriptData?.transcript?.markdown) {
console.log(
'[end-recording] Transcript available, attempting summarization...',
);
await updateSummary('*Generating summary...*');
try {
const summaryMarkdown = await summarizeTranscript(
transcriptData.transcript.markdown,
);
console.log(
'[end-recording] Summarization result:',
summaryMarkdown ? `${summaryMarkdown.length} chars` : 'undefined',
);
if (summaryMarkdown) {
await updateSummary(summaryMarkdown);
console.log('[end-recording] Summary saved to record');
} else {
await updateSummary('*Failed to generate summary: NO_RESPONSE*');
}
} catch (error) {
const errorCode =
error instanceof Error ? error.message : 'UNKNOWN_ERROR';
console.error('[end-recording] AI summarization failed:', error);
await updateSummary(`*Failed to generate summary: ${errorCode}*`);
}
} else {
console.log(
'[end-recording] No transcript markdown, skipping summarization',
);
}
if (body.participants?.length) {
await matchParticipants(callRecording.id, body.participants);
}
};
export default defineLogicFunction({
universalIdentifier: '471353f6-5933-417b-8062-9ad0fc44cd7f',
name: 'end-recording',
description: 'Endpoint to end a call recording',
timeoutSeconds: 60,
handler,
httpRouteTriggerSettings: {
path: '/end-recording',
httpMethod: 'POST',
isAuthRequired: false,
},
});
@@ -0,0 +1,10 @@
import { CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/call-recording-view';
import { defineNavigationMenuItem } from 'twenty-sdk';
export default defineNavigationMenuItem({
universalIdentifier: '5248a62d-7d2e-43a7-ba45-6e8f61876a71',
name: 'Call recordings',
icon: 'IconPhone',
position: 0,
viewUniversalIdentifier: CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,124 @@
import { defineObject, FieldType } from 'twenty-sdk';
export const CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER =
'af251b70-85c6-49bd-bf4a-2631f34c8f1a';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
'272dca6f-b3aa-49f5-b7ed-39780052f1fe';
export const CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'c581a044-f646-464b-aa4b-56b8ea9bf05a';
export const ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'56185e64-6591-41c1-a3e0-af8de20a5471';
export const RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER =
'e78d41fd-a493-4d06-b036-0dd7b7617dbe';
export const TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER =
'b2a3c8e1-7f94-4d5b-a6e2-9c1d0f3e8b47';
export const TRANSCRIPT_FIELD_UNIVERSAL_IDENTIFIER =
'a1d4e7c3-5b28-4f96-8e3a-0c7d9f2b6a15';
export const SUMMARY_FIELD_UNIVERSAL_IDENTIFIER =
'55eb083f-0b68-4f5c-bcd7-c853ad77ba11';
export const STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'24c92ad0-4559-4bf9-a9fa-09168914a142';
export default defineObject({
universalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'callRecording',
namePlural: 'callRecordings',
labelSingular: 'Call recording',
labelPlural: 'Call recordings',
description: 'A recorded call',
icon: 'IconPhone',
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
description: 'Name of the call recording',
icon: 'IconAbc',
},
{
universalIdentifier: CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'createdAt',
label: 'Created at',
description: 'When the call recording was created',
icon: 'IconCalendar',
},
{
universalIdentifier: ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'endedAt',
label: 'Ended at',
description: 'When the call ended',
icon: 'IconCalendar',
},
{
universalIdentifier: RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.FILES,
name: 'recordingFile',
label: 'Recording file',
description: 'The recording file of the call recording',
icon: 'IconFile',
universalSettings: { maxNumberOfValues: 1 },
},
{
universalIdentifier: TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.FILES,
name: 'transcriptFile',
label: 'Transcript file',
description: 'The transcript file of the call recording',
icon: 'IconFileText',
universalSettings: { maxNumberOfValues: 1 },
},
{
universalIdentifier: TRANSCRIPT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.RICH_TEXT_V2,
name: 'transcript',
label: 'Transcript',
description: 'Human-readable transcript of the call',
icon: 'IconMessage',
},
{
universalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
name: 'status',
label: 'Status',
description: 'Status of the call recording',
icon: 'IconStatusChange',
defaultValue: "'ONGOING'",
options: [
{
id: '8b275a4d-98ba-4718-912d-b1d97e713f5d',
value: 'ONGOING',
label: 'Ongoing',
position: 0,
color: 'blue',
},
{
id: 'a515ac77-44f8-4744-9c50-0a29352a800d',
value: 'ENDED',
label: 'Ended',
position: 1,
color: 'green',
},
],
},
{
universalIdentifier: SUMMARY_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.RICH_TEXT_V2,
name: 'summary',
label: 'Summary',
description: 'AI-generated summary of the call',
icon: 'IconSparkles',
},
],
});
@@ -0,0 +1,105 @@
import { PEOPLE_ON_CALL_RECORDING_ID } from 'src/fields/people-on-call-recording.field';
import { WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID } from 'src/fields/workspace-members-on-call-recording.field';
import {
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
NAME_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/objects/call-recording';
import { AggregateOperations, definePageLayout, ObjectRecordGroupByDateGranularity, PageLayoutTabLayoutMode } from 'twenty-sdk';
export const CALL_RECORDING_DASHBOARD_LAYOUT_UNIVERSAL_IDENTIFIER =
'17ff2924-00c9-4105-ac4e-64c28cba781f';
export default definePageLayout({
universalIdentifier: CALL_RECORDING_DASHBOARD_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'Call Recording Insights',
type: 'DASHBOARD',
tabs: [
{
universalIdentifier: 'aa3398e8-b7e8-402f-9681-4cdc44fdd6d8',
title: 'Overview',
position: 0,
icon: 'IconChartBar',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: '1a04a308-5318-4f75-9b3a-4ee414750508',
title: 'Total Calls',
type: 'GRAPH',
objectUniversalIdentifier:
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 3 },
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
label: 'Total Calls',
},
},
{
universalIdentifier: '26c8190f-ff63-45f8-9cd9-d8bfd1380cca',
title: 'Calls per Person',
type: 'GRAPH',
objectUniversalIdentifier:
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 0, column: 3, rowSpan: 6, columnSpan: 4 },
configuration: {
configurationType: 'PIE_CHART',
aggregateFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataUniversalIdentifier:
PEOPLE_ON_CALL_RECORDING_ID,
groupBySubFieldName: 'name.firstName',
showCenterMetric: true,
displayLegend: true,
displayDataLabel: false,
color: 'blue',
},
},
{
universalIdentifier: 'd3741561-e9ca-4dd2-8510-4f49d25911e5',
title: 'Calls per Workspace Member',
type: 'GRAPH',
objectUniversalIdentifier:
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 0, column: 7, rowSpan: 6, columnSpan: 5 },
configuration: {
configurationType: 'BAR_CHART',
aggregateFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID,
primaryAxisGroupBySubFieldName: 'name.firstName',
displayDataLabel: true,
displayLegend: false,
color: 'turquoise',
layout: 'VERTICAL',
},
},
{
universalIdentifier: '7916f127-8f60-4226-a0b8-45632cf3cfe7',
title: 'Calls Over Time',
type: 'GRAPH',
objectUniversalIdentifier:
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 6, column: 0, rowSpan: 6, columnSpan: 12 },
configuration: {
configurationType: 'LINE_CHART',
aggregateFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
displayDataLabel: false,
displayLegend: false,
color: 'purple',
},
},
],
},
],
});
@@ -0,0 +1,119 @@
import { CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-summary-viewer-front-component-universal-identifier';
import { CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-viewer-front-component-universal-identifier';
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
export default definePageLayout({
universalIdentifier: 'b7e3a1d4-5c92-4f68-9a0b-3e8d7c6f1a25',
name: 'Call Recording Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: 'e6b2d8f4-7a13-4c59-b2e1-9d4f0c8a3b67',
title: 'Summary',
position: 50,
icon: 'IconSparkles',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'e5c93fce-76b4-41e9-9c5d-9b17e034366c',
title: 'Summary',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
{
universalIdentifier: 'c4f8e2a6-3d71-4b95-8e0c-1a9f6d5b7c34',
title: 'Transcript',
position: 100,
icon: 'IconVideo',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'd5a9f3b7-4e82-4c06-9f1d-2b0a7e6c8d45',
title: 'Media Player',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
{
universalIdentifier: 'f7c1b5d9-6a04-4e28-b13f-4d2c9a8e0f67',
title: 'Timeline',
position: 200,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: '23c87a9c-25e3-4e83-84d9-02fb1a6fde76',
title: 'Timeline',
type: 'TIMELINE',
configuration: {
configurationType: 'TIMELINE',
},
},
],
},
{
universalIdentifier: '498e47e5-bed1-4492-a08d-b12b7ff40ed9',
title: 'Tasks',
position: 300,
icon: 'IconCheckbox',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'ae93482f-384f-42a8-9c06-6bc14b10da6a',
title: 'Tasks',
type: 'TASKS',
configuration: {
configurationType: 'TASKS',
},
},
],
},
{
universalIdentifier: 'c111ccf0-b95b-4333-b2bc-a7a9da40f913',
title: 'Notes',
position: 400,
icon: 'IconNotes',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'e2b6a0c4-1f59-4d73-a684-9c7b4f3d5e12',
title: 'Notes',
type: 'NOTES',
configuration: {
configurationType: 'NOTES',
},
},
],
},
{
universalIdentifier: 'f3c7b1d5-2a60-4e84-b795-0d8c5a4e6f23',
title: 'Files',
position: 500,
icon: 'IconPaperclip',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'a17bf74a-a7ff-48a0-8628-3fd905539c8d',
title: 'Files',
type: 'FILES',
configuration: {
configurationType: 'FILES',
},
},
],
},
],
});
@@ -0,0 +1,16 @@
import { defineRole } from 'twenty-sdk';
import { PermissionFlagType } from 'twenty-shared/constants';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'f9cfb3ce-cb1e-4f55-af85-be45f6059054';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Call recording default function role',
description: 'Call recording default function role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
permissionFlags: [PermissionFlagType.UPLOAD_FILE, PermissionFlagType.AI],
});
@@ -0,0 +1,51 @@
import { defineSkill } from 'twenty-sdk';
export const CALL_TRANSCRIPT_SUMMARIZATION_SKILL_UNIVERSAL_IDENTIFIER =
'11fb51a7-4d5a-4168-91d7-9fbe5eb7d609';
export default defineSkill({
universalIdentifier: CALL_TRANSCRIPT_SUMMARIZATION_SKILL_UNIVERSAL_IDENTIFIER,
name: 'call-transcript-summarization',
label: 'Call Transcript Summarization',
description:
'Instructions for summarizing and analyzing call recording transcripts',
content: `# Call Transcript Summarization
## When to Use
Use this skill when a user asks you to summarize, analyze, or extract insights from a call recording.
## How to Access the Data
1. Use \`find_one_callRecording\` to fetch the call recording by its ID.
2. Read the \`transcript\` field (RICH_TEXT_V2, markdown format) which contains the full conversation.
3. The transcript uses the format: **Speaker Name:** spoken text
## What to Produce
Generate a structured summary with these sections:
### Overview
A 2-3 sentence high-level description of what the call was about, who participated, and the general outcome.
### Key Discussion Points
Bullet points covering the main topics discussed, organized chronologically or by theme.
### Decisions Made
Any explicit decisions or agreements reached during the call.
### Action Items
Concrete next steps mentioned during the call, including who is responsible (if stated).
### Sentiment & Tone
A brief note on the overall tone of the conversation (collaborative, tense, exploratory, etc.).
## Output Format
- Use markdown formatting.
- Keep the summary concise — aim for roughly 20% of the transcript length.
- If the transcript is very short (under 200 words), provide a brief 2-3 sentence summary instead of the full structure.
## Saving the Summary
After generating the summary, use \`update_callRecording\` to save it in the \`summary\` field with the format:
\`\`\`json
{ "summary": { "blocknote": null, "markdown": "<your summary>" } }
\`\`\`
`,
});
@@ -0,0 +1,4 @@
import { AUDIO_EXTENSIONS } from 'src/constants/audio-extensions';
export const isAudioExtension = (extension: string): boolean =>
AUDIO_EXTENSIONS.includes(extension.toLowerCase() as (typeof AUDIO_EXTENSIONS)[number]);

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