Compare commits

...
Author SHA1 Message Date
Lucas Bordeau cd04b27a57 refactor: simplify z-index logic and enhance portal styling in record table
- Add static hoverPortal z-index to TABLE_Z_INDEX constant
- Remove scroll-dependent z-index sections (withGroups, withoutGroups scroll states, withoutGroupsCell0_0)
- Simplify RecordTableCellFocusedPortal to use static z-index value
- Simplify RecordTableCellFirstRowFirstColumn to use single static z-index
- Simplify RecordTableHeaderFirstScrollableCell to use static z-index
- Add outline-offset to hover and focus portals for proper cell boundaries
- Fix Linaria arithmetic interpolations for correct CSS unit spacing
2026-03-05 18:17:54 +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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Abdullah <[email protected]>
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] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Abdullah <[email protected]>
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] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Abdullah <[email protected]>
2026-03-05 09:21:45 +01:00
228865bd94 i18n - translations (#18398)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Co-authored-by: bosiraphael <[email protected]>
Co-authored-by: Weiko <[email protected]>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <[email protected]>
Co-authored-by: Charles Bochet <[email protected]>
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 <[email protected]>
Co-authored-by: Lucas Bordeau <[email protected]>
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
Charles BochetandGitHub 3b2bf39565 Refactor modal (#18377)
## Summary

- Move Modal UI components (`Modal`, `ModalContent`, `ModalHeader`,
`ModalFooter`, `ModalBackdrop`) from `twenty-front` to `twenty-ui` as
stateless, reusable components
- Create `ModalStatefulWrapper` in `twenty-front` that connects Jotai
state (`isModalOpenedComponentState`) to the stateless `Modal` via an
`isOpen` prop
- Rename `modalVariant` prop to `overlay` with clearer values: `'dark'`
(default), `'light'` (in-container), `'transparent'` (invisible panel).
Remove unused `'medium'` overlay
- Rename `modalId` to `modalInstanceId` across the entire modal zone
(~30 consumer files)
- Extract `ModalProps` to its own file in
`twenty-ui/types/ModalProps.ts`; extract `ModalStatefulWrapperProps` to
its own file using `Pick<ModalProps, ...>` for shared props
- Extract `ModalBackdrop` to its own file and export from `twenty-ui`;
use it in `UserOrMetadataLoader` instead of a local styled component
- Use `ModalFooter` in `StepNavigationButton` and `ModalHeader` in
`SpreadsheetImportStepperContainer` instead of duplicated `styled.div`
definitions
- Remove unused `onClose` prop from stateless `Modal`; fix `typeof
document` guard in `ModalStatefulWrapper`
- Split shared types into individual files: `ModalSize.ts`,
`ModalPadding.ts`, `ModalOverlay.ts`
- Extract wyw profiling instrumentation from `vite.config.ts` into
reusable `createWywProfilingPlugin` with parametrized threshold and
improved logging
- Delete old `Modal.tsx`, `Modal.styles.ts`, `ModalContent.tsx`,
`ModalHeader.tsx`, `ModalFooter.tsx` from `twenty-front`
- Add comprehensive Storybook stories in `twenty-ui` covering Default,
Confirmation, Small, ExtraLarge, Closed, and Interactive variants
2026-03-04 13:22:31 +01:00
Paul RastoinandGitHub 995793c0ac [CREATE_APP] Integration testing scaffold (#18345)
# Introduction
Adding integration test scaffold to the create twenty app and an example
to the hello world app
This PR also fixes all the sdk e2e tests in local

## `HELLO_WORLD`
Removed the legacy implem in the `twenty-apps` folder, replacing it by
an exhaustive app generation

## Next step
Will in another PR add workflows for CI testing

## Open question
- Should we still add vitest config and dep even if the user did not ask
for the integration test example ? -> currently we don't
- That's the perfect timing to identify if we're ok to handle seed
workspace authentication with the known api key
2026-03-04 13:12:13 +01:00
1930 changed files with 73931 additions and 18611 deletions
+1
View File
@@ -48,6 +48,7 @@
"search.exclude": {
"**/.yarn": true
},
"eslint.useFlatConfig": true,
"eslint.debug": true,
"files.associations": {
".cursorrules": "markdown"
+14 -14
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,14 @@
"@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/eslint": "22.5.4",
"@nx/eslint-plugin": "22.5.4",
"@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",
"@sentry/types": "^8",
"@storybook-community/storybook-addon-cookie": "^5.0.0",
"@storybook/addon-coverage": "^3.0.0",
@@ -90,10 +90,10 @@
"@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",
@@ -170,7 +170,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",
+2
View File
@@ -89,6 +89,7 @@ In interactive mode, you can pick from:
- **Example view** — a saved view for the example object (`views/example-view.ts`)
- **Example navigation menu item** — a sidebar link (`navigation-menu-items/example-navigation-menu-item.ts`)
- **Example skill** — an AI agent skill definition (`skills/example-skill.ts`)
- **Integration test** — a vitest integration test verifying app installation (`__tests__/app-install.integration-test.ts`)
## What gets scaffolded
@@ -108,6 +109,7 @@ In interactive mode, you can pick from:
- `views/example-view.ts` — Example saved view for the example object
- `navigation-menu-items/example-navigation-menu-item.ts` — Example sidebar navigation link
- `skills/example-skill.ts` — Example AI agent skill definition
- `__tests__/app-install.integration-test.ts` — Integration test that builds, installs, and verifies the app (includes `vitest.config.ts`, `tsconfig.spec.json`, and a setup file)
## Next steps
- Run `yarn twenty help` to see all available commands.
@@ -36,6 +36,17 @@ yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
```
## Integration Tests
If your project includes the example integration test (`src/__tests__/app-install.integration-test.ts`), you can run it with:
```bash
# Make sure a Twenty server is running at http://localhost:3000
yarn test
```
The test builds and installs the app, then verifies it appears in the applications list. Test configuration (API URL and API key) is defined in `vitest.config.ts`.
## LLMs instructions
Main docs and pitfalls are available in LLMS.md file.
@@ -27,5 +27,5 @@
"~/*": ["./*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/*.integration-test.ts"]
}
@@ -115,6 +115,7 @@ export class CreateAppCommand {
includeExampleView: false,
includeExampleNavigationMenuItem: false,
includeExampleSkill: false,
includeExampleIntegrationTest: false,
};
}
@@ -127,6 +128,7 @@ export class CreateAppCommand {
includeExampleView: true,
includeExampleNavigationMenuItem: true,
includeExampleSkill: true,
includeExampleIntegrationTest: true,
};
}
@@ -171,12 +173,19 @@ export class CreateAppCommand {
value: 'skill',
checked: true,
},
{
name: 'Integration test (vitest test verifying app installation)',
value: 'integrationTest',
checked: true,
},
],
},
]);
const includeField = selectedExamples.includes('field');
const includeView = selectedExamples.includes('view');
const includeExampleIntegrationTest =
selectedExamples.includes('integrationTest');
const includeObject =
selectedExamples.includes('object') || includeField || includeView;
@@ -197,6 +206,7 @@ export class CreateAppCommand {
includeExampleNavigationMenuItem:
selectedExamples.includes('navigationMenuItem'),
includeExampleSkill: selectedExamples.includes('skill'),
includeExampleIntegrationTest,
};
}
@@ -8,4 +8,5 @@ export type ExampleOptions = {
includeExampleView: boolean;
includeExampleNavigationMenuItem: boolean;
includeExampleSkill: boolean;
includeExampleIntegrationTest: boolean;
};
@@ -1,10 +1,10 @@
import { type ExampleOptions } from '@/types/scaffolding-options';
import { GENERATED_DIR } from 'twenty-shared/application';
import { copyBaseApplicationProject } from '@/utils/app-template';
import * as fs from 'fs-extra';
import { tmpdir } from 'os';
import { join } from 'path';
import createTwentyAppPackageJson from 'package.json';
import { join } from 'path';
import { GENERATED_DIR } from 'twenty-shared/application';
jest.mock('fs-extra', () => {
const actual = jest.requireActual('fs-extra');
@@ -25,6 +25,16 @@ const ALL_EXAMPLES: ExampleOptions = {
includeExampleView: true,
includeExampleNavigationMenuItem: true,
includeExampleSkill: true,
includeExampleIntegrationTest: true,
};
const SEED_TSCONFIG = {
compilerOptions: { paths: { 'src/*': ['./src/*'] } },
exclude: ['node_modules', 'dist', '**/*.integration-test.ts'],
};
const seedTsconfig = async (directory: string) => {
await fs.writeJson(join(directory, 'tsconfig.json'), SEED_TSCONFIG);
};
const NO_EXAMPLES: ExampleOptions = {
@@ -35,6 +45,7 @@ const NO_EXAMPLES: ExampleOptions = {
includeExampleFrontComponent: false,
includeExampleView: false,
includeExampleNavigationMenuItem: false,
includeExampleIntegrationTest: false,
};
describe('copyBaseApplicationProject', () => {
@@ -46,6 +57,7 @@ describe('copyBaseApplicationProject', () => {
`test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await fs.ensureDir(testAppDirectory);
await seedTsconfig(testAppDirectory);
jest.clearAllMocks();
});
@@ -149,11 +161,18 @@ describe('copyBaseApplicationProject', () => {
"import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'",
);
expect(appConfigContent).toContain(
'export const APPLICATION_UNIVERSAL_IDENTIFIER',
);
expect(appConfigContent).toContain(
'universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER',
);
expect(appConfigContent).toContain("displayName: 'My Test App'");
expect(appConfigContent).toContain("description: 'A test application'");
expect(appConfigContent).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
/APPLICATION_UNIVERSAL_IDENTIFIER =\s*'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
expect(appConfigContent).toContain(
@@ -235,6 +254,7 @@ describe('copyBaseApplicationProject', () => {
it('should generate unique UUIDs for each application', async () => {
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await seedTsconfig(firstAppDir);
await copyBaseApplicationProject({
appName: 'app-one',
appDisplayName: 'App One',
@@ -245,6 +265,7 @@ describe('copyBaseApplicationProject', () => {
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await seedTsconfig(secondAppDir);
await copyBaseApplicationProject({
appName: 'app-two',
appDisplayName: 'App Two',
@@ -263,7 +284,7 @@ describe('copyBaseApplicationProject', () => {
);
const uuidRegex =
/universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
/APPLICATION_UNIVERSAL_IDENTIFIER =\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const firstUuid = firstAppConfig.match(uuidRegex)?.[1];
const secondUuid = secondAppConfig.match(uuidRegex)?.[1];
@@ -275,6 +296,7 @@ describe('copyBaseApplicationProject', () => {
it('should generate unique role UUIDs for each application', async () => {
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await seedTsconfig(firstAppDir);
await copyBaseApplicationProject({
appName: 'app-one',
appDisplayName: 'App One',
@@ -285,6 +307,7 @@ describe('copyBaseApplicationProject', () => {
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await seedTsconfig(secondAppDir);
await copyBaseApplicationProject({
appName: 'app-two',
appDisplayName: 'App Two',
@@ -355,6 +378,12 @@ describe('copyBaseApplicationProject', () => {
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, '__tests__', 'app-install.integration-test.ts'),
),
).toBe(true);
// Install functions should always exist
expect(
await fs.pathExists(
@@ -428,6 +457,11 @@ describe('copyBaseApplicationProject', () => {
),
),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, '__tests__', 'app-install.integration-test.ts'),
),
).toBe(false);
});
});
@@ -446,6 +480,7 @@ describe('copyBaseApplicationProject', () => {
includeExampleFrontComponent: true,
includeExampleView: false,
includeExampleNavigationMenuItem: false,
includeExampleIntegrationTest: false,
},
});
@@ -483,6 +518,7 @@ describe('copyBaseApplicationProject', () => {
includeExampleFrontComponent: false,
includeExampleView: false,
includeExampleNavigationMenuItem: false,
includeExampleIntegrationTest: false,
},
});
@@ -540,6 +576,7 @@ describe('copyBaseApplicationProject', () => {
it('should generate unique UUIDs for example objects across apps', async () => {
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await seedTsconfig(firstAppDir);
await copyBaseApplicationProject({
appName: 'app-one',
appDisplayName: 'App One',
@@ -550,6 +587,7 @@ describe('copyBaseApplicationProject', () => {
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await seedTsconfig(secondAppDir);
await copyBaseApplicationProject({
appName: 'app-two',
appDisplayName: 'App Two',
@@ -738,6 +776,44 @@ describe('copyBaseApplicationProject', () => {
});
});
describe('integration test', () => {
it('should include vitest and test scripts in package.json when enabled', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const packageJson = await fs.readJson(
join(testAppDirectory, 'package.json'),
);
expect(packageJson.scripts.test).toBe('vitest run');
expect(packageJson.scripts['test:watch']).toBe('vitest');
expect(packageJson.devDependencies.vitest).toBeDefined();
});
it('should not include vitest or test scripts when disabled', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: NO_EXAMPLES,
});
const packageJson = await fs.readJson(
join(testAppDirectory, 'package.json'),
);
expect(packageJson.scripts.test).toBeUndefined();
expect(packageJson.scripts['test:watch']).toBeUndefined();
expect(packageJson.devDependencies.vitest).toBeUndefined();
});
});
describe('post-install logic function', () => {
it('should create post-install.ts with definePostInstallLogicFunction and typed payload', async () => {
await copyBaseApplicationProject({
@@ -0,0 +1,144 @@
import { scaffoldIntegrationTest } from '@/utils/test-template';
import * as fs from 'fs-extra';
import { tmpdir } from 'os';
import { join } from 'path';
describe('scaffoldIntegrationTest', () => {
let testAppDirectory: string;
let sourceFolderPath: string;
beforeEach(async () => {
testAppDirectory = join(
tmpdir(),
`test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
sourceFolderPath = join(testAppDirectory, 'src');
await fs.ensureDir(sourceFolderPath);
await fs.writeJson(join(testAppDirectory, 'tsconfig.json'), {
compilerOptions: {
paths: { 'src/*': ['./src/*'] },
},
exclude: ['node_modules', 'dist', '**/*.integration-test.ts'],
});
});
afterEach(async () => {
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
await fs.remove(testAppDirectory);
}
});
describe('integration test file', () => {
it('should create app-install.integration-test.ts with correct structure', async () => {
await scaffoldIntegrationTest({
appDirectory: testAppDirectory,
sourceFolderPath,
});
const testPath = join(
sourceFolderPath,
'__tests__',
'app-install.integration-test.ts',
);
expect(await fs.pathExists(testPath)).toBe(true);
const content = await fs.readFile(testPath, 'utf8');
expect(content).toContain(
"import { appBuild, appUninstall } from 'twenty-sdk/cli'",
);
expect(content).toContain(
"import { MetadataApiClient } from 'twenty-sdk/generated'",
);
expect(content).toContain(
"import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config'",
);
expect(content).toContain('TWENTY_TEST_API_KEY');
expect(content).toContain('assertServerIsReachable');
expect(content).toContain('appBuild');
expect(content).toContain('appUninstall');
expect(content).toContain('findManyApplications');
expect(content).toContain('APPLICATION_UNIVERSAL_IDENTIFIER');
});
});
describe('setup-test file', () => {
it('should create setup-test.ts with SDK config bootstrap', async () => {
await scaffoldIntegrationTest({
appDirectory: testAppDirectory,
sourceFolderPath,
});
const setupTestPath = join(
sourceFolderPath,
'__tests__',
'setup-test.ts',
);
expect(await fs.pathExists(setupTestPath)).toBe(true);
const content = await fs.readFile(setupTestPath, 'utf8');
expect(content).toContain('.twenty-sdk-test');
expect(content).toContain('config.json');
expect(content).toContain('process.env.TWENTY_API_URL');
expect(content).toContain('process.env.TWENTY_TEST_API_KEY');
});
});
describe('vitest config', () => {
it('should create vitest.config.ts with env vars and setup file', async () => {
await scaffoldIntegrationTest({
appDirectory: testAppDirectory,
sourceFolderPath,
});
const vitestConfigPath = join(testAppDirectory, 'vitest.config.ts');
expect(await fs.pathExists(vitestConfigPath)).toBe(true);
const content = await fs.readFile(vitestConfigPath, 'utf8');
expect(content).toContain('TWENTY_TEST_API_KEY');
expect(content).toContain('TWENTY_API_URL');
expect(content).toContain('setup-test.ts');
expect(content).toContain('tsconfig.spec.json');
expect(content).toContain('integration-test.ts');
});
});
describe('tsconfig.spec.json', () => {
it('should create tsconfig.spec.json extending the base tsconfig', async () => {
await scaffoldIntegrationTest({
appDirectory: testAppDirectory,
sourceFolderPath,
});
const tsconfigSpecPath = join(testAppDirectory, 'tsconfig.spec.json');
expect(await fs.pathExists(tsconfigSpecPath)).toBe(true);
const tsconfigSpec = await fs.readJson(tsconfigSpecPath);
expect(tsconfigSpec.extends).toBe('./tsconfig.json');
expect(tsconfigSpec.compilerOptions.composite).toBe(true);
expect(tsconfigSpec.include).toContain('src/**/*.ts');
expect(tsconfigSpec.exclude).not.toContain('**/*.integration-test.ts');
});
it('should add a reference to tsconfig.spec.json in tsconfig.json', async () => {
await scaffoldIntegrationTest({
appDirectory: testAppDirectory,
sourceFolderPath,
});
const tsconfig = await fs.readJson(
join(testAppDirectory, 'tsconfig.json'),
);
expect(tsconfig.references).toEqual([{ path: './tsconfig.spec.json' }]);
});
});
});
@@ -1,9 +1,10 @@
import * as fs from 'fs-extra';
import { join } from 'path';
import { v4 } from 'uuid';
import { ASSETS_DIR } from 'twenty-shared/application';
import { v4 } from 'uuid';
import { type ExampleOptions } from '@/types/scaffolding-options';
import { scaffoldIntegrationTest } from '@/utils/test-template';
import createTwentyAppPackageJson from 'package.json';
const SRC_FOLDER = 'src';
@@ -23,7 +24,11 @@ export const copyBaseApplicationProject = async ({
}) => {
await fs.copy(join(__dirname, './constants/base-application'), appDirectory);
await createPackageJson({ appName, appDirectory });
await createPackageJson({
appName,
appDirectory,
includeExampleIntegrationTest: exampleOptions.includeExampleIntegrationTest,
});
await createGitignore(appDirectory);
@@ -98,6 +103,13 @@ export const copyBaseApplicationProject = async ({
});
}
if (exampleOptions.includeExampleIntegrationTest) {
await scaffoldIntegrationTest({
appDirectory,
sourceFolderPath,
});
}
await createDefaultPreInstallFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
@@ -167,6 +179,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
*.d.ts
`;
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
@@ -509,11 +522,16 @@ const createApplicationConfig = async ({
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'${universalIdentifier}';
export default defineApplication({
universalIdentifier: '${v4()}',
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: '${displayName}',
description: '${description ?? ''}',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
@@ -527,10 +545,35 @@ export default defineApplication({
const createPackageJson = async ({
appName,
appDirectory,
includeExampleIntegrationTest,
}: {
appName: string;
appDirectory: string;
includeExampleIntegrationTest: boolean;
}) => {
const scripts: Record<string, string> = {
twenty: 'twenty',
lint: 'eslint',
'lint:fix': 'eslint --fix',
};
const devDependencies: Record<string, string> = {
typescript: '^5.9.3',
'@types/node': '^24.7.2',
'@types/react': '^18.2.0',
react: '^18.2.0',
eslint: '^9.32.0',
'typescript-eslint': '^8.50.0',
'twenty-sdk': createTwentyAppPackageJson.version,
};
if (includeExampleIntegrationTest) {
scripts.test = 'vitest run';
scripts['test:watch'] = 'vitest';
devDependencies.vitest = '^3.1.1';
devDependencies['vite-tsconfig-paths'] = '^4.2.1';
}
const packageJson = {
name: appName,
version: '0.1.0',
@@ -541,21 +584,8 @@ const createPackageJson = async ({
yarn: '>=4.0.2',
},
packageManager: '[email protected]',
scripts: {
twenty: 'twenty',
lint: 'eslint',
'lint:fix': 'eslint --fix',
},
dependencies: {},
devDependencies: {
'twenty-sdk': createTwentyAppPackageJson.version,
typescript: '^5.9.3',
'@types/node': '^24.7.2',
'@types/react': '^18.2.0',
react: '^18.2.0',
eslint: '^9.32.0',
'typescript-eslint': '^8.50.0',
},
scripts,
devDependencies,
};
await fs.writeFile(
@@ -0,0 +1,226 @@
import * as fs from 'fs-extra';
import { join } from 'path';
const SEED_API_KEY =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik';
export const scaffoldIntegrationTest = async ({
appDirectory,
sourceFolderPath,
}: {
appDirectory: string;
sourceFolderPath: string;
}) => {
await createIntegrationTest({
appDirectory: sourceFolderPath,
fileFolder: '__tests__',
fileName: 'app-install.integration-test.ts',
});
await createSetupTest({
appDirectory: sourceFolderPath,
fileFolder: '__tests__',
fileName: 'setup-test.ts',
});
await createVitestConfig(appDirectory);
await createTsconfigSpec(appDirectory);
};
const createVitestConfig = async (appDirectory: string) => {
const content = `import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
include: ['src/**/*.integration-test.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL: 'http://localhost:3000',
TWENTY_TEST_API_KEY:
'${SEED_API_KEY}',
},
},
});
`;
await fs.writeFile(join(appDirectory, 'vitest.config.ts'), content);
};
const createTsconfigSpec = async (appDirectory: string) => {
const tsconfigSpec = {
extends: './tsconfig.json',
compilerOptions: {
composite: true,
types: ['vitest/globals'],
},
include: ['src/**/*.ts', 'src/**/*.tsx'],
exclude: ['node_modules', 'dist'],
};
await fs.writeFile(
join(appDirectory, 'tsconfig.spec.json'),
JSON.stringify(tsconfigSpec, null, 2),
);
const tsconfigPath = join(appDirectory, 'tsconfig.json');
const tsconfig = await fs.readJson(tsconfigPath);
tsconfig.references = [{ path: './tsconfig.spec.json' }];
await fs.writeFile(tsconfigPath, JSON.stringify(tsconfig, null, 2));
};
const createSetupTest = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const content = `import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
beforeAll(() => {
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
profiles: {
default: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_TEST_API_KEY,
},
},
};
fs.writeFileSync(
path.join(TEST_CONFIG_DIR, 'config.json'),
JSON.stringify(configFile, null, 2),
);
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createIntegrationTest = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const content = `import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/generated';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(\`\${TWENTY_API_URL}/healthz\`);
} catch {
throw new Error(
\`Twenty server is not reachable at \${TWENTY_API_URL}. \` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(\`Server at \${TWENTY_API_URL} returned \${response.status}\`);
}
};
describe('App installation', () => {
let appInstalled = false;
beforeAll(async () => {
await assertServerIsReachable();
const buildResult = await appBuild({
appPath: APP_PATH,
onProgress: (message: string) => console.log(\`[build] \${message}\`),
});
if (!buildResult.success) {
throw new Error(
\`App build failed: \${buildResult.error?.message ?? 'Unknown error'}\`,
);
}
appInstalled = true;
});
afterAll(async () => {
if (!appInstalled) {
return;
}
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
\`App uninstall failed: \${uninstallResult.error?.message ?? 'Unknown error'}\`,
);
}
});
it('should find the installed app in the applications list', async () => {
const apiKey = process.env.TWENTY_TEST_API_KEY;
if (!apiKey) {
throw new Error(
'No API key found. Set TWENTY_TEST_API_KEY in your vitest config env.',
);
}
const metadataClient = new MetadataApiClient({
url: \`\${TWENTY_API_URL}/metadata\`,
headers: {
Authorization: \`Bearer \${apiKey}\`,
},
});
const result = await metadataClient.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const installedApp = result.findManyApplications.find(
(application: { universalIdentifier: string }) =>
application.universalIdentifier ===
APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(installedApp).toBeDefined();
});
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
+3
View File
@@ -23,5 +23,8 @@
"**/__mocks__/**/*",
"vite.config.ts",
"jest.config.mjs"
],
"exclude": [
"src/constants/base-application/vitest.config.ts"
]
}
@@ -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 = {
@@ -219,4 +219,3 @@ main().catch((error) => {
process.exit(1);
});
@@ -81,4 +81,3 @@ describe('HistoricalImporter', () => {
});
});
@@ -158,4 +158,3 @@ export class HistoricalImporter {
}
}
@@ -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 || '';
@@ -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 {}
}
@@ -1,2 +0,0 @@
TWENTY_API_KEY=<SET_YOUR_TWENTY_API_KEY>
TWENTY_API_URL=<SET_YOUR_TWENTY_API_URL>
+37 -2
View File
@@ -1,3 +1,38 @@
.yarn/install-state.gz
.env
# 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
# 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
*.d.ts
+1
View File
@@ -0,0 +1 @@
24.5.0
+12
View File
@@ -0,0 +1,12 @@
## 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
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## 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.
+47 -26
View File
@@ -1,41 +1,62 @@
# Hello world
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
A minimal hello-world application built with an alpha version of the twenty-cli.
## Getting Started
⚠️ Since this project uses an early alpha release, expect breaking changes and evolving features.
This example will gradually gain complexity and capabilities as the twenty-cli matures with future updates.
## Requirements
- twenty-cli `npm install -g twenty-cli`
- an `apiKey`. Go to `/settings/api-webhooks` to generate one
## Install to your Twenty workspace
First, authenticate to your workspace:
```bash
cp .env.example .env
yarn twenty auth:login
```
- replace `<SET_YOUR_TWENTY_API_KEY>` and `<SET_YOUR_TWENTY_API_URL>` accordingly
Then, start development mode to sync your app and watch for changes:
```bash
twenty auth login
twenty app sync
yarn twenty app:dev
```
## What it does
- creates a new object `postCard` with a `name` field
- creates a serverless function `create-new-post-card`
- two triggers on `create-new-post-card`:
- one route trigger on `GET` `/post-card/create?recipient=RecipientName`
- one databaseEvent trigger on `people.created` events
Open your Twenty instance and go to `/settings/applications` section to see the result.
## Development
## Available Commands
Run dev mode and see application updates on your workspace instantly
Run `yarn twenty help` to list all available commands. Common commands:
```bash
twenty app dev
# 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
```
## Integration Tests
If your project includes the example integration test (`src/__tests__/app-install.integration-test.ts`), you can run it with:
```bash
# Make sure a Twenty server is running at http://localhost:3000
yarn test
```
The test builds and installs the app, then verifies it appears in the applications list. Test configuration (API URL and API key) is defined in `vitest.config.ts`.
## 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,29 @@
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
},
},
];
+14 -11
View File
@@ -1,5 +1,5 @@
{
"name": "hello-world",
"name": "@twentyhq/hello-world",
"version": "0.2.2",
"license": "MIT",
"engines": {
@@ -10,17 +10,20 @@
"packageManager": "[email protected]",
"scripts": {
"twenty": "twenty",
"auth": "twenty auth:login",
"dev": "twenty app:dev",
"build": "twenty app:build",
"typecheck": "twenty app:typecheck",
"uninstall": "twenty app:uninstall",
"entity:add": "twenty entity:add"
},
"dependencies": {
"twenty-sdk": "0.6.3"
"lint": "eslint",
"lint:fix": "eslint --fix",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@types/node": "^24.7.2"
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"eslint": "^9.32.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"
}
}
@@ -0,0 +1,92 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/generated';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(`${TWENTY_API_URL}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${TWENTY_API_URL} returned ${response.status}`);
}
};
describe('App installation', () => {
let appInstalled = false;
beforeAll(async () => {
await assertServerIsReachable();
const buildResult = await appBuild({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[build] ${message}`),
});
if (!buildResult.success) {
throw new Error(
`App build failed: ${buildResult.error?.message ?? 'Unknown error'}`,
);
}
appInstalled = true;
});
afterAll(async () => {
if (!appInstalled) {
return;
}
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
});
it('should find the installed app in the applications list', async () => {
const apiKey = process.env.TWENTY_TEST_API_KEY;
if (!apiKey) {
throw new Error(
'No API key found. Set TWENTY_TEST_API_KEY in your vitest config env.',
);
}
const metadataClient = new MetadataApiClient({
url: `${TWENTY_API_URL}/metadata`,
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const result = await metadataClient.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const installedApp = result.findManyApplications.find(
(application: { universalIdentifier: string }) =>
application.universalIdentifier ===
APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(installedApp).toBeDefined();
});
});
@@ -0,0 +1,24 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
beforeAll(() => {
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
profiles: {
default: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_TEST_API_KEY,
},
},
};
fs.writeFileSync(
path.join(TEST_CONFIG_DIR, 'config.json'),
JSON.stringify(configFile, null, 2),
);
});
@@ -1,55 +0,0 @@
import {
defineLogicFunction,
type CronPayload,
type DatabaseEventPayload,
type ObjectRecordCreateEvent,
} from 'twenty-sdk';
import { CoreApiClient as Twenty, type CoreSchema } from 'twenty-sdk/generated';
type CreateNewPostCardParams =
| { name?: string }
| DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.Person>>
| CronPayload;
const handler = async (params: CreateNewPostCardParams) => {
const client = new Twenty();
const name =
'name' in params
? params.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
: 'Hello world';
const createPostCard = await client.mutation({
createPostCard: {
__args: {
data: {
name,
},
},
name: true,
id: true,
},
});
console.log('createPostCard result', createPostCard);
return createPostCard;
};
export default defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'create-new-post-card',
timeoutSeconds: 2,
handler,
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
},
cronTriggerSettings: {
pattern: '0 0 1 1 *',
},
databaseEventTriggerSettings: {
eventName: 'person.created',
},
});
@@ -0,0 +1,12 @@
import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'1badae7c-8a42-4dea-b4b8-3c56e77c2f9a';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Hello world',
description: '',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
@@ -1,17 +0,0 @@
import { defineApplication } from 'twenty-sdk';
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
displayName: 'Hello World',
description: 'A simple hello world app',
icon: 'IconWorld',
applicationVariables: {
DEFAULT_RECIPIENT_NAME: {
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
description: 'Default recipient name for postcards',
value: 'Alex Karp',
isSecret: false,
},
},
defaultRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
});
@@ -0,0 +1,11 @@
import { defineField, FieldType } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
export default defineField({
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
universalIdentifier: '2c503a0d-36c9-49ec-b82f-4fafe0eb6f47',
type: FieldType.NUMBER,
name: 'priority',
label: 'Priority',
description: 'Priority level for the example item (1-10)',
});
@@ -0,0 +1,17 @@
import { defineFrontComponent } from 'twenty-sdk';
export const HelloWorld = () => {
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Hello, World!</h1>
<p>This is your first front component.</p>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: '26c17445-fbfb-4b34-99d6-f461e734ca97',
name: 'hello-world-front-component',
description: 'A sample front component',
component: HelloWorld,
});
@@ -0,0 +1,18 @@
import { defineLogicFunction } from 'twenty-sdk';
const handler = async (): Promise<{ message: string }> => {
return { message: 'Hello, World!' };
};
export default defineLogicFunction({
universalIdentifier: '4f0b7137-1399-4e50-ac00-3c3bb2555c38',
name: 'hello-world-logic-function',
description: 'A simple logic function',
timeoutSeconds: 5,
handler,
httpRouteTriggerSettings: {
path: '/hello-world-logic-function',
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -0,0 +1,13 @@
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
};
export default definePostInstallLogicFunction({
universalIdentifier: 'c1410017-8536-42aa-a188-4bfc5a1c3dae',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
@@ -0,0 +1,13 @@
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
};
export default definePreInstallLogicFunction({
universalIdentifier: '68d005d4-1110-4fa0-8227-71e06d6b9f30',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
handler,
});
@@ -0,0 +1,14 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
export default defineNavigationMenuItem({
universalIdentifier: '574a895f-1511-4b38-9d28-d6b8436738ff',
name: 'example-navigation-menu-item',
icon: 'IconList',
position: 0,
// Link to a view:
// viewUniversalIdentifier: '...',
// Or link to an object:
// targetObjectUniversalIdentifier: '...',
// Or link to an external URL:
// link: 'https://example.com',
});
@@ -0,0 +1,28 @@
import { defineObject, FieldType } from 'twenty-sdk';
export const EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER =
'b75cfe84-18ce-47da-812a-53e25ee094af';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
'6ab9c690-06ce-455e-a2c9-8067a9747f96';
export default defineObject({
universalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'exampleItem',
namePlural: 'exampleItems',
labelSingular: 'Example item',
labelPlural: 'Example items',
description: 'A sample custom object',
icon: 'IconBox',
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
description: 'Name of the example item',
icon: 'IconAbc',
},
],
});
@@ -1,89 +0,0 @@
import { defineObject, FieldType } from 'twenty-sdk';
const POST_CARD_STATUS = {
DRAFT: 'DRAFT',
SENT: 'SENT',
DELIVERED: 'DELIVERED',
RETURNED: 'RETURNED',
} as const;
export default defineObject({
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post card',
labelPlural: 'Post cards',
description: 'A post card object',
icon: 'IconMail',
fields: [
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: FieldType.TEXT,
name: 'content',
label: 'Content',
description: "Postcard's content",
icon: 'IconAbc',
},
{
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
type: FieldType.FULL_NAME,
name: 'recipientName',
label: 'Recipient name',
icon: 'IconUser',
},
{
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
type: FieldType.ADDRESS,
name: 'recipientAddress',
label: 'Recipient address',
icon: 'IconHome',
},
{
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
type: FieldType.SELECT,
name: 'status',
label: 'Status',
icon: 'IconSend',
defaultValue: `'${POST_CARD_STATUS.DRAFT}'`,
options: [
{
id: 'a1b2c3d4-0001-4000-8000-000000000001',
value: POST_CARD_STATUS.DRAFT,
label: 'Draft',
position: 0,
color: 'gray',
},
{
id: 'a1b2c3d4-0002-4000-8000-000000000002',
value: POST_CARD_STATUS.SENT,
label: 'Sent',
position: 1,
color: 'orange',
},
{
id: 'a1b2c3d4-0003-4000-8000-000000000003',
value: POST_CARD_STATUS.DELIVERED,
label: 'Delivered',
position: 2,
color: 'green',
},
{
id: 'a1b2c3d4-0004-4000-8000-000000000004',
value: POST_CARD_STATUS.RETURNED,
label: 'Returned',
position: 3,
color: 'orange',
},
],
},
{
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
type: FieldType.DATE_TIME,
name: 'deliveredAt',
label: 'Delivered at',
icon: 'IconCheck',
isNullable: true,
defaultValue: null,
},
],
});
@@ -0,0 +1,14 @@
import { defineRole } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'f14afc30-f2fa-4f70-9b12-903c5f852225';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Hello world default function role',
description: 'Hello world default function role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});
@@ -1,33 +0,0 @@
import { defineRole, PermissionFlag } from 'twenty-sdk';
export default defineRole({
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'Default function role',
description: 'Default role for function Twenty client',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [
{
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
fieldUniversalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
canReadFieldValue: false,
canUpdateFieldValue: false,
},
],
permissionFlags: [PermissionFlag.APPLICATIONS],
});
@@ -0,0 +1,13 @@
import { defineSkill } from 'twenty-sdk';
export const EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER =
'4f00dd76-c07b-4d55-a43a-7f17e7f6440a';
export default defineSkill({
universalIdentifier: EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER,
name: 'example-skill',
label: 'Example Skill',
description: 'A sample skill for your application',
icon: 'IconBrain',
content: 'Add your skill instructions here. Skills provide context and capabilities to AI agents.',
});
@@ -0,0 +1,10 @@
import { defineView } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
export default defineView({
universalIdentifier: 'e574b32c-c058-492a-8a5c-780b844a8735',
name: 'example-view',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconList',
position: 0,
});
+28 -4
View File
@@ -5,21 +5,45 @@
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strictNullChecks": true,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"lib": [
"es2020",
"dom"
],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": [
"./src/*"
],
"~/*": [
"./*"
]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.integration-test.ts"
],
"references": [
{
"path": "./tsconfig.spec.json"
}
]
}
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"node_modules",
"dist"
]
}
@@ -0,0 +1,22 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
include: ['src/**/*.integration-test.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL: 'http://localhost:3000',
TWENTY_TEST_API_KEY:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
},
},
});
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 @@
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,29 @@
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
},
},
];
@@ -0,0 +1,31 @@
{
"name": "call-recording",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "[email protected]",
"scripts": {
"twenty": "twenty",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"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",
"eslint": "^9.32.0",
"react": "^18.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.0"
}
}
@@ -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]);
@@ -0,0 +1,4 @@
import { VIDEO_EXTENSIONS } from 'src/constants/video-extensions';
export const isVideoExtension = (extension: string): boolean =>
VIDEO_EXTENSIONS.includes(extension.toLowerCase() as (typeof VIDEO_EXTENSIONS)[number]);
@@ -0,0 +1,148 @@
import { CoreApiClient } from 'twenty-sdk/generated';
export interface Participant {
id: string;
name: string;
isHost: boolean;
platform: string;
}
const parseName = (
fullName: string,
): { firstName: string; lastName: string } => {
const parts = fullName.trim().split(/\s+/);
const firstName = parts[0] ?? '';
const lastName = parts.slice(1).join(' ');
return { firstName, lastName };
};
const findWorkspaceMember = async (
client: InstanceType<typeof CoreApiClient>,
firstName: string,
lastName: string,
): Promise<string | null> => {
const result: any = await client.query({
workspaceMembers: {
__args: {
filter: {
name: {
firstName: { ilike: `%${firstName}%` },
lastName: { ilike: `%${lastName}%` },
},
},
},
edges: {
node: {
id: true,
name: { firstName: true, lastName: true },
},
},
},
} as any);
const firstMatch = result.workspaceMembers?.edges?.[0]?.node;
return firstMatch?.id ?? null;
};
const findPerson = async (
client: InstanceType<typeof CoreApiClient>,
firstName: string,
lastName: string,
): Promise<string | null> => {
const result: any = await client.query({
people: {
__args: {
filter: {
name: {
firstName: { ilike: `%${firstName}%` },
lastName: { ilike: `%${lastName}%` },
},
},
},
edges: {
node: {
id: true,
name: { firstName: true, lastName: true },
},
},
},
} as any);
const firstMatch = result.people?.edges?.[0]?.node;
return firstMatch?.id ?? null;
};
export const matchParticipants = async (
callRecordingId: string,
participants: Participant[],
): Promise<void> => {
if (!participants.length) {
console.log('No participants to match, skipping');
return;
}
const client = new CoreApiClient();
for (const participant of participants) {
const { firstName, lastName } = parseName(participant.name);
if (!firstName) {
console.log(
`Skipping participant with empty name: ${participant.id}`,
);
continue;
}
const workspaceMemberId = await findWorkspaceMember(
client,
firstName,
lastName,
);
if (workspaceMemberId) {
console.log(
`Matched "${participant.name}" to workspace member ${workspaceMemberId}`,
);
await client.mutation({
updateCallRecording: {
__args: {
id: callRecordingId,
data: { workspaceMemberId },
},
id: true,
},
} as any);
continue;
}
const personId = await findPerson(client, firstName, lastName);
if (personId) {
console.log(
`Matched "${participant.name}" to person ${personId}`,
);
await client.mutation({
updateCallRecording: {
__args: {
id: callRecordingId,
data: { personId },
},
id: true,
},
} as any);
continue;
}
console.log(
`No match found for participant "${participant.name}"`,
);
}
};
@@ -0,0 +1,73 @@
const SUMMARIZATION_SYSTEM_PROMPT = [
'You are a helpful assistant that summarizes call transcripts.',
'Provide a concise summary with:',
'1) A brief overview of the call',
'2) Key discussion points',
'3) Action items (if any)',
'Use markdown formatting.',
].join(' ');
export const summarizeTranscript = async (
transcriptMarkdown: string,
): Promise<string | undefined> => {
const apiBaseUrl = process.env.TWENTY_API_URL;
const token =
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
console.log(
'[summarizeTranscript] Starting summarization',
JSON.stringify({
hasApiBaseUrl: !!apiBaseUrl,
hasToken: !!token,
transcriptLength: transcriptMarkdown.length,
}),
);
if (!apiBaseUrl || !token) {
console.log(
'[summarizeTranscript] Skipping: missing apiBaseUrl or token',
);
return undefined;
}
const url = `${apiBaseUrl}/rest/ai/generate-text`;
console.log('[summarizeTranscript] Calling', url);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
userPrompt: `Summarize this call transcript:\n\n${transcriptMarkdown}`,
}),
});
console.log(
'[summarizeTranscript] Response status:',
response.status,
response.statusText,
);
if (!response.ok) {
const errorBody = await response.text();
console.error('[summarizeTranscript] Error response body:', errorBody);
throw new Error(
`AI summarization request failed with status ${response.status}: ${errorBody}`,
);
}
const data = (await response.json()) as { text?: string };
console.log(
'[summarizeTranscript] Result text length:',
data.text?.length ?? 0,
);
return data.text ?? undefined;
};
@@ -0,0 +1,87 @@
import { WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID } from 'src/fields/workspace-members-on-call-recording.field';
import { PEOPLE_ON_CALL_RECORDING_ID } from 'src/fields/people-on-call-recording.field';
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER, CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER, NAME_FIELD_UNIVERSAL_IDENTIFIER, RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER, STATUS_FIELD_UNIVERSAL_IDENTIFIER, SUMMARY_FIELD_UNIVERSAL_IDENTIFIER, TRANSCRIPT_FIELD_UNIVERSAL_IDENTIFIER, TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
import { defineView } from 'twenty-sdk';
export const CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER =
'9c9c09bb-de9f-4248-89f2-e7d91f29c3ed';
export default defineView({
universalIdentifier: CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Call recordings',
objectUniversalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconPhone',
position: 0,
fields: [
{
universalIdentifier: 'b5609679-8451-45ec-ad52-5a4e3720af45',
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 0,
},
{
universalIdentifier: 'd791bea6-c49e-4d6f-8864-737ed00276f8',
fieldMetadataUniversalIdentifier: CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 1,
},
{
universalIdentifier: 'db96d9cf-cbd8-407b-b748-7b12913f018b',
fieldMetadataUniversalIdentifier: ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 2,
},
{
universalIdentifier: '17c4e68a-5b62-4509-b8ed-19f82dfb8e2f',
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 3,
},
{
universalIdentifier: 'a7bce6c7-39ce-406a-bc6f-00b495951b4a',
fieldMetadataUniversalIdentifier: RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 4,
},
{
universalIdentifier: 'f3e2d1c0-a9b8-47c6-85d4-3e2f1a0b9c8d',
fieldMetadataUniversalIdentifier: TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 5,
},
{
universalIdentifier: 'e4c3b2a1-0d9e-48f7-a6b5-1c2d3e4f5a6b',
fieldMetadataUniversalIdentifier: TRANSCRIPT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 6,
},
{
universalIdentifier: '56925631-0077-4b65-a25a-24afec4d8bff',
fieldMetadataUniversalIdentifier: WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID,
isVisible: true,
size: 12,
position: 7,
},
{
universalIdentifier: '1ba8538d-9c8e-47bf-b485-a8ba07b3d9a3',
fieldMetadataUniversalIdentifier: PEOPLE_ON_CALL_RECORDING_ID,
isVisible: true,
size: 12,
position: 8,
},
{
universalIdentifier: '58124ded-56ff-40a0-8858-eae1b5ae75d7',
fieldMetadataUniversalIdentifier: SUMMARY_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 6,
},
],
});
@@ -0,0 +1,32 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"jsxImportSource": "@emotion/react",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
RECALLAI_API_URL=https://us-west-2.recall.ai
RECALLAI_API_KEY=recall_api_key
# Twenty CRM integration (optional)
# Generate an API key at <your-twenty-instance>/settings/api-webhooks
TWENTY_API_URL=http://localhost:3000
# Workspace subdomain, required for logic function routes when multiworkspace is enabled
TWENTY_WORKSPACE_SUBDOMAIN=
TWENTY_API_KEY=replace_me_with_your_twenty_api_key
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

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