Compare commits

..
Author SHA1 Message Date
Charles BochetandGitHub 4fa2c400c0 fix(server): skip standard page layout widgets referencing missing field metadatas during 1.23 backfill (#19825)
## Summary

The 1.23 backfill command (`upgrade:1-23:backfill-record-page-layouts`)
creates standard page layout widgets from `STANDARD_PAGE_LAYOUTS`. Some
widgets reference field metadatas via `universalConfiguration` (e.g. the
`opportunity.owner` FIELD widget pointing at universal identifier
`20202020-be7e-4d1e-8e19-3d5c7c4b9f2a`).

If a workspace's matching field metadata does not exist or has a
different universal identifier (e.g. older workspaces created before
standard universal identifiers were backfilled), the runner throws

```
Field metadata not found for universal identifier: 20202020-be7e-4d1e-8e19-3d5c7c4b9f2a
```

and the entire migration for that workspace aborts. This was the
underlying cause behind the `Migration action 'create' for
'pageLayoutWidget' failed` error surfaced by #19823.
2026-04-17 23:59:13 +02:00
Charles BochetandGitHub e878d646ed chore(server): bump logic-function executor lambda memory to 512MB (#19826)
## Summary

The executor lambda for user logic functions is created in
`LambdaDriver` without a `MemorySize` parameter, so AWS Lambda falls
back to its 128 MB default. That cap is too tight for non-trivial logic
functions — large upstream GraphQL responses, JSON parsing of paginated
batches, and chained Twenty Core API mutations push the process over the
limit and trigger an OOM SIGKILL surfaced to the user as:

```
Runtime exited with error: signal: killed
```

This bumps the executor lambda memory to **512 MB** (matching the
existing `BUILDER_LAMBDA_MEMORY_MB`). The change is applied on both:

- The `CreateFunctionCommand` path used when a logic function is first
deployed.
- The `UpdateFunctionConfigurationCommand` path used when the deps/SDK
layer wiring is refreshed — so existing functions get reconfigured on
next deploy without any additional manual action.

## Why 512 MB

Lambda compute is allocated proportionally to memory. 512 MB:
- Matches the builder lambda already in this file
(`BUILDER_LAMBDA_MEMORY_MB`).
- Is comfortably above the 128 MB default that the existing OOMs are
hitting.
- Stays well below the higher tiers, keeping the per-invocation cost
increase modest.


Made with [Cursor](https://cursor.com)
2026-04-17 23:59:05 +02:00
Charles BochetandGitHub fb5a1988b1 fix(server): log inner errors of WorkspaceMigrationRunnerException in workspace iterator (#19823)
## Summary

When a workspace migration action fails during workspace iteration (e.g.
during upgrade commands), only the wrapper message was logged:

```
[WorkspaceIteratorService] Error in workspace 7914ba64-...: Migration action 'create' for 'pageLayoutWidget' failed
```

The underlying error (transpilation/metadata/workspace schema) and its
stack were swallowed, making production debugging painful.

This PR adds a follow-up log entry for each inner error attached to a
`WorkspaceMigrationRunnerException`, including its message and stack
trace. The runner exception itself is untouched — it already exposes
structured `errors` (`actionTranspilation`, `metadata`,
`workspaceSchema`).

After this change, logs look like:

```
[WorkspaceIteratorService] Error in workspace 7914ba64-...: Migration action 'create' for 'pageLayoutWidget' failed
[WorkspaceIteratorService] Caused by actionTranspilation in workspace 7914ba64-...: <real reason>
    at ...
```

## Test plan

- [ ] Trigger a failing workspace migration (e.g. backfill record page
layouts) on a workspace and confirm the underlying cause + stack now
appear in logs.

Made with [Cursor](https://cursor.com)
2026-04-17 22:31:35 +02:00
Charles BochetandGitHub 3eeaebb0cc fix(server): make workspace:seed:dev --light actually seed only one workspace (#19822)
## Summary

The `--light` flag of `workspace:seed:dev` was supposed to seed a single
workspace for thin dev containers, but it was only filtering the rich
workspaces (Apple, YCombinator) — the `Empty3`/`Empty4` fixtures
introduced in #19559 for upgrade-sequence integration tests were always
seeded.

So `--light` actually produced **3** workspaces:
- Apple
- Empty3
- Empty4

In single-workspace mode (`IS_MULTIWORKSPACE_ENABLED=false`, the default
for the `twenty-app-dev` container),
[`WorkspaceDomainsService.getDefaultWorkspace`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts)
returns the most recently created workspace — Empty4 — which has no
users. The prefilled `tim@apple.dev` therefore cannot sign in, which
breaks flows that depend on the default workspace such as `yarn twenty
remote add --local`'s OAuth handshake against the dev container.

This PR makes `--light` actually skip the empty fixtures so the dev
container ends up with a single workspace (Apple). The default (no flag)
invocation, used by `database:reset` for integration tests, still seeds
all four workspaces, so
`upgrade-sequence-runner-integration-test.util.ts` keeps working
unchanged.
2026-04-17 22:08:01 +02:00
59e4ed715a fix(server): normalize empty composite phone sub-fields to NULL (#19775)
Fixed using Opus 4.7, I wanted to test this model out and in this repo I
know you guys care about quality, pls let me know if this is good code.
It looks good to me

Fixes #19740.

## Summary

PostgreSQL UNIQUE indexes treat two `''` values as duplicates but two
`NULL`s as distinct. `validateAndInferPhoneInput` was persisting blank
`primaryPhoneNumber` as `''` instead of `NULL`, so a second record with
an empty unique phone failed with a constraint violation. The sibling
composite transforms (`transformEmailsValue`, `removeEmptyLinks`,
`transformTextField`) already canonicalize null-equivalent values;
phones was the outlier.

- Empty-string phone sub-fields now normalize to `null`. `undefined` is
preserved so partial updates leave columns the user did not touch alone.
- `PhonesFieldGraphQLInput` drops the aspirational `CountryCode` brand
on input. GraphQL delivers raw strings at the boundary; branding happens
during validation.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-17 19:36:43 +00:00
ab85946102 i18n - translations (#19821)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-17 21:37:54 +02:00
martmullandGitHub 38a03abc06 Fix app design 5 (#19820)
small fixes
2026-04-17 19:22:17 +00:00
WeikoandGitHub 13b32a22b6 Add page layout tab icon picker (#19818)
Adds the ability to change the icon of a record page layout tab from the
side panel in tab edit mode, and sets a default icon for newly-created
record page tabs (no default for dashboards).

<img width="916" height="312" alt="Screenshot 2026-04-17 at 19 55 51"
src="https://github.com/user-attachments/assets/d9f57e89-d40d-483e-b508-5d7318df1ef5"
/>
2026-04-17 18:19:46 +00:00
4a5702328a i18n - translations (#19819)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-17 20:14:22 +02:00
neo773andGitHub 9307c718cf Add twenty-managed Docker target with AWS CLI for EKS deployments (#19816)
Separate build target so self-hosters have slimmer image but managed
infra gets aws cli for automation
2026-04-17 17:54:10 +00:00
WeikoandGitHub b320de966c Add reset page layout in record page layout edit mode tab (#19800)
## Context
Adds a burger-menu dropdown on the layout customization bar exposing a
"Reset record page layout" action, so users can reset a record page
layout straight from the edit bar (previously only available in object
settings).

<img width="1512" height="849" alt="Screenshot 2026-04-17 at 18 03 19"
src="https://github.com/user-attachments/assets/145a77b8-6234-4987-ae31-38eccaa0548d"
/>
2026-04-17 17:46:42 +00:00
b8f8892b67 i18n - translations (#19817)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-17 19:49:00 +02:00
WeikoandGitHub df9c4e26b5 Disable reset to default when custom tab or widget (#19814)
## Context
"Reset to default" action is rejected by the backend for custom entities
because there is no "default" concept for them

## Implementation
Grey out the Reset to default action on record page-layout tabs and
widgets when the entity either has no applicationId yet (unsaved draft —
previously slipped through the existing check), or belongs to the
workspace custom application.

<img width="926" height="370" alt="Screenshot 2026-04-17 at 18 50 31"
src="https://github.com/user-attachments/assets/c7c163f4-17a6-4b69-a66d-90f9085d27a2"
/>
2026-04-17 17:30:57 +00:00
Raphaël BosiandGitHub 619ea13649 Twenty for twenty app (#19804)
## Twenty for Twenty: Resend module

Introduces `packages/twenty-apps/internal/twenty-for-twenty`, the
official internal Twenty app, with a first module integrating
[Resend](https://resend.com).

### Breakdown

**Resend module** (`src/modules/resend/`)
- Two app variables: `RESEND_API_KEY` and `RESEND_WEBHOOK_SECRET`.
- **Objects**: `resendContact`, `resendSegment`, `resendTemplate`,
`resendBroadcast`, `resendEmail`, with relations between them and to
standard `person`.
- **Inbound sync (Resend → Twenty)**:
- Cron-driven logic function `sync-resend-data` (every 5 min) pulling
all entities through paginated, rate-limit-aware utilities
(`sync-contacts`, `sync-segments`, `sync-templates`, `sync-broadcasts`,
`sync-emails`).
- Webhook endpoint (`resend-webhook`) verifying signatures and handling
`contact.*` and `email.*` events in real time.
- `find-or-create-person` auto-links Resend contacts to Twenty people by
email.
- **Outbound sync (Twenty → Resend)**: DB-event logic functions for
`contact.created/updated/deleted` and `segment.created/deleted`, with a
`lastSyncedFromResend` field for loop prevention.
- **UI**: views, page layouts, navigation menu items, and front
components (`HtmlPreview`, `RecordHtmlViewer`) to preview email/template
HTML in record pages; `sync-resend-data` command exposed as a front
component.

### Setup

See the new README for install steps, webhook configuration, and local
testing with the Resend CLI.
2026-04-17 17:29:09 +00:00
Abdullah.andGitHub ce2a0bfbe5 fix: socket.io allows an unbounded number of binary attachments (#19812)
Resolves [Dependabot Alert
683](https://github.com/twentyhq/twenty/security/dependabot/683).
2026-04-17 17:17:56 +00:00
WeikoandGitHub a18840f3cd Fix deactivated tabs not visible in new tab action (#19811)
## Context
On custom objects, clicking "+ New Tab" on a record page layout never
exposed deactivated tabs for reactivation, even though isActive: false
tabs were correctly returned by the API. Standard objects worked fine.

## Fix
isReactivatableTab gated reactivation on tab.applicationId ===
objectMetadata.applicationId. For custom objects these two ids are
intentionally different.
This check was unnecessary after all, we simply want to check if a tab
is inactive (only non-custom entities can be de-activated) 👍

<img width="694" height="551" alt="Screenshot 2026-04-17 at 18 14 28"
src="https://github.com/user-attachments/assets/42485cb2-8be5-4a55-a311-479ed3226908"
/>
2026-04-17 17:10:14 +00:00
Thomas des FrancsandGitHub 6095798434 Add SVG export and refine halftone studio controls (#19813)
## Summary
- Added image-mode SVG export and clipboard copy support for the
halftone generator.
- Reworked the export panel UX into separate `Download` and `Copy`
sections with format-only buttons.
- Simplified the SVG output to reduce redundant segments while
preserving the rendered result.
- Updated related halftone canvas, state, exporter, and illustration
code to support the new flow.

## Testing
- `yarn nx typecheck twenty-website-new`
- `yarn nx build twenty-website-new`
2026-04-17 16:53:38 +00:00
WeikoandGitHub 0cb50f8a9d Fix indexFieldMetadata select missing workspaceId (#19806) 2026-04-17 17:34:27 +02:00
47a742fd01 i18n - translations (#19805)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-17 17:09:25 +02:00
martmullGitHubCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
120ced44a9 Fix app design 4 (#19803)
## Before

<img width="1512" height="584" alt="image"
src="https://github.com/user-attachments/assets/2a05d0c7-4bba-438f-9b05-4abd159530ba"
/>
<img width="1512" height="908" alt="image"
src="https://github.com/user-attachments/assets/a36da096-505d-4f25-84bc-a0feca436d53"
/>


## After

<img width="1503" height="574" alt="image"
src="https://github.com/user-attachments/assets/e039b92f-057a-4ed7-869a-a248f446eb2b"
/>
<img width="1512" height="904" alt="image"
src="https://github.com/user-attachments/assets/065767b5-8a70-4ea7-a520-5b2ccbdcffa3"
/>

---------

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-17 14:52:41 +00:00
WeikoandGitHub 3268a86f4b Skip backfill record page layouts for missing standard objects (#19799) 2026-04-17 13:09:11 +00:00
neo773andGitHub 68746e22a0 Send Email Tool: Don't persist message on SMTP only connections (#19756)
Previously this blocked users who only had SMTP configured to send
outbound emails, this fixes it by making messageChannel and persist
layer conditional
2026-04-17 12:56:38 +00:00
Charles BochetandGitHub 4ed6fcd19e chore: move TABLE_WIDGET view type migration to 1.23 fast instance command (#19797)
## Summary

- Relocates `AddTableWidgetViewTypeFastInstanceCommand` from `1-22/` to
`1-23/` and bumps its `@RegisteredInstanceCommand` version from `1.22.0`
to `1.23.0`. The original timestamp `1775752190522` is preserved so the
command slots chronologically into the existing 1.23 sequence;
auto-discovered via `@RegisteredInstanceCommand`, no module wiring
change needed.
- Same pattern as #19792 (move
`pageLayoutWidget.conditionalAvailabilityExpression` to 1.23).
2026-04-17 12:44:35 +00:00
WeikoandGitHub e70269b9d3 Fix: aggregate Calculate not updating in dashboard Table widgets (#19796)
## Context
Picking an aggregate option (Count, Sum, Percentage Not Empty, etc.) in
a dashboard Table widget footer did nothing visually — the value never
appeared or updated

## Fix
RecordTableWidget was missing RecordIndexTableContainerEffect, which
reactively syncs currentView.viewFields[].aggregateOperation from the
Apollo cache into the viewFieldAggregateOperationState jotai atom that
the footer reads

<img width="612" height="261" alt="Screenshot 2026-04-17 at 14 17 47"
src="https://github.com/user-attachments/assets/b4409b0e-82a6-4614-bc09-653be738134a"
/>
2026-04-17 12:34:11 +00:00
f94ee2d495 i18n - translations (#19795)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-17 14:15:46 +02:00
bb464b2ffb Forbid other app role extension (#19783)
# Introduction
Even though this would not possible through API at the moment, from
neither API metadata or manifest ( as manifest `permissionsFlag`
declarations etc are done from within a declared role )
Prevent any app to create permissions entities over another app role
from the validation engine itself

## `isEditable`
We might wanna deprecate this column at some point from the entity it
self as now the grain would rather be `what app owns that role ?`

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-17 12:00:37 +00:00
Abdul RahmanandGitHub a93f23a150 Fix AI chat dropzone persisting when dragging file out without dropping (#19794)
Issue link:
https://discord.com/channels/1130383047699738754/1494248499351519232

### Before


https://github.com/user-attachments/assets/ec4ba8cc-b9e7-4b77-8b8f-b254e11edb19



### After



https://github.com/user-attachments/assets/2905536b-6a31-41e8-9f25-8768fd224b6a
2026-04-17 11:43:02 +00:00
Charles BochetandGitHub beeb8b7406 chore: move pageLayoutWidget.conditionalAvailabilityExpression migration to 1.23 fast instance command (#19792)
## Summary

- Replaces the standalone TypeORM migration
`1775654781000-addConditionalAvailabilityExpressionToPageLayoutWidget.ts`
with a registered fast instance command under
`packages/twenty-server/src/database/commands/upgrade-version-command/1-23/`,
so the `pageLayoutWidget.conditionalAvailabilityExpression` column is
created through the unified upgrade pipeline.
- Uses `ADD COLUMN IF NOT EXISTS` / `DROP COLUMN IF EXISTS` so the new
instance command is a safe no-op for environments that already applied
the previous TypeORM migration.
- Keeps the original timestamp `1775654781000` so the command slots
chronologically into the existing 1.23 sequence; auto-discovered via
`@RegisteredInstanceCommand`, no module wiring needed.

## Context

Reported error when creating a new workspace on `main`:

> column PageLayoutWidgetEntity.conditionalAvailabilityExpression does
not exist

Aligns this column addition with the rest of the 1.23 schema changes
that already use the instance-command pattern.
2026-04-17 11:40:05 +00:00
WeikoandGitHub 5cd8b7899d shouldIncludeRecordPageLayouts deprecation (#19774)
## Context
Deprecating shouldIncludeRecordPageLayouts in preparation for page
layout release.

See new workspace with standard page layout from standard app
<img width="570" height="682" alt="Screenshot 2026-04-16 at 18 35 23"
src="https://github.com/user-attachments/assets/bf7fa621-d40d-4c29-8d96-537c58b3eb40"
/>
2026-04-17 11:32:10 +00:00
76ea0f37ed Surface structured validation errors during application install (#19787)
## Summary
- Add `WorkspaceMigrationGraphqlApiExceptionInterceptor` to
`MarketplaceResolver` and `ApplicationInstallResolver` so validation
failures during app install return `METADATA_VALIDATION_FAILED` with
structured `extensions.errors` instead of generic
`INTERNAL_SERVER_ERROR`
- Update SDK `installTarballApp()` to pass the full GraphQL error object
(including extensions) through the install flow
- Add `formatInstallValidationErrors` utility to format structured
validation errors for CLI output
- Add integration test verifying structured error responses for invalid
navigation menu items and view fields

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:29:33 +00:00
Weiko 7aa60fd20b Revert "Fix"
This reverts commit 70603a1af6.
2026-04-17 13:15:57 +02:00
Weiko 70603a1af6 Fix 2026-04-17 13:15:04 +02:00
MarieandGitHub b31f84fbb8 fix(server): workspace member permissions and profile onboarding (#19786)
## Summary

Aligns **workspace member** editing and **onboarding** with how the
product is actually used: profile and other “settings” fields go through
**`updateWorkspaceMemberSettings`**, while **`/graphql`** record APIs
follow **object-level** permissions for the `workspaceMember` object.

## Product behaviour

### Completing “Create profile” onboarding

Users who must create a profile (empty name at sign-up) get
`ONBOARDING_CREATE_PROFILE_PENDING` set. The onboarding UI saves the
name with **`updateWorkspaceMemberSettings`**, not with a workspace
record **`updateOne`**.

**Before:** The server only cleared the pending flag on
**`workspaceMember.updateOne`**, so the flag could stay set and
onboarding appeared stuck.

**After:** Clearing the profile step runs when
**`updateWorkspaceMemberSettings`** persists an update that includes a
**name** (same rules as before: non-empty name parts). Onboarding can
advance normally after **Continue** on Create profile.

### Two ways to change workspace member data

| Path | Typical use | Who can change what |
|------|----------------|---------------------|
| **`updateWorkspaceMemberSettings`** (metadata API) | Standard member
fields the app treats as “my profile / preferences” (name,
avatar-related settings, locale, time zone, etc.) | **Always** your
**own** workspace member. Changing **another** member still requires
**Workspace members** in role settings (`WORKSPACE_MEMBERS`). Custom
fields are **not** allowed on this endpoint (unchanged). |
| **`/graphql`** record mutations on **`workspaceMember`** | Custom
fields, integrations, anything that goes through the generic record API
| **`WorkspaceMember`** is special-cased in permissions: **read** stays
**on** for everyone, but **update / create / delete** require
**`WORKSPACE_MEMBERS`**, including updating **your own** row via
`/graphql`. So a **Member** without that permission cannot fix their
name through **`updateWorkspaceMember`**; they use **Settings** /
**`updateWorkspaceMemberSettings`** instead. |

This matches **`WorkspaceRolesPermissionsCacheService`**: for the
workspace member object, `canReadObjectRecords` is always true;
`canUpdateObjectRecords` (and delete-related flags) follow
**`WORKSPACE_MEMBERS`**.

### Hooks and delete side-effects

- Removed **`workspaceMember.updateOne`** pre-query hook and
**`WorkspaceMemberPreQueryHookService`**: they duplicated the same rules
the permission cache already enforces for `/graphql`.
- **`WorkspaceMember.deleteOne`** pre-hook still tells users to remove
members via the dedicated flow; the post-hook only runs the
**`deleteUserWorkspace`** side-effect when a member row is actually
removed—**no** extra settings-permission check there, since only callers
that already passed **object** delete permission can remove the row.

## Tests

- **`workspace-members.integration-spec.ts`**: clarifies and extends
coverage so **`/graphql`** **`updateOne`** is denied for **own** record
on a **standard** name field and on a **custom** field when the role
lacks **`WORKSPACE_MEMBERS`**.

## Implementation notes

- **`OnboardingService.completeOnboardingProfileStepIfNameProvided`**
centralises the “clear profile pending if name present” logic;
**`UserResolver.updateWorkspaceMemberSettings`** calls it after save,
using the typed update payload’s **`name`** (no cast).
- **`UserWorkspaceService.updateUserWorkspaceLocaleForUserWorkspace`**:
drops a redundant **`coreEntityCacheService.invalidate`**;
**`updateWorkspaceMemberSettings`** still invalidates the user-workspace
cache after the mutation.
2026-04-17 09:58:34 +00:00
ba1195d92e i18n - translations (#19784)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-17 09:34:28 +02:00
Abdul RahmanandGitHub fcba0ca30a Add search to add column dropdown (#19763)
https://github.com/user-attachments/assets/4a64fff0-6495-4651-934b-43f4ad0dc966
2026-04-17 07:19:07 +00:00
d7453303b8 chore: sync AI model catalog from models.dev (#19782)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

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

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

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-04-17 08:32:09 +02:00
Paul RastoinandGitHub 75235f4621 Validate universalIdentifier uniqueness among application and its dependencies (#19767)
# Introduction
Gracefully validating that when creating an entity its
`universalIdentifier` is available within the all application metadata
maps context ( current app + twenty standard, currently the only managed
dependencies )
2026-04-16 16:59:12 +00:00
Paul RastoinandGitHub bf410ae438 upgrade:status command (#19584)
## Introduction
Introducing a new command in order to determine the curent twenty
instance and workspaces status as it's not stored in database but a
derivation of each current curors


## `upgrade:status` all healthy
<img width="1376" height="1202" alt="image"
src="https://github.com/user-attachments/assets/e90d6987-07d2-4b6b-b573-105249aca325"
/>

## `upgrade:status` Nearly use cases
<img width="1442" height="1304" alt="image"
src="https://github.com/user-attachments/assets/c336cb9d-eb9d-4c7d-9392-ec1ef54a7326"
/>

## `upgrade:status --failed-only`
<img width="1442" height="940" alt="image"
src="https://github.com/user-attachments/assets/93a3dfdb-0d2f-4a01-b185-118e5cf0a078"
/>

## `upgrade:status -w aa8fdcb1-8ee1-4012-98af-44a97caa7411 -w
20202020-1c25-4d02-bf25-6aeccf7ea419 -w
20202020-1c25-4d02-bf25-6aeccf7ea412`
<img width="1486" height="928" alt="image"
src="https://github.com/user-attachments/assets/ec1b1abc-46e8-4e36-9799-ab3a4b85e410"
/>
2026-04-16 16:05:02 +00:00
EtienneandGitHub aecbc89a3f Fix slow db query issue (#19770)
https://github.com/twentyhq/twenty/pull/19586#discussion_r3074136617
2026-04-16 15:37:58 +00:00
Thomas TrompetteandGitHub 446a3923f2 Add workspace id in job logs (#19764)
Cannot currently investigate spikes
2026-04-16 15:28:37 +00:00
4f4f723ed0 Fix MCP discovery: path-aware well-known URL and protocol version (#19766)
## Summary

Adding `https://api.twenty.com/mcp` as an MCP server in Claude fails
with `Couldn't reach the MCP server` before OAuth can start. Two
independent bugs cause this:

1. **Missing path-aware well-known route.** The latest MCP spec
instructs clients to probe `/.well-known/oauth-protected-resource/mcp`
before `/.well-known/oauth-protected-resource`. Only the root path was
registered, so the path-aware request fell through to
`ServeStaticModule` and returned the SPA's `index.html` with HTTP 200.
Strict clients (Claude.ai) tried to parse it as JSON and gave up. Fixed
by registering both paths on the same handler.
2. **Stale protocol version.** Server advertised `2024-11-05`, which
predates Streamable HTTP. We've implemented Streamable HTTP (SSE
response format was added in #19528), so bumped to `2025-06-18`.

Reproduction before the fix:

```
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" https://api.twenty.com/.well-known/oauth-protected-resource/mcp
200 text/html; charset=UTF-8
```

After the fix this returns `application/json` with the RFC 9728 metadata
document.

Note: this is separate from #19755 (host-aware resource URL for
multi-host deployments).

## Test plan

- [x] `npx jest oauth-discovery.controller` — 2/2 tests pass, including
one asserting both routes are registered
- [x] `npx nx lint:diff-with-main twenty-server` passes
- [ ] After deploy, `curl
https://api.twenty.com/.well-known/oauth-protected-resource/mcp` returns
JSON (not HTML)
- [ ] Adding `https://api.twenty.com/mcp` in Claude reaches the OAuth
authorization screen

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:32:45 +02:00
Charles BochetandGitHub 4103efcb84 fix: replace slow deep-equal with fastDeepEqual to resolve CPU bottleneck (#19771)
## Summary

- Replaced the `deep-equal` npm package with the existing
`fastDeepEqual` from `twenty-shared/utils` across 5 files in the server
and shared packages
- `deep-equal` was causing severe CPU overhead in the record update hot
path (`executeMany` → `formatTwentyOrmEventToDatabaseBatchEvent` →
`objectRecordChangedValues` → `deepEqual`, called **per field per
record**)
- `fastDeepEqual` is ~100x faster for plain JSON database records since
it skips unnecessary prototype chain inspection and edge-case handling
- Removed the now-unnecessary `LARGE_JSON_FIELDS` branching in
`objectRecordChangedValues` since all fields now use the fast
implementation
2026-04-16 17:23:50 +02:00
d3df58046c chore(server): drop api-host branch in OAuth discovery (#19768)
## Summary

Follow-up to #19755. Simplifies `OAuthDiscoveryController` by dropping
the `authorization_endpoint → frontend base URL` branch that was there
to make `api.twenty.com/mcp` paste-able in MCP clients.

We've decided not to support pasting `api.twenty.com/mcp` — users can
paste `app.twenty.com/mcp`, `<workspace>.twenty.com/mcp`, or a custom
domain, all of which serve both frontend and API. On those hosts,
`authorization_endpoint` was already pointed at the same host as
`issuer`, which is what we want.

## Change

- Remove `isApiHost` helper and the `authorizeBase` branch — use
`issuer` for `authorization_endpoint`.
- Drop now-unused `TwentyConfigService` and `DomainServerConfigService`
injections.
- Drop duplicate `DomainServerConfigModule` import from
`application-oauth.module.ts` (the module is no longer needed).

Net diff: +1 / -22 across 2 files.

## Breaking change

MCP clients configured with `https://api.twenty.com/mcp` will stop
working. They should be reconfigured with the host matching the
workspace they're connecting to (`<workspace>.twenty.com/mcp`,
`app.twenty.com/mcp`, or a custom domain).

## Test plan

- [x] `yarn jest --testPathPatterns="mcp-auth.guard"` → 2/2 passing
(unchanged)
- [x] `tsc --noEmit` clean on modified files
- [ ] Manual verification on staging: `app.twenty.com/mcp` and
`<workspace>.twenty.com/mcp` OAuth flow still works end-to-end

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

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:01:55 +02:00
cb6953abe3 fix(server): make OAuth discovery and MCP auth metadata host-aware (#19755)
## Summary

OAuth discovery metadata (RFC 9728 protected-resource, RFC 8414
authorization-server) and the MCP `WWW-Authenticate` header were
hardcoded to `SERVER_URL`. This breaks MCP clients that paste any URL
other than `api.twenty.com/mcp` — the metadata declares `resource:
https://api.twenty.com/mcp`, which doesn't match the URL the client
connected to, so the client rejects it and the OAuth flow never starts.

Reproduced with Claude's MCP integration: pasting
`<workspace>.twenty.com/mcp`, `app.twenty.com/mcp`, or a custom domain
returned *"Couldn't reach the MCP server"* because discovery returned a
resource URL for a different host.

Related memory: MCP clients POST to the URL the user entered, not the
discovered resource URL — so every paste-able hostname has to advertise
`resource` for that same hostname.

## What the server now does

`WorkspaceDomainsService.getValidatedRequestBaseUrl(req)` resolves the
canonical base URL for the host the request came in on, validated
against the set of hosts we actually serve:

- `SERVER_URL` (e.g. `api.twenty.com`) — API host
- default base URL (e.g. `app.twenty.com`) — the `DEFAULT_SUBDOMAIN`
base
- `FRONTEND_URL` bare host
- any `<workspace>.twenty.com` subdomain (DB lookup)
- any workspace `customDomain` where `isCustomDomainEnabled = true`
- any registered `publicDomain`

An unrecognized / spoofed Host falls back to
`DomainServerConfigService.getBaseUrl()`. **We never reflect arbitrary
Host values into the response.**

Callers updated:

- `OAuthDiscoveryController.getProtectedResourceMetadata` — echoes the
validated host into `resource` and `authorization_servers`.
- `OAuthDiscoveryController.getAuthorizationServerMetadata` — uses the
validated host for `issuer` and `*_endpoint`, **except**
`authorization_endpoint`: when the request came in via `SERVER_URL`
(API-only, no `/authorize` route), we keep that one pointed at the
default frontend base URL.
- `McpAuthGuard` — sets `WWW-Authenticate: Bearer
resource_metadata=\"<validatedBase>/.well-known/oauth-protected-resource\"`
on 401s, so the MCP client's follow-up discovery fetch lands on the same
host it started on.

## Security

- Workspace identity is already bound to the JWT via per-workspace
signing secrets (`jwtWrapperService.generateAppSecret(tokenType,
workspaceId)`). Host-aware discovery does not weaken that.
- Custom domains are only accepted once `isCustomDomainEnabled = true`
(i.e. after DNS verification), so an attacker can't register a
custom-domain mapping on a workspace and have discovery reflect it
before it's been proven.
- Unknown / spoofed Hosts fall through to the default base URL.

## Drive-by

Fixed a duplicate `DomainServerConfigModule` import in
`application-oauth.module.ts` while adding `WorkspaceDomainsModule`.

## Companion infra change required for custom domains

Customer custom domains (`crm.acme.com/mcp`) also require an
ingress-level fix to exclude `/mcp`, `/oauth`, and `/.well-known` from
the `/s\$uri` rewrite applied when `X-Twenty-Public-Domain: true`.
Shipping that in a twenty-infra PR (will cross-link here).

## Test plan

- [x] 14 new tests in
`WorkspaceDomainsService.getValidatedRequestBaseUrl` covering: missing
Host, SERVER_URL, base URL, FRONTEND_URL, workspace subdomain, unknown
subdomain fallback, enabled custom domain, disabled custom domain,
public domain, completely unrecognized host, lowercase coercion,
malformed Host, single-workspace mode fallback, DB throwing → fallback
- [x] New `oauth-discovery.controller.spec.ts` covering both endpoints
across api / app / workspace-subdomain / custom-domain hosts, plus
`cli_client_id` propagation
- [x] Rewrote `mcp-auth.guard.spec.ts` to cover `WWW-Authenticate` for
all four host types (api, workspace subdomain, custom domain, spoofed
fallback)
- [x] `yarn jest
--testPathPatterns=\"workspace-domains.service|oauth-discovery.controller|mcp-auth.guard\"`
→ 41/41 passing
- [x] `tsc --noEmit` clean on all modified files
- [ ] Manual verification against staging: connect Claude to
`api.twenty.com/mcp`, `app.twenty.com/mcp`,
`<workspace>.twenty.com/mcp`, and a custom domain and confirm OAuth flow
completes on each

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

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 16:47:42 +02:00
9bc803d0c7 i18n - translations (#19765)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-16 16:17:27 +02:00
Thomas TrompetteandGitHub cd31d9e0de Add test tab to tool step (#19760)
So we can use those as variables.
When the function input is updated, invalidate the input using an
effect.

<img width="770" height="635" alt="Capture d’écran 2026-04-16 à 14 45
41"
src="https://github.com/user-attachments/assets/cbf0b3e4-baad-424d-8f08-06eb1028abdb"
/>
2026-04-16 14:00:21 +00:00
c0fef0be08 i18n - translations (#19762)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-16 15:14:16 +02:00
Abdul RahmanandGitHub 270069c3e3 Add search to Fields dropdown (#19750)
Issue link:
https://discord.com/channels/1130383047699738754/1489198502998315100


https://github.com/user-attachments/assets/7d0a859e-c33e-4f9e-bdb3-5867fc0dd80f
2026-04-16 12:59:01 +00:00
Paul RastoinandGitHub 2413af0ba9 [run-instance-commands] Preserve fast slow sequentiality (#19757)
# Introduction
The command was wrongly running all fast and then all slow ignoring
instance commands segment
Leading to 
```ts
1.23.0_AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand_1776090711153 executed successfully
[Nest] 32679  - 04/16/2026, 1:21:07 PM     LOG [InstanceCommandRunnerService] 1.23.0_DropWorkspaceVersionColumnFastInstanceCommand_1785000000000 executed successfully
[Nest] 32679  - 04/16/2026, 1:21:07 PM     LOG [InstanceCommandRunnerService] 1.22.0_BackfillWorkspaceIdOnIndirectEntitiesSlowInstanceCommand_1775758621018 executed successfully
[Nest] 32679  - 04/16/2026, 1:21:07 PM     LOG [RunInstanceCommandsCommand] Instance commands completed
```

No prod/self host impact as 1.23 hasn't been released yet
2026-04-16 12:14:47 +00:00
2b5b8a8b13 Link command menu items to specific page layout (#19706)
- Add a `pageLayoutId` foreign key to `CommandMenuItem`, allowing
command menu items to be scoped to a specific page layout instead of
being globally available
- Filter command menu items by the current page layout on the frontend.
Items with a `pageLayoutId` only appear when viewing that layout, while
items without one remain globally visible
- Create an effect to track the current page layout ID
- Include a seed example: a "Show Notification" command pinned to the
Star history standalone page layout

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-16 12:07:36 +00:00
7af82fb6a4 i18n - translations (#19758)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-16 13:48:47 +02:00
9beb1ca326 Support Select All for workflow manual triggers (#19734)
## Summary
- Move record fetching and payload building from the enrichment hook
into `TriggerWorkflowVersionEngineCommand`, following the same
component-based pattern as `DeleteRecordsCommand` and
`RestoreRecordsCommand`
- The enrichment hook now only stores workflow metadata (`trigger`,
`availabilityType`, `availabilityObjectMetadataId`); the component uses
`useLazyFetchAllRecords` for exclusion mode (Select All) with full
pagination
- `buildTriggerWorkflowVersionPayloads` is now a pure function accepting
`selectedRecords: ObjectRecord[]` instead of reading from the Jotai
store

Fixes the issue introduced by #19718 which blocked Select All with a
warning toast instead of implementing it.

## Test plan
- [ ] Select individual records → run workflow trigger from command menu
→ works as before
- [ ] Click Select All → run workflow trigger from command menu →
fetches all matching records and runs the workflow
- [ ] Select All with some records deselected → correctly excludes those
records
- [ ] Global workflows (no object context) → run without payload as
before
- [ ] Bulk record triggers → payload wraps records in `{namePlural:
[records]}`

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 11:33:03 +00:00
d54092b0e2 fix: add missing LayoutRenderingProvider in SettingsApplicationCustomTab (#19679)
## Summary

- `SettingsApplicationCustomTab` renders `FrontComponentRenderer` which
calls `useFrontComponentExecutionContext` →
`useLayoutRenderingContext()`, but the settings page never provided a
`LayoutRenderingProvider`
- Every other render site (side panel, command menu, record pages) wraps
`FrontComponentRenderer` with this provider — it was just missed here
- Opening the "Custom" tab in Settings → Applications crashes with:
`LayoutRenderingContext Context not found`
- Fix: wrap with `LayoutRenderingProvider` using `DASHBOARD` layout type
and no target record, matching the pattern used in
`SidePanelFrontComponentPage`

## Test plan

- [ ] Open Settings → Applications → any app with a custom settings tab
- [ ] Click the "Custom" tab — should render the front component without
crashing

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-16 10:01:28 +00:00
60701c2cf9 i18n - translations (#19754)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-16 12:03:48 +02:00
martmullandGitHub 381f3ba7d9 Fix app design 1/2 (#19735)
comply with
https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=96977-349627&m=dev

## After
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 40
37"
src="https://github.com/user-attachments/assets/6d80191a-79a9-4f0f-aa4f-0e447fff4f6d"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 40
22"
src="https://github.com/user-attachments/assets/4f763272-027e-4246-b455-7d46babf7d8c"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 39
11"
src="https://github.com/user-attachments/assets/b9b35e18-8068-447e-821d-5ec28bb5bd16"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 39
05"
src="https://github.com/user-attachments/assets/57d9318a-902f-4fd7-a2a3-5795ebe0b9dc"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 39
02"
src="https://github.com/user-attachments/assets/78a33fa8-6bdd-484e-a82d-bd0f7592a623"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 38
58"
src="https://github.com/user-attachments/assets/f7987aed-c6e1-4032-a611-86817655137d"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 38
55"
src="https://github.com/user-attachments/assets/d1c451ab-1d2d-41e4-a059-cf4303ecabe7"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 38
48"
src="https://github.com/user-attachments/assets/593cae36-2320-443f-a955-93b211a6ee3f"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 37
40"
src="https://github.com/user-attachments/assets/c9f602b1-8de3-4e82-a3a6-344594a0c153"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 37
34"
src="https://github.com/user-attachments/assets/b54ddddf-5dda-46c8-ace3-cffe6015825a"
/>

## before

<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42
18"
src="https://github.com/user-attachments/assets/c0976a0a-0124-48ec-8e7c-78627cea7063"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42
16"
src="https://github.com/user-attachments/assets/d2db926c-4040-411d-9091-8b60e7c519e6"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42
13"
src="https://github.com/user-attachments/assets/2d69f2ff-f26e-4249-91a3-2cf3d261e840"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42
07"
src="https://github.com/user-attachments/assets/1028aabc-77ac-4c51-a8c3-9a194faba87f"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 42
01"
src="https://github.com/user-attachments/assets/1caa9f5e-3eaa-433c-9d3b-e0f094f16e8e"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41
56"
src="https://github.com/user-attachments/assets/f42b6976-3a8f-4591-9283-bda79bdb424b"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41
53"
src="https://github.com/user-attachments/assets/93d00df8-0091-4dfa-9ac0-f6f376be5962"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41
43"
src="https://github.com/user-attachments/assets/9deae7e5-39c1-4518-a463-6d79bc5bf132"
/>
<img width="1512" height="909" alt="Capture d’écran 2026-04-16 à 09 41
37"
src="https://github.com/user-attachments/assets/3e21b521-c47d-482c-ad41-66abfe973772"
/>
2026-04-16 09:47:44 +00:00
neo773andGitHub 64470baa1e fix compute folders to update util (#19749)
This regressed with the migration work

Type checker should've caught it, but didn't because of `Partial`
changed it to `Pick` instead to avoid future cases

/closes https://github.com/twentyhq/twenty/issues/19745
2026-04-16 09:09:15 +00:00
Paul RastoinandGitHub f5e8c05267 Add logs before and after instance slow data migration (#19753)
As it can take sometime, would result in not seeing any logs until
2026-04-16 09:08:37 +00:00
Raphaël BosiandGitHub 48c540eb6f Add event forwarding stories to the front component renderer (#19721)
Add Storybook stories and example components to test event forwarding
through the front component renderer: form events (text input, checkbox,
focus/blur, submit), keyboard events (key/code/modifiers), and host API
calls (navigate, snackbar, progress, close panel)
2026-04-16 08:55:49 +00:00
Paul RastoinandGitHub 5583254f58 Remove workspace-migrations deadcode (#19752)
Was a previous twenty version upgrade command util
2026-04-16 08:52:58 +00:00
Abdullah.andGitHub 97832af778 [Website] Resolve animations breaking due to renderer context being lost. (#19747)
Resolves the following two issues. The reason we had these issues was
because the maxLimit of renderers was reached and the browser started
losing context before restoring it. Now, we make sure the context that
is lost belongs to visuals that are not currently in the viewport and
when those visuals come back into viewport, they're loaded again.

https://github.com/twentyhq/core-team-issues/issues/2372

https://github.com/twentyhq/core-team-issues/issues/2373
2026-04-16 08:50:56 +00:00
Abdul RahmanandGitHub 7ce3e2b065 Fix spacing between workspace domain cards (#19742)
https://discord.com/channels/1130383047699738754/1492113564138344468
2026-04-16 08:43:54 +00:00
Abdul RahmanandGitHub a49d8386aa Fix calendar event "Not shared" content alignment and background (#19743)
Issue link:
https://discord.com/channels/1130383047699738754/1491712714848866424

<img width="1287" height="419" alt="Screenshot 2026-04-16 at 9 50 34 AM"
src="https://github.com/user-attachments/assets/ba2143df-7957-4aee-8994-80c0392b6086"
/>
2026-04-16 08:38:09 +00:00
Charles BochetandGitHub e420ee8746 Release v1.22.0 for twenty-sdk, twenty-client-sdk, and create-twenty-app (#19751)
## Summary
- Bump `twenty-sdk` from `1.22.0-canary.6` to `1.22.0`
- Bump `twenty-client-sdk` from `1.22.0-canary.6` to `1.22.0`
- Bump `create-twenty-app` from `1.22.0-canary.6` to `1.22.0`
2026-04-16 08:29:50 +00:00
Thomas TrompetteandGitHub 7adab8884b Add isUnique update in query + invalidate cache on rollback (#19746)
Fix isUnique not sent in field update mutation: Added isUnique and
settings to the Pick type in useUpdateOneFieldMetadataItem, which
previously silently dropped these properties from the GraphQL payload.

Invalidate cache on failed migration rollback: Added invalidateCache()
call in the migration runner's catch block so that a failed migration
(e.g., unique index creation failing due to duplicate data) regenerates
the Redis hash, preventing stale metadata from persisting in frontend
localStorage indefinitely. Was
2026-04-16 07:44:59 +00:00
6101a4f113 Update website hero to use shared local avatars and logos (#19736)
## Summary
- import shared company logos and people avatars into
`packages/twenty-website-new/public/images/shared`
- expand the shared asset registry with the new local logo and avatar
paths
- update the home hero data to use local shared assets for matched
people and company icons
- replace synthetic or mismatched hero avatars with better-fitting named
or anonymous local assets
- prefer local shared company logos in hero visual components before
falling back to remote icons

## Testing
- Not run (not requested)

---------

Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
2026-04-16 06:58:48 +00:00
Thomas des FrancsandGitHub da8fe7f6f7 Homepage 3 cards finished (#19732)
& various fixes
2026-04-16 06:42:25 +00:00
cddc47b61f i18n - translations (#19731)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-15 19:08:24 +02:00
Charles BochetandGitHub 446c39d1c0 Fix silent failures in logic function route trigger execution (#19698)
## Summary

Route-triggered logic functions were returning empty 500 responses with
zero server-side logging when the Lambda build chain failed. This PR
makes those failures observable and returns meaningful HTTP responses to
API clients.

- **Observability** — Log errors (with stack traces) at each layer of
the execution chain: `LambdaDriver` (deps-layer fetch, SDK-layer fetch,
invocation), `LogicFunctionExecutorService`, and `RouteTriggerService`.
- **Typed exceptions** — Replace raw `throw error` sites with
`LogicFunctionException` carrying an appropriate code and
`userFriendlyMessage` (new codes: `LOGIC_FUNCTION_EXECUTION_FAILED`,
`LOGIC_FUNCTION_LAYER_BUILD_FAILED`).
- **Correct HTTP semantics** — `RouteTriggerService` maps inner
exception codes to the right `RouteTriggerExceptionCode` so
`LOGIC_FUNCTION_NOT_FOUND` returns 404 and `RATE_LIMIT_EXCEEDED` returns
429 (new code + filter case) instead of a generic 500.
- **User-facing messages** — Forward the inner
`CustomException.userFriendlyMessage` when wrapping into
`RouteTriggerException`, without leaking raw internal error text into
the public exception message.
- **Infra** — Bump Lambda ephemeral storage from 2048 to 4096 MB to
prevent `ENOSPC` errors during yarn install layer builds (root cause of
the original silent failures).
2026-04-15 16:49:43 +00:00
5eda10760c Fix navbar folder opening lag by deferring navigation until expand animation completes (#19686)
### Before


https://github.com/user-attachments/assets/7d57aa55-8d4c-43e1-8835-f40206e5e453



### After


https://github.com/user-attachments/assets/2457c8ee-fcb9-41ae-a8f7-08f8c7b4a233

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-04-15 16:31:33 +00:00
b9605a3003 Refactor SnackBar duration handling and progress bar visibility logic (#19712)
## Summary

Fixes error snackbars disappearing too quickly by **disabling
auto-dismiss for error variants by default**. Error snackbars now remain
visible until the user closes them.

Closes #19694

## What changed

- Error snackbars no longer default to a 6s timeout (they only
auto-dismiss if an explicit `duration` is provided).
- Non-error snackbars keep the existing default auto-dismiss behavior
(6s).
- Progress bar animation/visibility is tied to auto-dismiss (no progress
bar when there’s no duration).

**Files**
-
packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx

## How to test

1. Trigger a long error snackbar (example: spreadsheet/CSV import error
with a long message).
2. Confirm the snackbar **stays visible** until clicking **Close**.
3. Trigger a success/info snackbar and confirm it **still
auto-dismisses** after ~6s.

## Notes

- Call sites that explicitly pass `options.duration` for error snackbars
will continue to auto-dismiss (intentional).

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-15 16:22:28 +00:00
WeikoandGitHub 56d6e13b5d Fix fields widget flash during reset (#19726)
Summary

- Remove the evictViewMetadataForViewIds step from the page-layout reset
flow. It synchronously cleared viewFields/viewFieldGroups rows from the
metadata store, leaving a window where
useFieldsWidgetGroups saw a view with no fields and fell back to
buildDefaultFieldsWidgetGroups, briefly rendering a synthetic "General"
+ "Other" layout before the real reset defaults
arrived.
- invalidateMetadataStore() alone is sufficient: it marks the
collections stale and triggers MinimalMetadataLoadEffect to refetch,
which replaces current atomically. The UI now
transitions old-layout → new-default with no synthetic flash.
- Simplified refreshPageLayoutAfterReset to no longer take a
collectAffectedViewIds callback, and updated both tab/widget reset call
sites plus ObjectLayout.tsx accordingly.
- Deleted the now-unused evictViewMetadataForViewIds and
collectViewIdsFromWidgets utils.
2026-04-15 16:08:24 +00:00
725171bfd3 i18n - translations (#19729)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-15 18:11:26 +02:00
Raphaël BosiandGitHub 3b85747d3f Go back to the original command K button (#19727)
## Before


https://github.com/user-attachments/assets/868e5293-8843-44c8-8011-b7130e97fa95


## After


https://github.com/user-attachments/assets/89cf46cc-1fb1-4e78-bfef-53e1d04e8ebf
2026-04-15 15:53:26 +00:00
Paul RastoinandGitHub a4cc7fb9c5 [Upgrade] Fix workspace creation cursor (#19701)
## Summary

### Problem

The upgrade migration system required new workspaces to always start
from a workspace command, which was too rigid. When the system was
mid-upgrade within an instance command (IC) segment, workspace creation
would fail or produce inconsistent state.

### Solution

#### Workspace-scoped instance command rows

Instance commands now write upgrade migration rows for **all
active/suspended workspaces** alongside the global row. This means every
workspace has a complete migration history, including instance command
records.

- `InstanceCommandRunnerService` reloads `activeOrSuspendedWorkspaceIds`
immediately before writing records (both success and failure paths) to
mitigate race conditions with concurrent workspace creation.
- `recordUpgradeMigration` in `UpgradeMigrationService` accepts a
discriminated union over `status`, handles `error: unknown` formatting
internally, and writes global + workspace rows in batch.

#### Flexible initial cursor for new workspaces

`getInitialCursorForNewWorkspace` now accepts the last **attempted**
(not just completed) instance command with its status:

- If the IC is `completed` and the next step is a workspace segment →
cursor is set to the last WC of that segment (existing behavior).
- If the IC is `failed` or not the last of its segment → cursor is set
to that IC itself, preserving its status.

This allows workspaces to be created at any point during the upgrade
lifecycle, including mid-IC-segment and after IC failure.

#### Relaxed workspace segment validation

`validateWorkspaceCursorsAreInWorkspaceSegment` accepts workspaces whose
cursor is:
1. Within the current workspace segment, OR
2. At the immediately preceding instance command with `completed` status
(handles the `-w` single-workspace upgrade scenario).

Workspaces with cursors in a previous segment, ahead of the current
segment, or at a preceding IC with `failed` status are rejected.

### Test plan
created empty workspaces to allow testing upgrade with several active
workspaces
2026-04-15 15:41:10 +00:00
Raphaël BosiandGitHub 0f8152f536 Fix command menu item edit record selection dropdown icons (#19725)
## Before
<img width="816" height="126" alt="CleanShot 2026-04-15 at 17 20 11@2x"
src="https://github.com/user-attachments/assets/647d6aa6-1000-41d9-9351-c1489bc095c6"
/>


## After
<img width="808" height="120" alt="CleanShot 2026-04-15 at 17 19 05@2x"
src="https://github.com/user-attachments/assets/e7685370-b6eb-4720-a5dd-401dfae8dc6b"
/>
2026-04-15 15:31:59 +00:00
Charles BochetandGitHub a328c127f3 Fix standalone page migration failing on navigationMenuItem enum type change (#19724)
## Summary
- The `AddStandalonePageFastInstanceCommand` migration was failing with:
`default for column "type" cannot be cast automatically to type
core."navigationMenuItem_type_enum"`
- The migration was missing `DROP DEFAULT` / `SET DEFAULT` around the
`ALTER COLUMN TYPE` for `navigationMenuItem.type`. PostgreSQL cannot
automatically cast the existing default (`'VIEW'::old_enum`) to the new
enum type.
- The same 3-step pattern (DROP DEFAULT → ALTER TYPE → SET DEFAULT) was
already correctly applied for `pageLayout.type` in the same migration —
this fix brings `navigationMenuItem.type` in line.
2026-04-15 17:25:55 +02:00
Baptiste DevessierandGitHub 8cb803cedf Various bug fixes Record page layouts (#19719)
Fixes:

- Can't add multiple widgets in a row
- Ensure newly created is always focused
2026-04-15 15:12:57 +00:00
WeikoandGitHub 5e83ad43de Update backfill page layout command (#19687) 2026-04-15 14:55:42 +00:00
Raphaël BosiandGitHub 7ba5fe32f8 Add new html tags to the remote elements (#19723)
- Add 72 missing HTML and SVG elements to the remote-dom component
registry (48 HTML + 24 SVG), bringing the total from 47 to 119 supported
elements
- HTML additions include semantic inline text (b, i, u, s, mark, sub,
sup, kbd, etc.), description lists, ruby annotations, structural
elements (figure, details, dialog), and form utilities (fieldset,
progress, meter, optgroup)
- SVG additions include containers (svg, g, defs), shapes (path, circle,
rect, line, polygon), text (text, tspan), gradients (linearGradient,
radialGradient, stop), and utilities (clipPath, mask, foreignObject,
marker)
- Add htmlTag override to support SVG elements with camelCase names
(e.g. clipPath, foreignObject) while keeping custom element tags
lowercase per the Web Components spec
2026-04-15 14:53:23 +00:00
7601dbc218 i18n - translations (#19722)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-15 16:12:03 +02:00
Thomas TrompetteandGitHub 8a968d1e31 Hide workflow manual trigger from command menu on "Select All" (#19718)
https://github.com/user-attachments/assets/e19c6d23-03c1-49c8-8b83-5c8f5f0f1135
2026-04-15 13:55:38 +00:00
0c4a194c7a i18n - translations (#19720)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-15 15:50:21 +02:00
EtienneandGitHub 94b8e34362 Object view widget - Introduce new TABLE_WIDGET view type (#19545)
closes
https://discord.com/channels/1130383047699738754/1491549365263667230/1491804729397743666
2026-04-15 13:30:37 +00:00
MarieandGitHub 2fccd194f3 [Billing for self host] End dummy enterprise key validity (#19560)
<img width="1504" height="755" alt="Screenshot 2026-04-10 at 16 40 07"
src="https://github.com/user-attachments/assets/68a12e40-a077-48df-9e18-885493520a32"
/>


Re-using hasValidEnterpriseKey to avoid breaking changes. 
This will be entirely removed in the next versions.
2026-04-15 13:26:38 +00:00
762fb6fd64 Fix active navigation item disambiguation (#19664)
## Summary

- Introduce a `activeNavigationMenuItemState` Jotai atom (persisted via
localStorage) to disambiguate active navigation items when multiple
items share the same URL
- Add active item evaluation for record show pages with three scenarios:
1. Navigating from a nav item → parent stays active + dedicated RECORD
item also active
  2. Clicking a dedicated RECORD nav item → only that item active
3. Navigating via search/direct link → OBJECT nav item fallback, or
Opened section if none exists
- Extract shared active logic into `isNavigationMenuItemActive` utility
to eliminate duplication between orphan items and folder items
- Support multiple simultaneously active items within folders via
`Set<number>` instead of a single index

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-04-15 13:00:47 +00:00
Raphaël BosiandGitHub 7956ecc7b2 Invert pinned and dots icon buttons in command menu items edit mode (#19717)
## Before
<img width="804" height="158" alt="CleanShot 2026-04-15 at 14 22 22@2x"
src="https://github.com/user-attachments/assets/65c4d4f1-6f17-47e2-b964-f3b7132d2f18"
/>

## After
<img width="804" height="168" alt="CleanShot 2026-04-15 at 14 21 38@2x"
src="https://github.com/user-attachments/assets/2f1d4fff-5f07-4df0-833e-129ad85391a9"
/>
2026-04-15 12:38:52 +00:00
WeikoandGitHub e12b55a951 Fix duplicate Fields widget (#19696)
## Context
Tab duplication was broken after the view creation logic was deferred
and moved to the FE.

- Duplicating a tab or a FIELDS widget now produces a fully independent
copy: new view, new view field groups, new view fields — all with fresh
IDs — while preserving any unsaved edits
  from the source widget.
- Removed the backend auto-seed of default view fields / view field
groups in ViewService.createOne for FIELDS_WIDGET views. The frontend
always sends the complete layout via
upsertFieldsWidget, so the auto-seed was both redundant and the source
of potential bugs.
- Extracted a shared useDuplicateFieldsWidgetForPageLayout hook used by
both tab and widget duplication paths, plus a small
useCloneViewInMetadataStore helper that clones the FlatView
in the metadata store and returns the copied flat view fields/groups for
the caller.
2026-04-15 12:29:44 +00:00
Raphaël BosiandGitHub 2df32f7003 Hide fallback command menu items in edit mode (#19700)
Hide fallback command menu items in edit mode
2026-04-15 09:38:54 +00:00
Thomas TrompetteandGitHub 46ee72160d Fix infinite recursion in iterator loop traversal when If/Else branch loops back to enclosing iterator (#19714)
Fix a stack overflow (Maximum call stack size exceeded) in
getAllStepIdsInLoop caused by an If/Else branch inside an iterator loop
pointing back to the enclosing iterator. The traversal incorrectly
treated the enclosing iterator as a nested iterator, calling
getAllStepIdsInLoop recursively with fresh visited sets, causing
infinite recursion.

Add the enclosing iterator's own ID to the skip condition in
traverseSteps so back-edges from If/Else branches are handled the same
way as back-edges from regular nextStepIds.

<img width="1054" height="723" alt="Capture d’écran 2026-04-15 à 11 00
42"
src="https://github.com/user-attachments/assets/aee1477b-5059-4552-809e-7c8a34a9ec4a"
/>
2026-04-15 09:23:25 +00:00
f953aea3c5 i18n - translations (#19715)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-15 11:27:10 +02:00
Baptiste DevessierandGitHub c805a351ab Reactivate disabled full tab widgets (#19702)
https://github.com/user-attachments/assets/14a48c7d-e731-4a15-883f-c53beb4943de
2026-04-15 09:11:55 +00:00
3e48be4c31 Update home card visuals and partner marketing assets (#19711)
## Summary
- replace static home card imagery with code-driven visuals for the
familiar interface, fast path, and live data cards
- refine the live data card interaction details, including hover states,
animated cursors, filter chips, table styling, and inline tag editing
- add shared company logos, people avatars, and updated partner
testimonial assets to support the new marketing visuals
- refresh related home, partner, case study, signoff, testimonials, and
design-system content/components to align with the updated marketing
presentation

## Testing
- `npx tsc -p tsconfig.json --noEmit` in `packages/twenty-website-new`

Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
2026-04-15 06:35:46 +00:00
a9ea1c6eed i18n - docs translations (#19710)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-14 22:35:59 +02:00
7a721ef5dd i18n - docs translations (#19709)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-14 20:45:33 +02:00
77e5b06a50 i18n - translations (#19707)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-14 19:00:22 +02:00
MarieandGitHub bc28e1557c Introduce updateWorkspaceMemberSettings and clarify product (#19441)
## Summary

Introduces a dedicated **metadata** mutation to update **standard
(non-custom)** workspace member settings, moves profile-related UI to
use it, and aligns **workspace member** record permissions with the rest
of the CRM so users cannot escalate visibility via RLS by editing their
own member record.

## Product behaviour

### Profile and appearance (standard fields)

- Users can still update **their own** standard workspace member fields
that the product exposes in **Settings / Profile** (e.g. name, locale,
color scheme, avatar flow) via the new
**`updateWorkspaceMemberSettings`** mutation.
- The mutation returns a **boolean**; the app **merges** the updated
fields into local state so the UI stays in sync without refetching the
full workspace member record.
- **Locale** changes also keep **`userWorkspace`** in sync when a locale
is present in the payload (including from the workspace `updateOne` path
when applicable).

### Custom fields on workspace members

- The dedicated metadata mutation **rejects** any **custom** workspace
member field (and unknown keys). Those updates must go through the
normal **object** `updateOne` pipeline, which is subject to **object-
and field-level** permissions like other records. But since we don't
have object- and field-level permission configuration for system objects
yet, this permission is derived from Workspace member settings
permission.
- **Workspace member** is no longer exempt from ORM permission
validation for updates merely because it is a **system** object. Users
who **do not** have workspace member access (e.g. no **Workspace
members** settings permission and no equivalent broad settings access on
the role) **cannot** use `updateOne` on `workspaceMember` to change
**custom** (or other) fields on their own row—even though that row is
used for RLS predicates.
- This closes a path where someone could widen what they can see by
writing to fields that drive row-level rules.

### Who can change another member

- Updating **another** user’s workspace member still requires
**Workspace members** (or equivalent) settings permission, consistent
with admin tooling.
2026-04-14 16:29:00 +00:00
42f452311b i18n - docs translations (#19705)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-14 18:44:30 +02:00
59a222e0f0 i18n - translations (#19703)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-14 18:38:55 +02:00
307b6c94de Align GraphQL error handling for billing and AI chat (#19690)
## What changed

This refactor fixes AI chat error surfacing by aligning both the backend
and frontend with the existing GraphQL error architecture instead of
adding AI-local error translation.

On the backend:
- add a dedicated GraphQL billing exception path
- register billing GraphQL handling globally for GraphQL requests
- reuse the existing AI GraphQL interceptor path for agent/chat
exceptions
- keep billing status classification shared between REST and GraphQL
- remove the earlier attempt to preserve `CustomException` metadata in
the global GraphQL fallback

On the frontend:
- keep the original Apollo GraphQL error object in AI chat state
- reuse shared Apollo/GraphQL helpers for user-facing messages and
error-type checks
- delete AI-specific error extraction helpers that duplicated generic
GraphQL parsing
- replace a few direct `extensions.subCode` call sites with a shared
predicate

## Why it changed

The original bug was that `BillingException` and AI exceptions thrown
from chat were not being translated into GraphQL errors with the
expected `extensions.subCode` and `extensions.userFriendlyMessage`, so
the AI chat UI had nothing structured to inspect.

An intermediate fix worked mechanically but pushed `CustomException`
handling into the global GraphQL fallback, which blurred the intended
layering. This PR moves the behavior back to explicit GraphQL edges.

## Root cause

`AgentChatResolver` could throw `BillingException` and `AgentException`,
but:
- billing had a REST exception filter and no shared GraphQL equivalent
- AI chat was not consistently using the same GraphQL exception
translation path as the sibling AI resolver
- the frontend chat UI had drifted into AI-specific error parsing
instead of consuming the same structured Apollo errors as the rest of
the app

## Impact

- `BILLING_CREDITS_EXHAUSTED` is now preserved through GraphQL and can
render the existing credits-exhausted UI in chat
- `API_KEY_NOT_CONFIGURED` is preserved through the AI GraphQL path
- AI chat now follows the same general GraphQL error consumption pattern
as the rest of the frontend
- billing GraphQL handling is less dependent on individual resolver
authors remembering to add a filter

## Validation

- `yarn jest --config packages/twenty-server/jest.config.mjs
packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/billing-graphql-api-exception-handler.util.spec.ts
packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/__tests__/agent-graphql-api-exception-handler.util.spec.ts`
- `yarn jest --config packages/twenty-front/jest.config.mjs
packages/twenty-front/src/utils/__tests__/is-graphql-error-of-type.util.test.ts`
- `npx oxlint --type-aware ...` on touched backend/frontend files
- `npx prettier --check ...` on touched backend/frontend files

## Follow-up ideas

- consolidate frontend GraphQL error helpers further so more existing
direct `extensions.subCode` checks move to shared utilities
- consider whether common GraphQL exception filter registration should
live in a more explicit GraphQL-specific module instead of
`CoreEngineModule`
- add an end-to-end test for a real `sendChatMessage` GraphQL failure
path in AI chat

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 18:31:59 +02:00
martmullandGitHub 3463ee5dc4 Switch default remote to lastly added remote (#19697)
as title
2026-04-14 16:03:05 +00:00
9cf6f42313 i18n - translations (#19699)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-14 18:08:57 +02:00
Raphaël BosiandGitHub a88d1f4442 Introduce standalone page (#19675)
Add support for standalone pages: a new `PageLayout` type
(`STANDALONE_PAGE`) that can be rendered independently at
`/page/:pageLayoutId`, not tied to any record or object context.

- New `STANDALONE_PAGE` page layout type
- New `PAGE_LAYOUT` navigation menu item type: adds a `pageLayoutId`
foreign key to `NavigationMenuItemEntity`, allowing sidebar items to
link directly to standalone pages
- New `GLOBAL_OBJECT_CONTEXT` command menu availability type: separates
object-context-dependent commands (Create Record, Import, Export, See
Deleted, Create View, Hide Deleted) from truly global ones, so
standalone pages only show relevant commands
- Frontend routing & rendering: adds a `/page/:pageLayoutId` route with
its own page component, header, and command menu
- Widget rendering refactor
- Instance commands: two fast 1.22 migrations: `pageLayoutId` column +
`STANDALONE_PAGE` enum, and `GLOBAL_OBJECT_CONTEXT` availability type
enum
- Workspace command: backfills existing command menu items from `GLOBAL`
to `GLOBAL_OBJECT_CONTEXT` where appropriate
- Dev seeds: adds a sample "Star History" standalone page with an iframe
widget for local development
2026-04-14 15:52:45 +00:00
martmullandGitHub 43249d80e8 Add missing doc (#19693)
follow up of the @charlesBochet presentation
as title
2026-04-14 15:30:50 +00:00
ed9dd3c275 i18n - translations (#19692)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-14 14:56:36 +02:00
martmullandGitHub b3354ab6e7 Fix multi-workspace-registration (#19685)
## Before

<img width="1512" height="915" alt="image"
src="https://github.com/user-attachments/assets/5cb05f76-b672-404e-b31d-ca455802f97a"
/>


## After

<img width="1512" height="726" alt="image"
src="https://github.com/user-attachments/assets/58229c4c-3ac6-4428-9c4d-3586a2b9ee36"
/>
2026-04-14 12:41:19 +00:00
Paul RastoinandGitHub 762de40c3d Improve upgrade registry logging for pre-release bundles (#19689)
```ts
Registered 3 fast instance command(s), 0 slow instance command(s), and 14 workspace command(s) for 1.21.0
Registered 5 fast instance command(s), 1 slow instance command(s), and 3 workspace command(s) for 1.22.0
Registered 1 fast instance command(s), 0 slow instance command(s), and 1 workspace command(s) for 1.23.0 (pre-release)
```
2026-04-14 12:33:47 +00:00
573ecea753 i18n - translations (#19691)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-14 14:39:07 +02:00
Thomas TrompetteandGitHub c8a3de6c65 Test workflow with webhook expected body (#19688)
As title. Currently payload is always undefined when testing
2026-04-14 12:21:48 +00:00
WeikoandGitHub edba7fe085 Reset to default page layout (#19682)
- Add resetPageLayoutToDefault GraphQL mutation that resets an entire
page layout (all tabs, widgets, view field groups, and view fields) to
their default state in a single operation
- Add a "Reset to default" button in the settings Layout tab
(/settings/objects/:object#layout) with a confirmation modal


<img width="673" height="426" alt="Screenshot 2026-04-14 at 13 13 53"
src="https://github.com/user-attachments/assets/002d33c9-9ea1-49f2-bef6-179ce034c126"
/>
2026-04-14 12:19:48 +00:00
Paul RastoinandGitHub ef328755bb Bump current version to 1.23.0 (#19683) 2026-04-14 12:04:44 +00:00
WeikoandGitHub 1e42be5a44 Expend field widget field supported types (#19684)
## Context
Expending field widget supported types to all field types. They will all
by default fallback to "FIELD" displaymode which is the inline display
mode (same as the one used in FIELDS widget) and can be extended to
other displayMode such as CARD or EDITOR in the future if needed. Most
of the work was already done in the previous PR and was unnecessary
filter out until this was properly tested

<img width="951" height="757" alt="Screenshot 2026-04-14 at 13 24 24"
src="https://github.com/user-attachments/assets/a08a1855-21ea-4e75-8032-4f970b3ff50d"
/>
<img width="915" height="595" alt="Screenshot 2026-04-14 at 13 23 34"
src="https://github.com/user-attachments/assets/8b420c1f-0496-4c1c-91b8-b266c40b3772"
/>
2026-04-14 11:51:01 +00:00
b194b67ac4 fix(address): populate street line from place details (#19326)
## Summary
- extract and expose `street` from Google place details (`street_number`
+ `route`) on the server DTO
- request and type `street` in front-end geo-map place details query
- use `placeData.street` as the preferred value for `addressStreet1` in
address autofill
- add regression coverage for query fields and street-line precedence
behavior

## Why
Address autocomplete selection currently writes full place text
(including city/state/postcode/country) into `addressStreet1`,
duplicating values already mapped to dedicated fields.

Fixes #18860

---------

Signed-off-by: jeevan6996 <jeevanpawar5890@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-14 11:44:36 +00:00
Thomas des FrancsandGitHub eaf54ae02f website new fixes (#19678) 2026-04-14 11:28:40 +00:00
martmullandGitHub fb4d037b93 Upgrade self hosting application (#19680)
as title, installed on
https://twentyfortwenty.twenty.com/objects/selfHostingUsers?viewId=20069db0-5137-4b2f-9b20-1797572b8eb8
2026-04-14 11:21:18 +00:00
edc47bd458 i18n - docs translations (#19677)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-14 12:45:12 +02:00
WeikoandGitHub 47bdcb11d8 Move is active to fe (#19649)
## Context
Moving isActive filtering to the frontend for page layout tabs and
widgets, hiding inactive entities from the UI while keeping them in
state for future reactivation

Next we will implement deactivated standard tab re-activation during tab
creation (cc @Devessier)
<img width="234" height="303" alt="📋 Menu (Slots)"
src="https://github.com/user-attachments/assets/17a25ac6-55e2-4778-b7f0-e7554ed69704"
/>
2026-04-14 10:18:09 +00:00
1145 changed files with 59056 additions and 25033 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "1.22.0-canary.6",
"version": "1.22.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -0,0 +1,42 @@
name: CD
on:
push:
branches:
- main
pull_request:
types: [labeled]
permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:3000
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-and-install:
if: >-
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
- name: Install
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
@@ -0,0 +1,48 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty test instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
@@ -35,3 +35,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
*.d.ts
@@ -0,0 +1 @@
24.5.0
@@ -1,40 +1,19 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}]
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -1,5 +1 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
enableTransparentWorkspaces: false
@@ -0,0 +1,14 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## 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.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -0,0 +1,14 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## 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.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -0,0 +1,14 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## 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.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -1,44 +1,11 @@
# Self Hosting
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
Used to manage billing and telemetry of self-hosted instances
## Getting Started
## Features
Run `yarn twenty help` to list all available commands.
### Telemetry Webhook
## Learn More
Receives user signup telemetry events from self-hosted Twenty instances and creates/updates selfHostingUser records.
**Endpoint:** `POST /webhook/telemetry`
**Payload Structure:**
```json
{
"action": "user_signup",
"timestamp": "2025-11-21T...",
"version": "1",
"payload": {
"userId": "uuid",
"workspaceId": "uuid",
"payload": {
"events": [
{
"userEmail": "user@example.com",
"userId": "uuid",
"userFirstName": "John",
"userLastName": "Doe",
"locale": "en",
"serverUrl": "https://self-hosted.example.com"
}
]
}
}
}
```
**Response:**
```json
{
"success": true,
"message": "Self hosting user created/updated: uuid"
}
```
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started)
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)
@@ -1,6 +1,6 @@
{
"name": "self-hosting",
"version": "0.0.1",
"version": "1.0.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -11,13 +11,22 @@
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"twenty-client-sdk": "1.22.0-canary.6",
"twenty-sdk": "1.22.0-canary.6"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"oxlint": "^0.16.0",
"twenty-sdk": "0.6.2"
},
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/appManifest.schema.json",
"universalIdentifier": "a7070f46-3158-4b40-828f-8e6b1febc233"
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
}
}
@@ -0,0 +1,72 @@
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application.config';
const APP_PATH = process.cwd();
describe('App installation', () => {
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(`[build] ${message}`),
});
if (!buildResult.success) {
throw new Error(
`Build failed: ${buildResult.error?.message ?? 'Unknown error'}`,
);
}
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(`[deploy] ${message}`),
});
if (!deployResult.success) {
throw new Error(
`Deploy failed: ${deployResult.error?.message ?? 'Unknown error'}`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
`Install failed: ${installResult.error?.message ?? 'Unknown error'}`,
);
}
});
afterAll(async () => {
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 metadataClient = new MetadataApiClient();
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,53 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.test.json');
beforeAll(async () => {
const apiUrl = process.env.TWENTY_API_URL!;
const token = process.env.TWENTY_API_KEY!;
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(
CONFIG_PATH,
JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey: token },
},
defaultRemote: 'local',
},
null,
2,
),
);
process.env.TWENTY_APP_ACCESS_TOKEN ??= token;
});
@@ -1,6 +1,9 @@
import { defineApplication } from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'94f7db30-59e5-4b09-a5fe-64cd3d4a65b0';
export default defineApplication({
universalIdentifier: '94f7db30-59e5-4b09-a5fe-64cd3d4a65b0',
displayName: 'Self Hosting',
@@ -5,7 +5,7 @@ import {
type ObjectRecordUpdateEvent,
} from 'twenty-sdk';
import { SELF_HOSTING_USER_NAME_SINGULAR } from 'src/objects/selfHostingUser.object';
import { CoreApiClient } from 'twenty-sdk/clients';
import { CoreApiClient } from 'twenty-client-sdk/core';
type SelfHostingUser = {
id: string;
@@ -1,5 +1,5 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/clients';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type TelemetryEvent } from 'src/logic-functions/types/telemetry-event.type';
export const main = async (
@@ -4,7 +4,7 @@ import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.const
export default defineRole({
universalIdentifier:
UNIVERSAL_IDENTIFIERS.roles.defaultRole.universalIdentifier,
label: 'default role',
label: 'Self hosting default role',
description: 'Add a description for your role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
@@ -27,5 +27,16 @@
"~/*": ["./*"]
}
},
"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,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}
@@ -0,0 +1,24 @@
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: process.env.TWENTY_API_URL ?? 'http://localhost:2020',
TWENTY_API_KEY:
process.env.TWENTY_API_KEY ??
// Tim Apple (admin) access token for twenty-app-dev
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4',
},
},
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
name: CD
on:
push:
branches:
- main
pull_request:
types: [labeled]
permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:3000
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-and-install:
if: >-
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
- name: Install
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
@@ -0,0 +1,48 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty test instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
@@ -0,0 +1,38 @@
# 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
@@ -0,0 +1 @@
24.5.0
@@ -0,0 +1,19 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -0,0 +1,14 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## 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.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -0,0 +1,14 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## 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.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -0,0 +1,14 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## 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.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -0,0 +1,122 @@
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Overview
**Twenty for Twenty** is the official internal Twenty app. It is organized into modules, each integrating a third-party service with Twenty.
### Resend module (`src/modules/resend/`)
Two-way sync between Twenty and the [Resend](https://resend.com) email platform. The module syncs contacts, segments, templates, broadcasts, and emails.
**Inbound (Resend -> Twenty):**
- A cron job runs every 5 minutes to pull all entities from the Resend API
- A webhook endpoint receives real-time events for contacts and emails
**Outbound (Twenty -> Resend):**
- Database event triggers push contact and segment changes back to Resend when records are created, updated, or deleted in Twenty
## Getting Started
### 1. Install and run the app
```bash
yarn twenty dev
```
This registers the app with your local Twenty instance at `http://localhost:3000/settings/applications`.
### 2. Configure app variables
In Twenty, go to **Settings > Applications > Twenty for Twenty** and set:
- **RESEND_API_KEY** -- Your Resend API key. Create one at https://resend.com/api-keys (full access recommended).
- **RESEND_WEBHOOK_SECRET** -- The signing secret for verifying inbound webhooks (see "Webhook setup" below).
### 3. Webhook setup
The app exposes an HTTP endpoint at `/s/webhook/resend` that receives Resend webhook events. To connect it:
1. Go to https://resend.com/webhooks
2. Click **Add webhook**
3. Set the **Endpoint URL** to your Twenty server's public URL + `/s/webhook/resend` (e.g. `https://your-domain.com/s/webhook/resend`)
4. Set **Events types** to **All Events**
5. Click **Add**
6. Copy the **signing secret** Resend displays and paste it into the `RESEND_WEBHOOK_SECRET` app variable in Twenty
The webhook handles:
- **Contact events** (`contact.created`, `contact.updated`, `contact.deleted`) -- upserts/deletes Resend contact records in Twenty
- **Email events** (`email.sent`, `email.delivered`, `email.bounced`, `email.opened`, `email.clicked`, etc.) -- updates delivery status on Resend email records in real-time
- **Domain events** -- logged and skipped (no domain object in the app yet)
### 4. Testing webhooks locally
Install the [Resend CLI](https://resend.com/docs/resend-cli):
```bash
brew install resend/cli/resend
```
Or via npm if Homebrew has issues:
```bash
npm install -g resend-cli
```
Authenticate:
```bash
resend login
```
Start the webhook listener with forwarding to your local Twenty server:
```bash
resend webhooks listen --forward-to http://localhost:3000/s/webhook/resend
```
The CLI will:
1. Create a public tunnel automatically
2. Register a temporary webhook in Resend pointing to that tunnel
3. Forward incoming events (with Svix signature headers) to your local Twenty server
4. Display events in the terminal as they arrive
5. Clean up the temporary webhook when you press Ctrl+C
To trigger test events, create or update a contact in the [Resend dashboard](https://resend.com/contacts), or send a test email.
## Sync behavior
### Inbound sync
| Source | Mechanism | Entities |
|---|---|---|
| Cron (every 5 min) | Polls Resend API, upserts into Twenty | Contacts, segments, templates, broadcasts, emails |
| Webhook (real-time) | Receives Resend events via HTTP | Contacts, emails |
### Outbound sync
| Twenty action | Resend API call |
|---|---|
| Create contact | `contacts.create()` -- writes `resendId` back to Twenty |
| Update contact (name, email, unsubscribed) | `contacts.update()` |
| Delete contact | `contacts.remove()` |
| Create segment | `segments.create()` -- writes `resendId` back to Twenty |
| Delete segment | `segments.remove()` |
### Loop prevention
A `lastSyncedFromResend` field on contact, segment, and email records tracks when data came from Resend. Outbound triggers skip processing when this field is part of the update, preventing infinite echo loops between inbound and outbound sync.
## Commands
Run `yarn twenty help` to list all available commands.
## Learn More
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started)
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
- [Resend API documentation](https://resend.com/docs)
- [Discord](https://discord.gg/cx5n4Jzs57)
@@ -0,0 +1,36 @@
{
"name": "twenty-for-twenty",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run --config vitest.unit.config.ts",
"test:unit:watch": "vitest --config vitest.unit.config.ts"
},
"dependencies": {
"resend": "^6.12.0",
"twenty-client-sdk": "1.22.0",
"twenty-sdk": "1.22.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
}
}
@@ -0,0 +1,3 @@
<svg width="1800" height="1800" viewBox="0 0 1800 1800" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1000.46 450C1174.77 450 1278.43 553.669 1278.43 691.282C1278.43 828.896 1174.77 932.563 1000.46 932.563H912.382L1350 1350H1040.82L707.794 1033.48C683.944 1011.47 672.936 985.781 672.935 963.765C672.935 932.572 694.959 905.049 737.161 893.122L908.712 847.244C973.85 829.812 1018.81 779.353 1018.81 713.298C1018.8 632.567 952.745 585.78 871.095 585.78H450V450H1000.46Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 504 B

@@ -0,0 +1,87 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}
@@ -0,0 +1,46 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CoreApiClient', () => {
it('should support CRUD on standard objects', async () => {
const client = new CoreApiClient();
const created = await client.mutation({
createNote: {
__args: { data: { title: 'Integration test note' } },
id: true,
},
});
expect(created.createNote.id).toBeDefined();
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
id: true,
},
});
});
});
@@ -0,0 +1,32 @@
import { defineApplication } from 'twenty-sdk';
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import {
RESEND_API_KEY_UNIVERSAL_IDENTIFIER,
RESEND_WEBHOOK_SECRET_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
logoUrl: 'public/resend-icon-black.svg',
applicationVariables: {
RESEND_API_KEY: {
universalIdentifier: RESEND_API_KEY_UNIVERSAL_IDENTIFIER,
description: 'API key for the Resend service',
isSecret: true,
},
RESEND_WEBHOOK_SECRET: {
universalIdentifier: RESEND_WEBHOOK_SECRET_UNIVERSAL_IDENTIFIER,
description: 'Signing secret for verifying Resend webhook payloads',
isSecret: true,
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,5 @@
export const APP_DISPLAY_NAME = 'Twenty for Twenty';
export const APP_DESCRIPTION =
'The official Twenty internal app modules for Resend and more';
export const APPLICATION_UNIVERSAL_IDENTIFIER = '9426c4a3-7da4-4f61-bdb7-01e1276478b8';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'b115921d-7ca3-4a1f-94e3-7119174fef78';
@@ -0,0 +1,16 @@
import { defineRole } from 'twenty-sdk';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default function role`,
description: `${APP_DISPLAY_NAME} default function role`,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});
@@ -0,0 +1,329 @@
// App-level
export const RESEND_API_KEY_UNIVERSAL_IDENTIFIER =
'e5828892-33d5-4532-b796-551df48a07c0';
export const RESEND_WEBHOOK_SECRET_UNIVERSAL_IDENTIFIER =
'b291b241-bd84-4661-8e79-3dc7a63371dd';
// Logic functions
export const SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'7a3c841f-509e-46f0-b2f1-fb942b716ee3';
export const RESEND_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'049b7227-3e8b-444b-84aa-939a7e4ca440';
export const ON_RESEND_CONTACT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'656b85b4-71d0-477b-9741-4967d8d88ac9';
export const ON_RESEND_CONTACT_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'7b770cc2-6d31-4f1b-a7db-48d44cf6109b';
export const ON_RESEND_CONTACT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'7a2341e7-d96a-4ba1-b41d-699c73d61081';
export const ON_RESEND_SEGMENT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'ac5b424a-f51d-46c8-a95a-42589fb81676';
export const ON_RESEND_SEGMENT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'd5e5b6e1-e0d9-45f0-b3d9-6b96417e4ed0';
// Commands
export const SYNC_RESEND_DATA_COMMAND_UNIVERSAL_IDENTIFIER =
'70c1c7bc-b3f1-491a-90c8-2616facc7a9c';
// Front components
export const SYNC_RESEND_DATA_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'96e073b8-e331-40d1-9ec0-137bc921f486';
export const EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'7df0713e-3c16-4cbc-a25f-08cead363941';
export const TEMPLATE_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'58d79064-1e2a-4500-b8e7-c023cd9835fe';
// Objects
export const RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER =
'd59c00df-9715-46b1-bacd-580681435cef';
export const RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER =
'f06283b0-c7f9-4267-86de-e489f816cca1';
export const RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER =
'cb91a26f-131b-4db4-916b-7a308fcc29d7';
export const RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER =
'85ddb31f-0d1c-4619-bbaf-3d208c1b9fea';
export const RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER =
'bebc114f-a8f5-455d-8c6f-e33f20f66967';
// Object fields - Resend email
export const SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
'9e52c08b-d619-445a-b70b-121dc7676ff3';
export const FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER =
'c663f57e-0fe3-4066-91df-9eb001bab03a';
export const TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER =
'613e0400-709d-496e-9be4-b6eab8282d2e';
export const HTML_BODY_FIELD_UNIVERSAL_IDENTIFIER =
'd14c29e4-c971-47d3-a1f1-8f459b8d8719';
export const TEXT_BODY_FIELD_UNIVERSAL_IDENTIFIER =
'9d6edd43-903b-4e57-8bd0-8bbd9e914c30';
export const CC_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER =
'eff53046-039e-444e-9142-33d7cc354ad6';
export const BCC_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER =
'3db64fe7-99e9-4d44-81be-e0a55d32b211';
export const REPLY_TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER =
'acbea701-ca16-4db2-960f-7d8c8493e42d';
export const LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'c24331f3-43da-4143-9763-53ae01205300';
export const EMAIL_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER =
'374e4e3a-dc13-4159-813e-e5da789a97f0';
export const EMAIL_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'732e6009-67f7-4880-8161-a4310f4df690';
export const SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'3d1b8deb-f102-4c1b-929e-ed7b2647e64b';
export const TAGS_FIELD_UNIVERSAL_IDENTIFIER =
'd6406a2c-a8b6-416e-b351-e1e5ddb3d5fe';
export const EMAIL_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER =
'1b841d96-4318-48f5-aa0d-184d19e9af55';
// Object fields - Resend segment
export const SEGMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'92aad4eb-bfc7-4c3d-aa0a-fad5cf9cbda1';
export const SEGMENT_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER =
'5bce2a58-9eae-4903-b599-3a602e759373';
export const SEGMENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'4757fafa-3032-4f69-ba09-86d968239a8f';
export const SEGMENT_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER =
'8001f093-09c0-4a22-b7a3-9b1dcee47ce2';
// Object fields - Resend contact
export const CONTACT_EMAIL_FIELD_UNIVERSAL_IDENTIFIER =
'62ce21e5-a015-4f26-8c7a-3c141b6d7064';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
'e0fb9280-4588-4c2b-8996-bbd4c1d33f54';
export const UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER =
'41403b6f-b91e-4eb6-8875-8b5c21b0a6d3';
export const CONTACT_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER =
'c84c8703-33e1-4acb-8a7f-1d62647166d5';
export const CONTACT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'040bf210-36cf-49cb-8d83-0da9b864c900';
export const CONTACT_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER =
'd1874e6a-db94-4308-afc6-aeb4dc3a9eb2';
// Object fields - Resend template
export const TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'a045319f-483d-4625-86cd-653111dc8ac3';
export const TEMPLATE_ALIAS_FIELD_UNIVERSAL_IDENTIFIER =
'850a9d4a-df38-4af0-ba69-aaef090cafef';
export const TEMPLATE_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'f98b84c0-c8ef-4f9e-a72a-a53e2ef60fea';
export const TEMPLATE_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER =
'33a80d6c-e6ee-490e-b5ac-87b557bcdf50';
export const TEMPLATE_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
'3aff5a64-49fa-450c-b85d-4d4f210f6433';
export const TEMPLATE_REPLY_TO_FIELD_UNIVERSAL_IDENTIFIER =
'b05c1a87-4cea-4e01-8e6a-eff5929de149';
export const TEMPLATE_HTML_FIELD_UNIVERSAL_IDENTIFIER =
'9ced95c8-345d-438d-97dc-f52b99f4a9c3';
export const TEMPLATE_TEXT_FIELD_UNIVERSAL_IDENTIFIER =
'd7614d4c-ed00-4961-8765-7c8b0c9a320b';
export const TEMPLATE_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER =
'3883be35-ab0e-4bed-bc59-131683b9a0b2';
export const TEMPLATE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'72939761-7a57-4448-997a-68da6b4f60db';
export const TEMPLATE_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'cfe6a645-ee89-4117-8ace-fb9623e726cc';
export const TEMPLATE_PUBLISHED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'669a3ebb-d60b-41a3-81e9-53ab8b18f2b1';
// Object fields - Resend broadcast
export const BROADCAST_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'e3222f79-751d-4c9e-b33d-c5f2e6ee8304';
export const BROADCAST_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
'51025e6b-375a-45a0-89eb-773314702073';
export const BROADCAST_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER =
'fa189da3-3443-4984-b637-7fae9b30e2c5';
export const BROADCAST_REPLY_TO_FIELD_UNIVERSAL_IDENTIFIER =
'8674b4e8-1881-4001-a9df-8e9258b59d50';
export const PREVIEW_TEXT_FIELD_UNIVERSAL_IDENTIFIER =
'b8a008aa-1a44-4edb-b472-f392af635867';
export const BROADCAST_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'394135a4-6488-4ec3-97bc-d0514921ded8';
export const BROADCAST_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER =
'ee40a63f-6d32-496a-9f4d-1abfba386909';
export const BROADCAST_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'01855a84-450b-4de9-b172-e51555724370';
export const BROADCAST_SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'abbb4379-d0a5-432a-8c34-0e940242d687';
export const BROADCAST_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER =
'e1e281aa-7ba7-47a0-951d-0f6150a63099';
// Relation fields
export const SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER =
'8b335824-b19d-486e-b865-5761c795c971';
export const RESEND_BROADCASTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER =
'784601f0-e892-4164-90db-6903ab062c7e';
export const PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER =
'90353fa9-10fd-4cc8-b7b0-ef9a5d17ff8e';
export const RESEND_CONTACTS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER =
'134222c5-7760-4fd1-b89d-8a956f3068c5';
export const SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER =
'5fbd9063-fb4e-4584-820b-27918b6f95f0';
export const BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER =
'4cd54c84-1e3e-4d3f-a35d-b32a6e8e1137';
export const RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER =
'0b346627-4797-4cff-8a7c-1dc8f4580d6f';
export const CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER =
'46ffbf1c-0c25-4995-a209-291626b6fd2d';
export const RESEND_EMAILS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER =
'c6142228-e0e4-4143-9942-df4fce3ef231';
export const RESEND_CONTACTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER =
'ba14727c-19eb-4b08-844c-88bf33b8267d';
export const PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER =
'cb08e862-8114-480e-986b-8f50fe49de41';
export const RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER =
'd8b69019-ca10-4599-b099-ecfc891d6438';
// Views
export const RESEND_BROADCAST_VIEW_UNIVERSAL_IDENTIFIER =
'9875e352-4dd7-4296-9291-5de5864594b8';
export const RESEND_TEMPLATE_VIEW_UNIVERSAL_IDENTIFIER =
'c4a00e17-cec7-44f6-85c6-5826a0db8923';
export const RESEND_SEGMENT_VIEW_UNIVERSAL_IDENTIFIER =
'30683084-d0d4-4d25-bb5f-c6bcff9fc92a';
export const RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER =
'3d710924-5f47-4ac7-ba5e-28d3be9ee004';
export const RESEND_EMAIL_VIEW_UNIVERSAL_IDENTIFIER =
'43571c5b-f71d-47bf-95b3-8741b2201315';
// View fields - Resend broadcast view
export const RESEND_BROADCAST_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'7d2bd63b-d609-441e-97b7-8d72e1f64fcb';
export const RESEND_BROADCAST_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
'0cb2b32f-a7ca-4c6e-91af-705be37416dd';
export const RESEND_BROADCAST_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER =
'24261a77-9ddd-4fee-bcef-10308b6e438e';
export const RESEND_BROADCAST_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'ba957060-3879-4812-98ac-0199732c79d9';
export const RESEND_BROADCAST_VIEW_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER =
'7e78132b-7b33-468e-962d-d7bb1b3025d4';
export const RESEND_BROADCAST_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'fdcf945c-5d0f-42f2-94e6-765d5216bd2f';
export const RESEND_BROADCAST_VIEW_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER =
'4d7eb630-9f44-4090-a887-7fb08e0c49ed';
export const RESEND_BROADCAST_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER =
'e1726752-fed7-4e9b-b395-e47b60f3d56d';
// View fields - Resend template view
export const RESEND_TEMPLATE_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'01464797-cd0c-4686-b4fd-8e7f9d2e81d4';
export const RESEND_TEMPLATE_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
'c9c823c4-27f2-4805-a6ce-e22e97c5ac16';
export const RESEND_TEMPLATE_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER =
'36a274f8-4273-4b16-8a66-99c652ca308d';
export const RESEND_TEMPLATE_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'0474cc6b-3e28-4fb6-b0ba-3b22004eb9c4';
export const RESEND_TEMPLATE_VIEW_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'6bc34358-17b2-40c6-a0bc-3320ac972736';
export const RESEND_TEMPLATE_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'e3e90309-715c-4962-95d3-ddc46124fc93';
// View fields - Resend segment view
export const RESEND_SEGMENT_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'dc878169-b670-4aff-90b4-869849ec063e';
export const RESEND_SEGMENT_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'4898ae92-b94e-4bbf-850d-76011613fd64';
export const RESEND_SEGMENT_VIEW_CONTACTS_FIELD_UNIVERSAL_IDENTIFIER =
'cd223c54-d968-4830-b84f-ebefe8595842';
export const RESEND_SEGMENT_VIEW_BROADCASTS_FIELD_UNIVERSAL_IDENTIFIER =
'0adacbc6-f355-49bb-9350-b7202cf28b8c';
// View fields - Resend contact view
export const RESEND_CONTACT_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'f589ef0c-0f8b-4895-8d84-bd3b743f925f';
export const RESEND_CONTACT_VIEW_EMAIL_FIELD_UNIVERSAL_IDENTIFIER =
'3af5988f-de6b-46de-996b-439bd5acd51c';
export const RESEND_CONTACT_VIEW_UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER =
'efa38cf1-afcf-461a-9780-6d916e02256b';
export const RESEND_CONTACT_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'a48545bb-8cf1-4836-82fa-d0c7bc314a1b';
export const RESEND_CONTACT_VIEW_PERSON_FIELD_UNIVERSAL_IDENTIFIER =
'9fc1ab4b-fc2d-4c33-9de5-3cff7b0ea5ec';
export const RESEND_CONTACT_VIEW_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER =
'54949f7e-0454-45a5-9e30-8f16f75adeaa';
export const RESEND_CONTACT_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER =
'fbbe207d-dbda-4bda-935c-a6fa9ae30645';
// View fields - Resend email view
export const RESEND_EMAIL_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
'3a3ee801-6dfa-43b4-ba22-5c079a2d238d';
export const RESEND_EMAIL_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER =
'486e23dd-3d8b-42d8-86a8-146d5fd7aaf0';
export const RESEND_EMAIL_VIEW_LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'94e9b3ce-c858-4b15-96ca-69fedf834853';
export const RESEND_EMAIL_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'61dd7f9f-ce22-4fb4-aeee-f36154211cf8';
export const RESEND_EMAIL_VIEW_PERSON_FIELD_UNIVERSAL_IDENTIFIER =
'5b6cf4a7-f11f-49d4-bc05-8f1d6e0ad72a';
export const RESEND_EMAIL_VIEW_CONTACT_FIELD_UNIVERSAL_IDENTIFIER =
'abee6dcd-4754-45b2-ae77-d42bec85afad';
export const RESEND_EMAIL_VIEW_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER =
'8d7df695-5ffb-46b7-9a57-ac979988351f';
// Navigation menu items
export const RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'd87574b8-f09b-4963-bfa0-9e4afd433035';
export const RESEND_TEMPLATE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'c56546f1-e4f0-4d03-a480-d152e4b8b427';
export const RESEND_SEGMENT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'6aa7a107-3c7a-4099-9f8e-b1fa21e6f612';
export const RESEND_EMAIL_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'9d99110c-68ba-41c0-bd95-9b829ab84974';
export const RESEND_BROADCAST_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'cb11a15d-2116-4dd2-9f7d-88ffd2271620';
export const RESEND_CONTACT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'7cf30c1e-3f3c-4250-9141-a6584dc6697b';
// Page layouts - Resend template record page
export const RESEND_TEMPLATE_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'466cb8b7-70ec-4bb8-802d-6a6bdab4f647';
export const RESEND_TEMPLATE_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER =
'f8106373-a1a2-4bb8-86b5-0faf8ed52953';
export const RESEND_TEMPLATE_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER =
'fa3ad75b-b700-4450-8961-c4395a10ba9f';
export const RESEND_TEMPLATE_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER =
'56206825-2f86-40be-b311-f5ebc91e016b';
export const RESEND_TEMPLATE_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER =
'534b14b0-8ed1-414b-8a83-b631955c2058';
export const RESEND_TEMPLATE_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER =
'8b62586a-f976-4bae-8ec0-696369e8ec0f';
export const RESEND_TEMPLATE_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER =
'4099d844-7104-4fc0-8cb0-b1e0f88d9d33';
export const RESEND_TEMPLATE_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER =
'5dc2596a-28c8-4cd1-8500-6648b576f64a';
export const RESEND_TEMPLATE_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER =
'2fe143b4-aa7e-43d3-8a4e-becbe94e96a6';
export const RESEND_TEMPLATE_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER =
'd82f101a-93fc-44c6-afd9-26dd7a1ba99a';
export const RESEND_TEMPLATE_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER =
'57c3e4e2-d898-40f3-9273-2dbc07746dba';
export const RESEND_TEMPLATE_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER =
'f017f93c-c611-46ca-9bef-6c5038cf9609';
export const RESEND_TEMPLATE_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER =
'3059a217-5322-4744-9ed0-4757591bba1c';
// Page layouts - Resend email record page
export const RESEND_EMAIL_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'e481afa9-f100-4d88-959d-d4b3518583a2';
export const RESEND_EMAIL_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER =
'7273a427-5920-42c9-8f50-82bebf01f2bd';
export const RESEND_EMAIL_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER =
'4eebad57-eb42-4b69-85a6-2e7c1d365b94';
export const RESEND_EMAIL_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER =
'50299e13-3652-4059-ae1e-db512869d20b';
export const RESEND_EMAIL_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER =
'10bedce3-3e4f-4639-8500-e2035241f364';
export const RESEND_EMAIL_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER =
'e6c548d4-c371-4b4c-b59c-5b5f4fe50b11';
export const RESEND_EMAIL_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER =
'e45c80c7-d5b7-4109-aa5a-ab534d7c4ca2';
export const RESEND_EMAIL_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER =
'bce4141e-d115-43ea-94f0-5c45d0ab38b2';
export const RESEND_EMAIL_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER =
'9eab60e5-e305-482d-aec0-ab928a20c855';
export const RESEND_EMAIL_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER =
'45ada37c-1f26-4552-9726-308638304ddc';
export const RESEND_EMAIL_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER =
'a4edbea4-a5c3-4316-b6a7-c46fc97d36cd';
export const RESEND_EMAIL_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER =
'c2f8a3d1-7e49-4b56-9c0a-8d1e5f3b7a92';
export const RESEND_EMAIL_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER =
'b9d4e6f2-1a38-4c75-8b0d-3f7a9c2e5d14';
@@ -0,0 +1,39 @@
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
type HtmlPreviewProps = {
html: string | null | undefined;
};
export const HtmlPreview = ({ html }: HtmlPreviewProps) => {
if (!isDefined(html) || !isNonEmptyString(html)) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#999',
fontFamily: 'sans-serif',
fontSize: '14px',
}}
>
No HTML content available
</div>
);
}
return (
<iframe
srcDoc={html}
sandbox=""
title="Email HTML preview"
style={{
width: '100%',
height: '100%',
border: 'none',
}}
/>
);
};
@@ -0,0 +1,55 @@
import { isDefined } from 'twenty-shared/utils';
import { HtmlPreview } from 'src/modules/resend/html-viewer/components/HtmlPreview';
import { useRecordHtml } from 'src/modules/resend/html-viewer/hooks/useRecordHtml';
type RecordHtmlViewerProps = {
objectName: string;
loadingText: string;
};
export const RecordHtmlViewer = ({
objectName,
loadingText,
}: RecordHtmlViewerProps) => {
const { html, loading, error } = useRecordHtml(objectName);
if (loading) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#999',
fontFamily: 'sans-serif',
fontSize: '14px',
}}
>
{loadingText}
</div>
);
}
if (isDefined(error)) {
return (
<div
style={{
padding: '16px',
color: '#999',
fontFamily: 'sans-serif',
fontSize: '13px',
}}
>
<div>{error}</div>
</div>
);
}
return (
<div style={{ width: '100%', height: '100%' }}>
<HtmlPreview html={html} />
</div>
);
};
@@ -0,0 +1,15 @@
import { defineFrontComponent } from 'twenty-sdk';
import { RecordHtmlViewer } from 'src/modules/resend/html-viewer/components/RecordHtmlViewer';
import { EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
const EmailHtmlViewer = () => (
<RecordHtmlViewer objectName="resendEmail" loadingText="Loading email..." />
);
export default defineFrontComponent({
universalIdentifier: EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Email HTML Viewer',
description: 'Renders the HTML body of a Resend email',
component: EmailHtmlViewer,
});
@@ -0,0 +1,19 @@
import { defineFrontComponent } from 'twenty-sdk';
import { RecordHtmlViewer } from 'src/modules/resend/html-viewer/components/RecordHtmlViewer';
import { TEMPLATE_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
const TemplateHtmlViewer = () => (
<RecordHtmlViewer
objectName="resendTemplate"
loadingText="Loading template..."
/>
);
export default defineFrontComponent({
universalIdentifier:
TEMPLATE_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Template HTML Viewer',
description: 'Renders the HTML body of a Resend email template',
component: TemplateHtmlViewer,
});
@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { useRecordId } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
type RecordHtmlState = {
html: string | null;
loading: boolean;
error: string | null;
};
export const useRecordHtml = (objectName: string): RecordHtmlState => {
const recordId = useRecordId();
const [state, setState] = useState<RecordHtmlState>({
html: null,
loading: true,
error: null,
});
useEffect(() => {
if (!isDefined(recordId)) {
setState({ html: null, loading: false, error: 'No record ID' });
return;
}
setState({ html: null, loading: true, error: null });
new CoreApiClient()
.query({
[objectName]: {
__args: { filter: { id: { eq: recordId } } },
htmlBody: true,
},
})
.then((result) => {
const record = (result as Record<string, unknown>)[objectName] as
| { htmlBody?: string | null }
| undefined;
setState(
isDefined(record)
? { html: record.htmlBody ?? null, loading: false, error: null }
: { html: null, loading: false, error: 'Record not found' },
);
})
.catch((fetchError: unknown) => {
setState({
html: null,
loading: false,
error:
fetchError instanceof Error
? fetchError.message
: String(fetchError),
});
});
}, [recordId, objectName]);
return state;
};
@@ -0,0 +1,93 @@
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import {
Command,
defineFrontComponent,
enqueueSnackbar,
updateProgress,
} from 'twenty-sdk';
import { isDefined } from 'twenty-shared/utils';
import {
SYNC_RESEND_DATA_COMMAND_UNIVERSAL_IDENTIFIER,
SYNC_RESEND_DATA_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
const execute = async () => {
await updateProgress(0.1);
const metadataClient = new MetadataApiClient();
const { findManyLogicFunctions } = await metadataClient.query({
findManyLogicFunctions: {
id: true,
universalIdentifier: true,
},
});
const syncFunction = findManyLogicFunctions.find(
(fn) =>
fn.universalIdentifier ===
SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
);
if (!isDefined(syncFunction)) {
throw new Error('Sync logic function not found');
}
await updateProgress(0.3);
const { executeOneLogicFunction } = await metadataClient.mutation({
executeOneLogicFunction: {
__args: {
input: {
id: syncFunction.id,
payload: {} as Record<string, never>,
},
},
status: true,
error: true,
},
});
if (executeOneLogicFunction.status !== 'SUCCESS') {
const rawMessage =
typeof executeOneLogicFunction.error?.errorMessage === 'string'
? executeOneLogicFunction.error.errorMessage
: 'Sync logic function execution failed';
const isRateLimit =
rawMessage.toLowerCase().includes('rate_limit') ||
rawMessage.toLowerCase().includes('rate limit');
throw new Error(
isRateLimit
? 'Sync failed: Resend API rate limit exceeded. Please try again later.'
: `Sync failed: ${rawMessage}`,
);
}
await updateProgress(1);
await enqueueSnackbar({
message: 'Resend data sync completed',
variant: 'success',
});
};
const SyncResendData = () => <Command execute={execute} />;
export default defineFrontComponent({
universalIdentifier: SYNC_RESEND_DATA_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Sync Resend Data',
description: 'Triggers a manual sync of all Resend data',
isHeadless: true,
component: SyncResendData,
command: {
universalIdentifier: SYNC_RESEND_DATA_COMMAND_UNIVERSAL_IDENTIFIER,
label: 'Sync Resend data',
icon: 'IconRefresh',
isPinned: false,
availabilityType: 'GLOBAL',
},
});
@@ -0,0 +1,24 @@
import {
BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'broadcast',
label: 'Broadcast',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'broadcastId',
},
icon: 'IconSpeakerphone',
});
@@ -0,0 +1,24 @@
import {
CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'contact',
label: 'Contact',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'contactId',
},
icon: 'IconAddressBook',
});
@@ -0,0 +1,28 @@
import {
PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACTS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'person',
label: 'Person',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
RESEND_CONTACTS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'personId',
},
icon: 'IconUser',
});
@@ -0,0 +1,28 @@
import {
PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'person',
label: 'Person',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
RESEND_EMAILS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'personId',
},
icon: 'IconUser',
});
@@ -0,0 +1,23 @@
import {
RESEND_BROADCASTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: RESEND_BROADCASTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'resendBroadcasts',
label: 'Broadcasts',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
icon: 'IconSpeakerphone',
});
@@ -0,0 +1,28 @@
import {
PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACTS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: RESEND_CONTACTS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'resendContacts',
label: 'Resend Contacts',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
icon: 'IconAddressBook',
});
@@ -0,0 +1,23 @@
import {
RESEND_CONTACTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: RESEND_CONTACTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'resendContacts',
label: 'Contacts',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
icon: 'IconAddressBook',
});
@@ -0,0 +1,23 @@
import {
BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'resendEmails',
label: 'Emails',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
icon: 'IconMail',
});
@@ -0,0 +1,23 @@
import {
CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'resendEmails',
label: 'Emails',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
icon: 'IconMail',
});
@@ -0,0 +1,28 @@
import {
PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: RESEND_EMAILS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'resendEmails',
label: 'Resend Emails',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
icon: 'IconMail',
});
@@ -0,0 +1,24 @@
import {
RESEND_BROADCASTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'segment',
label: 'Segment',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
RESEND_BROADCASTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'segmentId',
},
icon: 'IconUsersGroup',
});
@@ -0,0 +1,24 @@
import {
RESEND_CONTACTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'segment',
label: 'Segment',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
RESEND_CONTACTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'segmentId',
},
icon: 'IconUsersGroup',
});
@@ -0,0 +1,19 @@
import {
RESEND_BROADCAST_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_UNIVERSAL_IDENTIFIER,
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
export default defineNavigationMenuItem({
universalIdentifier:
RESEND_BROADCAST_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Broadcasts',
icon: 'IconSpeakerphone',
position: 2,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: RESEND_BROADCAST_VIEW_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier:
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,18 @@
import {
RESEND_CONTACT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER,
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
export default defineNavigationMenuItem({
universalIdentifier: RESEND_CONTACT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Contacts',
icon: 'IconAddressBook',
position: 1,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier:
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,18 @@
import {
RESEND_EMAIL_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_UNIVERSAL_IDENTIFIER,
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
export default defineNavigationMenuItem({
universalIdentifier: RESEND_EMAIL_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Emails',
icon: 'IconMail',
position: 0,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: RESEND_EMAIL_VIEW_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier:
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,11 @@
import { RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
export default defineNavigationMenuItem({
universalIdentifier: RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Resend',
icon: 'IconMail',
position: 0,
type: NavigationMenuItemType.FOLDER,
});
@@ -0,0 +1,19 @@
import {
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
export default defineNavigationMenuItem({
universalIdentifier:
RESEND_SEGMENT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Segments',
icon: 'IconUsersGroup',
position: 4,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: RESEND_SEGMENT_VIEW_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier:
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,19 @@
import {
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
export default defineNavigationMenuItem({
universalIdentifier:
RESEND_TEMPLATE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Templates',
icon: 'IconTemplate',
position: 3,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: RESEND_TEMPLATE_VIEW_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier:
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,138 @@
import {
BROADCAST_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_REPLY_TO_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
PREVIEW_TEXT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineObject, FieldType } from 'twenty-sdk';
export default defineObject({
universalIdentifier: RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'resendBroadcast',
namePlural: 'resendBroadcasts',
labelSingular: 'Resend broadcast',
labelPlural: 'Resend broadcasts',
description: 'A broadcast email campaign from Resend',
icon: 'IconSpeakerphone',
labelIdentifierFieldMetadataUniversalIdentifier:
BROADCAST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: BROADCAST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
description: 'Name of the broadcast',
icon: 'IconAbc',
},
{
universalIdentifier: BROADCAST_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'subject',
label: 'Subject',
description: 'Email subject line',
icon: 'IconMail',
},
{
universalIdentifier: BROADCAST_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'fromAddress',
label: 'From',
description: 'Sender email address',
icon: 'IconMailForward',
},
{
universalIdentifier: BROADCAST_REPLY_TO_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'replyTo',
label: 'Reply to',
description: 'Reply-to email address',
icon: 'IconMailReply',
},
{
universalIdentifier: PREVIEW_TEXT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'previewText',
label: 'Preview text',
description: 'Preview text shown in email clients',
icon: 'IconEye',
},
{
universalIdentifier: BROADCAST_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
name: 'status',
label: 'Status',
description: 'Current broadcast status',
icon: 'IconStatusChange',
options: [
{
id: 'c090f598-1e30-4cc2-9686-7413b926760e',
value: 'DRAFT',
label: 'Draft',
position: 0,
color: 'gray',
},
{
id: 'd8428e6a-8a2b-4307-89d8-f8095f8f425c',
value: 'QUEUED',
label: 'Queued',
position: 1,
color: 'blue',
},
{
id: '13c38a05-511b-4bf3-86c1-824d084107c8',
value: 'SENDING',
label: 'Sending',
position: 2,
color: 'yellow',
},
{
id: '79150897-a5c0-4695-9a59-cd40db44b066',
value: 'SENT',
label: 'Sent',
position: 3,
color: 'green',
},
],
},
{
universalIdentifier: BROADCAST_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'resendId',
label: 'Resend ID',
description: 'Resend broadcast identifier',
icon: 'IconHash',
},
{
universalIdentifier: BROADCAST_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'createdAt',
label: 'Created at',
description: 'When the broadcast was created',
icon: 'IconCalendar',
},
{
universalIdentifier: BROADCAST_SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'scheduledAt',
label: 'Scheduled at',
description: 'When the broadcast is scheduled to be sent',
icon: 'IconCalendarEvent',
},
{
universalIdentifier: BROADCAST_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'sentAt',
label: 'Sent at',
description: 'When the broadcast was sent',
icon: 'IconSend',
},
],
});
@@ -0,0 +1,73 @@
import {
CONTACT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
CONTACT_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
CONTACT_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER,
CONTACT_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
NAME_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineObject, FieldType } from 'twenty-sdk';
export default defineObject({
universalIdentifier: RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'resendContact',
namePlural: 'resendContacts',
labelSingular: 'Resend contact',
labelPlural: 'Resend contacts',
description: 'A contact from Resend',
icon: 'IconAddressBook',
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: CONTACT_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'email',
label: 'Email',
description: 'Contact email address',
icon: 'IconMail',
},
{
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.FULL_NAME,
name: 'name',
label: 'Name',
description: 'Name of the contact',
icon: 'IconUser',
},
{
universalIdentifier: UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.BOOLEAN,
name: 'unsubscribed',
label: 'Unsubscribed',
description: 'Whether the contact has unsubscribed',
icon: 'IconMailOff',
},
{
universalIdentifier: CONTACT_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'resendId',
label: 'Resend ID',
description: 'Resend contact identifier',
icon: 'IconHash',
},
{
universalIdentifier: CONTACT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'createdAt',
label: 'Created at',
description: 'When the contact was created',
icon: 'IconCalendar',
},
{
universalIdentifier:
CONTACT_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'lastSyncedFromResend',
label: 'Last synced from Resend',
description: 'Timestamp of last inbound sync (used to prevent echo loops)',
icon: 'IconClock',
},
],
});
@@ -0,0 +1,238 @@
import {
BCC_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
CC_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
EMAIL_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
EMAIL_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER,
EMAIL_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
HTML_BODY_FIELD_UNIVERSAL_IDENTIFIER,
LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
REPLY_TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER,
SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
TAGS_FIELD_UNIVERSAL_IDENTIFIER,
TEXT_BODY_FIELD_UNIVERSAL_IDENTIFIER,
TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineObject, FieldType } from 'twenty-sdk';
export default defineObject({
universalIdentifier: RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'resendEmail',
namePlural: 'resendEmails',
labelSingular: 'Resend email',
labelPlural: 'Resend emails',
description: 'An email sent via Resend',
icon: 'IconMail',
labelIdentifierFieldMetadataUniversalIdentifier:
SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'subject',
label: 'Subject',
description: 'Email subject line',
icon: 'IconAbc',
},
{
universalIdentifier: FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'fromAddress',
label: 'From',
description: 'Sender email address',
icon: 'IconMailForward',
},
{
universalIdentifier: TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'toAddresses',
label: 'To',
description: 'Recipient email addresses',
icon: 'IconUsers',
},
{
universalIdentifier: HTML_BODY_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'htmlBody',
label: 'HTML body',
description: 'HTML content of the email',
icon: 'IconFileText',
},
{
universalIdentifier: TEXT_BODY_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'textBody',
label: 'Text body',
description: 'Plain text content of the email',
icon: 'IconAlignLeft',
},
{
universalIdentifier: CC_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'ccAddresses',
label: 'CC',
description: 'Carbon copy email addresses',
icon: 'IconCopy',
},
{
universalIdentifier: BCC_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'bccAddresses',
label: 'BCC',
description: 'Blind carbon copy email addresses',
icon: 'IconEyeOff',
},
{
universalIdentifier: REPLY_TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'replyToAddresses',
label: 'Reply to',
description: 'Reply-to email addresses',
icon: 'IconMailReply',
},
{
universalIdentifier: LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
name: 'lastEvent',
label: 'Last event',
description: 'Most recent delivery status',
icon: 'IconStatusChange',
options: [
{
id: '1b69e2c9-81f0-4f14-8361-dda018c1c343',
value: 'SENT',
label: 'Sent',
position: 0,
color: 'blue',
},
{
id: '9964e796-5b4a-4631-a0bd-7ab1848ae207',
value: 'DELIVERED',
label: 'Delivered',
position: 1,
color: 'green',
},
{
id: 'e4ae61fe-2ea8-4ee3-8bb5-0c8f5c6e8081',
value: 'DELIVERY_DELAYED',
label: 'Delivery Delayed',
position: 2,
color: 'yellow',
},
{
id: 'ea6d5170-c522-4559-a51e-f3f81435c632',
value: 'COMPLAINED',
label: 'Complained',
position: 3,
color: 'orange',
},
{
id: 'eeafaa07-3b8a-4b89-b1d6-f4b113b0276f',
value: 'BOUNCED',
label: 'Bounced',
position: 4,
color: 'red',
},
{
id: 'fa6b6add-7b95-4bb5-8f5f-a532430ac201',
value: 'OPENED',
label: 'Opened',
position: 5,
color: 'turquoise',
},
{
id: 'c4529bad-d911-4f1f-aac3-620852a09eae',
value: 'CLICKED',
label: 'Clicked',
position: 6,
color: 'sky',
},
{
id: '05bd5800-eade-4500-92f6-97d91afabb54',
value: 'SCHEDULED',
label: 'Scheduled',
position: 7,
color: 'gray',
},
{
id: 'd71a6236-2e6e-4c4f-9cae-b806c2724302',
value: 'QUEUED',
label: 'Queued',
position: 8,
color: 'gray',
},
{
id: 'd6674c98-eb98-488a-860d-fbf8b4a073c9',
value: 'FAILED',
label: 'Failed',
position: 9,
color: 'red',
},
{
id: '9e042a8d-3f33-4915-938e-978065712065',
value: 'CANCELED',
label: 'Canceled',
position: 10,
color: 'gray',
},
{
id: '0c837074-d7ce-43b1-ac08-553e6ee10ece',
value: 'RECEIVED',
label: 'Received',
position: 11,
color: 'blue',
},
{
id: '9bfec759-a242-4d85-b124-c4a83949b8da',
value: 'SUPPRESSED',
label: 'Suppressed',
position: 12,
color: 'red',
},
],
},
{
universalIdentifier: EMAIL_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'resendId',
label: 'Resend ID',
description: 'Resend email identifier',
icon: 'IconHash',
},
{
universalIdentifier: EMAIL_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'createdAt',
label: 'Created at',
description: 'When the email was created',
icon: 'IconCalendar',
},
{
universalIdentifier: SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'scheduledAt',
label: 'Scheduled at',
description: 'When the email is scheduled to be sent',
icon: 'IconCalendarEvent',
},
{
universalIdentifier: TAGS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.RAW_JSON,
name: 'tags',
label: 'Tags',
description: 'Custom tags attached to the email',
icon: 'IconTag',
},
{
universalIdentifier:
EMAIL_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'lastSyncedFromResend',
label: 'Last synced from Resend',
description: 'Timestamp of last inbound sync (used to prevent echo loops)',
icon: 'IconClock',
},
],
});
@@ -0,0 +1,55 @@
import {
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
SEGMENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
SEGMENT_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER,
SEGMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
SEGMENT_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineObject, FieldType } from 'twenty-sdk';
export default defineObject({
universalIdentifier: RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'resendSegment',
namePlural: 'resendSegments',
labelSingular: 'Resend segment',
labelPlural: 'Resend segments',
description: 'A contact segment from Resend',
icon: 'IconUsersGroup',
labelIdentifierFieldMetadataUniversalIdentifier:
SEGMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: SEGMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
description: 'Name of the segment',
icon: 'IconAbc',
},
{
universalIdentifier: SEGMENT_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'resendId',
label: 'Resend ID',
description: 'Resend segment identifier',
icon: 'IconHash',
},
{
universalIdentifier: SEGMENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'createdAt',
label: 'Created at',
description: 'When the segment was created',
icon: 'IconCalendar',
},
{
universalIdentifier:
SEGMENT_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'lastSyncedFromResend',
label: 'Last synced from Resend',
description: 'Timestamp of last inbound sync (used to prevent echo loops)',
icon: 'IconClock',
},
],
});
@@ -0,0 +1,142 @@
import {
RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
TEMPLATE_ALIAS_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_HTML_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_PUBLISHED_AT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_REPLY_TO_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_TEXT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineObject, FieldType } from 'twenty-sdk';
export default defineObject({
universalIdentifier: RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'resendTemplate',
namePlural: 'resendTemplates',
labelSingular: 'Resend template',
labelPlural: 'Resend templates',
description: 'An email template from Resend',
icon: 'IconTemplate',
labelIdentifierFieldMetadataUniversalIdentifier:
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
description: 'Name of the template',
icon: 'IconAbc',
},
{
universalIdentifier: TEMPLATE_ALIAS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'alias',
label: 'Alias',
description: 'Alternative identifier for the template',
icon: 'IconTag',
},
{
universalIdentifier: TEMPLATE_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
name: 'status',
label: 'Status',
description: 'Publication status of the template',
icon: 'IconStatusChange',
options: [
{
id: '3047dfdd-5424-44bc-bead-f2e2a84f363f',
value: 'DRAFT',
label: 'Draft',
position: 0,
color: 'gray',
},
{
id: '146cf14d-e276-4477-86f5-4114de97bccb',
value: 'PUBLISHED',
label: 'Published',
position: 1,
color: 'green',
},
],
},
{
universalIdentifier: TEMPLATE_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'fromAddress',
label: 'From',
description: 'Sender email address',
icon: 'IconMailForward',
},
{
universalIdentifier: TEMPLATE_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'subject',
label: 'Subject',
description: 'Email subject line',
icon: 'IconMail',
},
{
universalIdentifier: TEMPLATE_REPLY_TO_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'replyTo',
label: 'Reply to',
description: 'Reply-to email address',
icon: 'IconMailReply',
},
{
universalIdentifier: TEMPLATE_HTML_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'htmlBody',
label: 'HTML body',
description: 'HTML content of the template',
icon: 'IconFileText',
},
{
universalIdentifier: TEMPLATE_TEXT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'textBody',
label: 'Text body',
description: 'Plain text content of the template',
icon: 'IconAlignLeft',
},
{
universalIdentifier: TEMPLATE_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'resendId',
label: 'Resend ID',
description: 'Resend template identifier',
icon: 'IconHash',
},
{
universalIdentifier: TEMPLATE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'createdAt',
label: 'Created at',
description: 'When the template was created',
icon: 'IconCalendar',
},
{
universalIdentifier: TEMPLATE_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'resendUpdatedAt',
label: 'Resend updated at',
description: 'When the template was last updated in Resend',
icon: 'IconCalendarClock',
},
{
universalIdentifier: TEMPLATE_PUBLISHED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'publishedAt',
label: 'Published at',
description: 'When the template was published',
icon: 'IconCalendarEvent',
},
],
});
@@ -0,0 +1,142 @@
import {
EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
export default definePageLayout({
universalIdentifier: RESEND_EMAIL_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'Resend Email Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: RESEND_EMAIL_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER,
title: 'Home',
position: 50,
icon: 'IconHome',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Fields',
type: 'FIELDS',
configuration: {
configurationType: 'FIELDS',
},
},
],
},
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER,
title: 'Preview',
position: 75,
icon: 'IconEye',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Email Preview',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER,
title: 'Timeline',
position: 100,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Timeline',
type: 'TIMELINE',
configuration: {
configurationType: 'TIMELINE',
},
},
],
},
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER,
title: 'Tasks',
position: 200,
icon: 'IconCheckbox',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Tasks',
type: 'TASKS',
configuration: {
configurationType: 'TASKS',
},
},
],
},
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER,
title: 'Notes',
position: 300,
icon: 'IconNotes',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Notes',
type: 'NOTES',
configuration: {
configurationType: 'NOTES',
},
},
],
},
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER,
title: 'Files',
position: 400,
icon: 'IconPaperclip',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Files',
type: 'FILES',
configuration: {
configurationType: 'FILES',
},
},
],
},
],
});
@@ -0,0 +1,142 @@
import {
RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER,
TEMPLATE_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
export default definePageLayout({
universalIdentifier: RESEND_TEMPLATE_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'Resend Template Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: RESEND_TEMPLATE_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER,
title: 'Home',
position: 50,
icon: 'IconHome',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Fields',
type: 'FIELDS',
configuration: {
configurationType: 'FIELDS',
},
},
],
},
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER,
title: 'Preview',
position: 75,
icon: 'IconEye',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Template Preview',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
TEMPLATE_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER,
title: 'Timeline',
position: 100,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Timeline',
type: 'TIMELINE',
configuration: {
configurationType: 'TIMELINE',
},
},
],
},
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER,
title: 'Tasks',
position: 200,
icon: 'IconCheckbox',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Tasks',
type: 'TASKS',
configuration: {
configurationType: 'TASKS',
},
},
],
},
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER,
title: 'Notes',
position: 300,
icon: 'IconNotes',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Notes',
type: 'NOTES',
configuration: {
configurationType: 'NOTES',
},
},
],
},
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER,
title: 'Files',
position: 400,
icon: 'IconPaperclip',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Files',
type: 'FILES',
configuration: {
configurationType: 'FILES',
},
},
],
},
],
});
@@ -0,0 +1,102 @@
import {
BROADCAST_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineView } from 'twenty-sdk';
export default defineView({
universalIdentifier: RESEND_BROADCAST_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Resend broadcasts',
objectUniversalIdentifier: RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconSpeakerphone',
position: 0,
fields: [
{
universalIdentifier: RESEND_BROADCAST_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
BROADCAST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 0,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
BROADCAST_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 1,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
BROADCAST_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 2,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
BROADCAST_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 3,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
BROADCAST_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 4,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
BROADCAST_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 5,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 6,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 7,
},
],
});
@@ -0,0 +1,89 @@
import {
CONTACT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
CONTACT_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
NAME_FIELD_UNIVERSAL_IDENTIFIER,
PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineView } from 'twenty-sdk';
export default defineView({
universalIdentifier: RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Resend contacts',
objectUniversalIdentifier: RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconAddressBook',
position: 0,
fields: [
{
universalIdentifier: RESEND_CONTACT_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 0,
},
{
universalIdentifier: RESEND_CONTACT_VIEW_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
CONTACT_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 1,
},
{
universalIdentifier:
RESEND_CONTACT_VIEW_UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 2,
},
{
universalIdentifier:
RESEND_CONTACT_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
CONTACT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 3,
},
{
universalIdentifier:
RESEND_CONTACT_VIEW_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 4,
},
{
universalIdentifier:
RESEND_CONTACT_VIEW_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 5,
},
{
universalIdentifier:
RESEND_CONTACT_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 6,
},
],
});
@@ -0,0 +1,91 @@
import {
BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
EMAIL_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_UNIVERSAL_IDENTIFIER,
SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineView } from 'twenty-sdk';
export default defineView({
universalIdentifier: RESEND_EMAIL_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Resend emails',
objectUniversalIdentifier: RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconMail',
position: 0,
fields: [
{
universalIdentifier:
RESEND_EMAIL_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 0,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 1,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 2,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
EMAIL_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 3,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 4,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 5,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 6,
},
],
});
@@ -0,0 +1,58 @@
import {
RESEND_BROADCASTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_VIEW_BROADCASTS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_VIEW_CONTACTS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_VIEW_UNIVERSAL_IDENTIFIER,
SEGMENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
SEGMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineView } from 'twenty-sdk';
export default defineView({
universalIdentifier: RESEND_SEGMENT_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Resend segments',
objectUniversalIdentifier: RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconUsersGroup',
position: 0,
fields: [
{
universalIdentifier: RESEND_SEGMENT_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
SEGMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 0,
},
{
universalIdentifier:
RESEND_SEGMENT_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
SEGMENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 1,
},
{
universalIdentifier:
RESEND_SEGMENT_VIEW_CONTACTS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
RESEND_CONTACTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 2,
},
{
universalIdentifier:
RESEND_SEGMENT_VIEW_BROADCASTS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
RESEND_BROADCASTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 3,
},
],
});
@@ -0,0 +1,80 @@
import {
RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineView } from 'twenty-sdk';
export default defineView({
universalIdentifier: RESEND_TEMPLATE_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Resend templates',
objectUniversalIdentifier: RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconTemplate',
position: 0,
fields: [
{
universalIdentifier: RESEND_TEMPLATE_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 0,
},
{
universalIdentifier:
RESEND_TEMPLATE_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TEMPLATE_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 1,
},
{
universalIdentifier:
RESEND_TEMPLATE_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TEMPLATE_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 2,
},
{
universalIdentifier:
RESEND_TEMPLATE_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TEMPLATE_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 3,
},
{
universalIdentifier:
RESEND_TEMPLATE_VIEW_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TEMPLATE_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 4,
},
{
universalIdentifier:
RESEND_TEMPLATE_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TEMPLATE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 5,
},
],
});
@@ -0,0 +1,4 @@
export type EmailsField = {
primaryEmail: string;
additionalEmails: string[] | null;
};
@@ -0,0 +1,10 @@
import type { EmailsField, FullNameField } from 'twenty-sdk';
export type ResendContactRecord = {
id: string;
resendId?: string;
email?: EmailsField;
name?: FullNameField;
unsubscribed?: boolean;
lastSyncedFromResend?: string;
};
@@ -0,0 +1,5 @@
export type ResendSegmentRecord = {
id: string;
resendId?: string;
name?: string;
};
@@ -0,0 +1,2 @@
export const capitalize = (str: string): string =>
str.charAt(0).toUpperCase() + str.slice(1);
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
fetchAllPaginated,
type ResendListFn,
} from 'src/modules/resend/shared/utils/fetch-all-paginated';
type Item = { id: string };
type ListResponse = Awaited<ReturnType<ResendListFn<Item>>>;
const page = (ids: string[], hasMore: boolean): ListResponse => ({
data: { data: ids.map((id) => ({ id })), has_more: hasMore },
error: null,
});
const makeListFn = (
pages: ListResponse[],
): { fn: ResendListFn<Item>; calls: { limit: number; after?: string }[] } => {
const calls: { limit: number; after?: string }[] = [];
let index = 0;
const fn: ResendListFn<Item> = async (params) => {
calls.push(params);
const result = pages[index] ?? page([], false);
index++;
return result;
};
return { fn, calls };
};
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('fetchAllPaginated', () => {
it('returns items from a single page when has_more is false', async () => {
const { fn, calls } = makeListFn([page(['a', 'b', 'c'], false)]);
const result = await fetchAllPaginated(fn);
expect(result.map((item) => item.id)).toEqual(['a', 'b', 'c']);
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual({ limit: 100 });
});
it('follows cursor across pages and concatenates results', async () => {
const { fn, calls } = makeListFn([
page(['a', 'b'], true),
page(['c', 'd'], true),
page(['e'], false),
]);
const result = await fetchAllPaginated(fn);
expect(result.map((item) => item.id)).toEqual(['a', 'b', 'c', 'd', 'e']);
expect(calls).toEqual([
{ limit: 100 },
{ limit: 100, after: 'b' },
{ limit: 100, after: 'd' },
]);
});
it('stops when a page returns an empty array', async () => {
const { fn, calls } = makeListFn([
page(['a'], true),
page([], true),
page(['b'], false),
]);
const result = await fetchAllPaginated(fn);
expect(result.map((item) => item.id)).toEqual(['a']);
expect(calls).toHaveLength(2);
});
it('stops when data is null', async () => {
const { fn } = makeListFn([
page(['a'], true),
{ data: null, error: null },
]);
const result = await fetchAllPaginated(fn);
expect(result.map((item) => item.id)).toEqual(['a']);
});
it('includes label and cursor in the error message when listFn returns an error', async () => {
const fn: ResendListFn<Item> = async (params) => {
if (params.after === 'a') {
return { data: null, error: { code: 'boom' } };
}
return page(['a'], true);
};
await expect(fetchAllPaginated(fn, 'broadcasts')).rejects.toThrow(
/Resend list\[broadcasts\] failed at cursor=a: .*boom/,
);
});
it('throws when the cursor does not advance', async () => {
const { fn } = makeListFn([page(['a'], true), page(['a'], true)]);
await expect(fetchAllPaginated(fn, 'segments')).rejects.toThrow(
/Resend list\[segments\] cursor stuck at a/,
);
});
});
@@ -0,0 +1,53 @@
import { isDefined } from 'twenty-shared/utils';
import { withRateLimitRetry } from 'src/modules/resend/shared/utils/with-rate-limit-retry';
const PAGE_SIZE = 100;
export type ResendListFn<T> = (params: {
limit: number;
after?: string;
}) => Promise<{
data: { data: T[]; has_more: boolean } | null;
error: unknown;
}>;
export const fetchAllPaginated = async <T extends { id: string }>(
listFn: ResendListFn<T>,
label = 'items',
): Promise<T[]> => {
const items: T[] = [];
let cursor: string | undefined;
while (true) {
const params = {
limit: PAGE_SIZE,
...(isDefined(cursor) && { after: cursor }),
};
const response = await withRateLimitRetry(() => listFn(params));
if (isDefined(response.error)) {
throw new Error(
`Resend list[${label}] failed at cursor=${cursor ?? 'start'}: ${JSON.stringify(response.error)}`,
);
}
const page = response.data;
if (!isDefined(page) || page.data.length === 0) break;
items.push(...page.data);
if (!page.has_more) break;
const nextCursor = page.data[page.data.length - 1].id;
if (nextCursor === cursor) {
throw new Error(`Resend list[${label}] cursor stuck at ${nextCursor}`);
}
cursor = nextCursor;
}
return items;
};
@@ -0,0 +1,62 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
type PersonName = {
firstName?: string;
lastName?: string;
};
type PeopleConnection = {
edges: Array<{ node: { id: string } }>;
};
export const findOrCreatePerson = async (
client: CoreApiClient,
email: string | undefined | null,
name?: PersonName,
): Promise<string | undefined> => {
if (!isNonEmptyString(email)) {
return undefined;
}
const { people } = await client.query({
people: {
edges: { node: { id: true } },
__args: {
filter: {
emails: {
primaryEmail: { eq: email },
},
},
first: 1,
},
},
});
const existingPersonId = (people as PeopleConnection | undefined)?.edges[0]
?.node?.id;
if (isDefined(existingPersonId)) {
return existingPersonId;
}
const { createPerson } = await client.mutation({
createPerson: {
__args: {
data: {
name: {
firstName: name?.firstName ?? '',
lastName: name?.lastName ?? '',
},
emails: {
primaryEmail: email,
},
},
},
id: true,
},
});
return (createPerson as { id: string } | undefined)?.id;
};
@@ -0,0 +1,34 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
type ConnectionResult = {
edges: Array<{ node: { id: string; resendId: string } }>;
};
export const findRecordByResendId = async (
client: CoreApiClient,
objectNamePlural: string,
resendId: string,
): Promise<string | undefined> => {
const result = await client.query({
[objectNamePlural]: {
__args: {
filter: {
resendId: { eq: resendId },
},
first: 1,
},
edges: {
node: {
id: true,
resendId: true,
},
},
},
});
const connection = (result as Record<string, unknown>)[objectNamePlural] as
| ConnectionResult
| undefined;
return connection?.edges[0]?.node.id;
};
@@ -0,0 +1,2 @@
export const getErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
@@ -0,0 +1,12 @@
import { Resend } from 'resend';
import { isDefined } from 'twenty-shared/utils';
export const getResendClient = (): Resend => {
const apiKey = process.env.RESEND_API_KEY;
if (!isDefined(apiKey)) {
throw new Error('RESEND_API_KEY environment variable is not set');
}
return new Resend(apiKey);
};
@@ -0,0 +1,45 @@
import { isDefined } from 'twenty-shared/utils';
export type LastEvent =
| 'SENT'
| 'DELIVERED'
| 'DELIVERY_DELAYED'
| 'COMPLAINED'
| 'BOUNCED'
| 'OPENED'
| 'CLICKED'
| 'SCHEDULED'
| 'QUEUED'
| 'FAILED'
| 'CANCELED'
| 'RECEIVED'
| 'SUPPRESSED';
const EVENT_TO_LAST_EVENT: Record<string, LastEvent> = {
sent: 'SENT',
delivered: 'DELIVERED',
delivery_delayed: 'DELIVERY_DELAYED',
complained: 'COMPLAINED',
bounced: 'BOUNCED',
opened: 'OPENED',
clicked: 'CLICKED',
scheduled: 'SCHEDULED',
queued: 'QUEUED',
failed: 'FAILED',
canceled: 'CANCELED',
received: 'RECEIVED',
suppressed: 'SUPPRESSED',
};
export const mapLastEvent = (lastEvent: string): LastEvent | null => {
const key = lastEvent.replace(/^email\./, '').toLowerCase();
const mapped = EVENT_TO_LAST_EVENT[key];
if (!isDefined(mapped)) {
console.warn(`[resend] Unknown email event: ${lastEvent}`);
return null;
}
return mapped;
};
@@ -0,0 +1,14 @@
import type { EmailsField } from 'src/modules/resend/shared/types/emails-field';
export const toEmailsField = (
value: string | string[] | undefined | null,
): EmailsField => {
if (Array.isArray(value)) {
return {
primaryEmail: value[0] ?? '',
additionalEmails: value.length > 1 ? value.slice(1) : null,
};
}
return { primaryEmail: value ?? '', additionalEmails: null };
};
@@ -0,0 +1,8 @@
import { isDefined } from 'twenty-shared/utils';
export const toIsoString = (date: string): string =>
new Date(date).toISOString();
export const toIsoStringOrNull = (
date: string | null | undefined,
): string | null => (isDefined(date) ? toIsoString(date) : null);
@@ -0,0 +1,51 @@
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
const isRateLimitError = (error: unknown): boolean => {
const text =
error instanceof Error
? error.message
: typeof error === 'object' && error !== null
? JSON.stringify(error)
: '';
const lower = text.toLowerCase();
return (
lower.includes('rate limit') ||
lower.includes('rate_limit') ||
lower.includes('too many requests')
);
};
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 1000;
const MIN_INTERVAL_MS = 220;
let lastCallTimestamp = 0;
export const withRateLimitRetry = async <T>(
fn: () => Promise<T>,
): Promise<T> => {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const elapsed = Date.now() - lastCallTimestamp;
if (elapsed < MIN_INTERVAL_MS) await sleep(MIN_INTERVAL_MS - elapsed);
lastCallTimestamp = Date.now();
try {
return await fn();
} catch (error) {
if (!isRateLimitError(error) || attempt === MAX_RETRIES) throw error;
const delayMs = BASE_DELAY_MS * 2 ** attempt;
console.warn(
`[resend] Rate limited, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})`,
);
await sleep(delayMs);
}
}
throw new Error('Unreachable');
};
@@ -0,0 +1,83 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordCreateEvent,
} from 'twenty-sdk';
import { isDefined } from 'twenty-shared/utils';
import { ON_RESEND_CONTACT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import type { ResendContactRecord } from 'src/modules/resend/shared/types/resend-contact-record';
import { findOrCreatePerson } from 'src/modules/resend/shared/utils/find-or-create-person';
import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client';
type ContactCreateEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<ResendContactRecord>
>;
const handler = async (
event: ContactCreateEvent,
): Promise<object | undefined> => {
const { after } = event.properties;
if (isNonEmptyString(after.resendId)) {
return { skipped: true, reason: 'record already has resendId (inbound sync)' };
}
const email = after.email?.primaryEmail;
if (!isNonEmptyString(email)) {
return { skipped: true, reason: 'no email on record' };
}
const resend = getResendClient();
const { data, error } = await resend.contacts.create({
email,
firstName: after.name?.firstName ?? undefined,
lastName: after.name?.lastName ?? undefined,
unsubscribed: after.unsubscribed ?? false,
});
if (isDefined(error) || !isDefined(data)) {
throw new Error(
`Failed to create Resend contact: ${JSON.stringify(error)}`,
);
}
const client = new CoreApiClient();
const personId = await findOrCreatePerson(client, email, {
firstName: after.name?.firstName ?? undefined,
lastName: after.name?.lastName ?? undefined,
});
await client.mutation({
updateResendContact: {
__args: {
id: event.recordId,
data: {
resendId: data.id,
lastSyncedFromResend: new Date().toISOString(),
...(isDefined(personId) && { personId }),
},
},
id: true,
},
});
return { synced: true, resendId: data.id, twentyId: event.recordId, personId };
};
export default defineLogicFunction({
universalIdentifier: ON_RESEND_CONTACT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-resend-contact-created',
description:
'Creates a contact in Resend when a new resendContact record is created in Twenty',
timeoutSeconds: 30,
handler,
databaseEventTriggerSettings: {
eventName: 'resendContact.created',
},
});
@@ -0,0 +1,55 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordDeleteEvent,
} from 'twenty-sdk';
import { isDefined } from 'twenty-shared/utils';
import { ON_RESEND_CONTACT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import type { ResendContactRecord } from 'src/modules/resend/shared/types/resend-contact-record';
import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client';
type ContactDeleteEvent = DatabaseEventPayload<
ObjectRecordDeleteEvent<ResendContactRecord>
>;
const handler = async (
event: ContactDeleteEvent,
): Promise<object | undefined> => {
const resendId = event.properties.before?.resendId;
if (!isNonEmptyString(resendId)) {
return { skipped: true, reason: 'no resendId on record' };
}
const resend = getResendClient();
const { error } = await resend.contacts.remove({ id: resendId });
if (isDefined(error)) {
const errorString = JSON.stringify(error);
if (errorString.includes('not_found')) {
return { skipped: true, reason: 'contact already deleted on Resend' };
}
throw new Error(
`Failed to delete Resend contact ${resendId}: ${errorString}`,
);
}
return { synced: true, resendId, action: 'deleted' };
};
export default defineLogicFunction({
universalIdentifier: ON_RESEND_CONTACT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-resend-contact-deleted',
description:
'Removes a contact from Resend when a resendContact record is deleted in Twenty',
timeoutSeconds: 30,
handler,
databaseEventTriggerSettings: {
eventName: 'resendContact.deleted',
},
});
@@ -0,0 +1,104 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk';
import { isDefined } from 'twenty-shared/utils';
import { ON_RESEND_CONTACT_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import type { ResendContactRecord } from 'src/modules/resend/shared/types/resend-contact-record';
import { findOrCreatePerson } from 'src/modules/resend/shared/utils/find-or-create-person';
import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client';
type ContactUpdateEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<ResendContactRecord>
>;
const handler = async (
event: ContactUpdateEvent,
): Promise<object | undefined> => {
if (event.properties.updatedFields?.includes('lastSyncedFromResend')) {
return { skipped: true, reason: 'inbound sync echo' };
}
const { after } = event.properties;
const resendId = after?.resendId;
if (!isNonEmptyString(resendId)) {
return { skipped: true, reason: 'no resendId on record' };
}
const resend = getResendClient();
const updatePayload: Record<string, unknown> = { id: resendId };
if (event.properties.updatedFields?.includes('unsubscribed')) {
updatePayload.unsubscribed = after.unsubscribed;
}
if (event.properties.updatedFields?.includes('name')) {
updatePayload.firstName = after.name?.firstName ?? null;
updatePayload.lastName = after.name?.lastName ?? null;
}
if (event.properties.updatedFields?.includes('email')) {
updatePayload.email = after.email?.primaryEmail;
}
if (Object.keys(updatePayload).length <= 1) {
return { skipped: true, reason: 'no relevant fields changed' };
}
const { error } = await resend.contacts.update(
updatePayload as Parameters<typeof resend.contacts.update>[0],
);
if (isDefined(error)) {
throw new Error(
`Failed to update Resend contact ${resendId}: ${JSON.stringify(error)}`,
);
}
let personId: string | undefined;
if (event.properties.updatedFields?.includes('email')) {
const email = after.email?.primaryEmail;
const client = new CoreApiClient();
personId = await findOrCreatePerson(client, email, {
firstName: after.name?.firstName ?? undefined,
lastName: after.name?.lastName ?? undefined,
});
if (isDefined(personId)) {
await client.mutation({
updateResendContact: {
__args: { id: event.recordId, data: { personId } },
id: true,
},
});
}
}
return {
synced: true,
resendId,
updatedFields: Object.keys(updatePayload).filter((k) => k !== 'id'),
personId,
};
};
export default defineLogicFunction({
universalIdentifier: ON_RESEND_CONTACT_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-resend-contact-updated',
description:
'Pushes contact field changes to Resend when a resendContact record is updated in Twenty',
timeoutSeconds: 30,
handler,
databaseEventTriggerSettings: {
eventName: 'resendContact.updated',
updatedFields: ['unsubscribed', 'name', 'email'],
},
});
@@ -0,0 +1,64 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordCreateEvent,
} from 'twenty-sdk';
import { ON_RESEND_SEGMENT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import type { ResendSegmentRecord } from 'src/modules/resend/shared/types/resend-segment-record';
import { findOrCreateResendSegment } from 'src/modules/resend/sync/utils/find-or-create-resend-segment';
import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client';
type SegmentCreateEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<ResendSegmentRecord>
>;
const handler = async (
event: SegmentCreateEvent,
): Promise<object | undefined> => {
const { after } = event.properties;
if (isNonEmptyString(after.resendId)) {
return { skipped: true, reason: 'record already has resendId (inbound sync)' };
}
const name = after.name;
if (!isNonEmptyString(name)) {
return { skipped: true, reason: 'no name on record' };
}
const resend = getResendClient();
const client = new CoreApiClient();
const resendId = await findOrCreateResendSegment(resend, client, name);
await client.mutation({
updateResendSegment: {
__args: {
id: event.recordId,
data: {
resendId,
lastSyncedFromResend: new Date().toISOString(),
},
},
id: true,
},
});
return { synced: true, resendId, twentyId: event.recordId };
};
export default defineLogicFunction({
universalIdentifier: ON_RESEND_SEGMENT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-resend-segment-created',
description:
'Creates a segment in Resend when a new resendSegment record is created in Twenty',
timeoutSeconds: 30,
handler,
databaseEventTriggerSettings: {
eventName: 'resendSegment.created',
},
});
@@ -0,0 +1,55 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordDeleteEvent,
} from 'twenty-sdk';
import { isDefined } from 'twenty-shared/utils';
import { ON_RESEND_SEGMENT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import type { ResendSegmentRecord } from 'src/modules/resend/shared/types/resend-segment-record';
import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client';
type SegmentDeleteEvent = DatabaseEventPayload<
ObjectRecordDeleteEvent<ResendSegmentRecord>
>;
const handler = async (
event: SegmentDeleteEvent,
): Promise<object | undefined> => {
const resendId = event.properties.before?.resendId;
if (!isNonEmptyString(resendId)) {
return { skipped: true, reason: 'no resendId on record' };
}
const resend = getResendClient();
const { error } = await resend.segments.remove(resendId);
if (isDefined(error)) {
const errorString = JSON.stringify(error);
if (errorString.includes('not_found')) {
return { skipped: true, reason: 'segment already deleted on Resend' };
}
throw new Error(
`Failed to delete Resend segment ${resendId}: ${errorString}`,
);
}
return { synced: true, resendId, action: 'deleted' };
};
export default defineLogicFunction({
universalIdentifier: ON_RESEND_SEGMENT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-resend-segment-deleted',
description:
'Removes a segment from Resend when a resendSegment record is deleted in Twenty',
timeoutSeconds: 30,
handler,
databaseEventTriggerSettings: {
eventName: 'resendSegment.deleted',
},
});
@@ -0,0 +1,224 @@
import { describe, expect, it, vi } from 'vitest';
import type { StepOutcome } from 'src/modules/resend/sync/types/step-outcome';
import type { SyncResult } from 'src/modules/resend/sync/types/sync-result';
import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result';
import { orchestrateSyncResend } from 'src/modules/resend/sync/utils/orchestrate-sync-resend';
import {
MAX_ERRORS_IN_THROWN_MESSAGE,
reportAndThrowIfErrors,
} from 'src/modules/resend/sync/utils/report-and-throw-if-errors';
import type { SegmentIdMap } from 'src/modules/resend/sync/utils/sync-segments';
const emptyResult = (): SyncResult => ({
fetched: 0,
created: 0,
updated: 0,
errors: [],
});
const emptySegmentMap: SegmentIdMap = new Map();
const okSegments = (): Promise<SyncStepResult<SegmentIdMap>> =>
Promise.resolve({ result: emptyResult(), value: emptySegmentMap });
const okTemplates = (): Promise<SyncStepResult> =>
Promise.resolve({ result: emptyResult(), value: undefined });
const okStep = (): Promise<SyncStepResult> =>
Promise.resolve({ result: emptyResult(), value: undefined });
describe('orchestrateSyncResend', () => {
it('runs segments, templates, contacts and emails concurrently', async () => {
const order: string[] = [];
const trackStart =
<T,>(name: string, value: T) =>
(): Promise<SyncStepResult<T>> => {
order.push(`${name}:start`);
return new Promise((resolve) => {
setImmediate(() => {
order.push(`${name}:end`);
resolve({ result: emptyResult(), value });
});
});
};
await orchestrateSyncResend({
syncSegments: trackStart('segments', emptySegmentMap),
syncTemplates: trackStart('templates', undefined),
syncContacts: trackStart('contacts', undefined),
syncEmails: trackStart('emails', undefined),
syncBroadcasts: () => okStep(),
});
expect(order.slice(0, 4)).toEqual([
'segments:start',
'templates:start',
'contacts:start',
'emails:start',
]);
});
it('runs broadcasts after segments resolved', async () => {
const broadcastsArgs: SegmentIdMap[] = [];
const segmentMap: SegmentIdMap = new Map([['seg-1', 'twenty-seg-1']]);
const outcomes = await orchestrateSyncResend({
syncSegments: () =>
Promise.resolve({ result: emptyResult(), value: segmentMap }),
syncTemplates: okTemplates,
syncContacts: () => okStep(),
syncEmails: () => okStep(),
syncBroadcasts: (s) => {
broadcastsArgs.push(s);
return okStep();
},
});
expect(broadcastsArgs).toHaveLength(1);
expect(broadcastsArgs[0]).toBe(segmentMap);
const broadcasts = outcomes.find(
(outcome) => outcome.name === 'broadcasts',
);
expect(broadcasts?.status).toBe('ok');
});
it('runs broadcasts when templates fails but segments succeeds', async () => {
const syncBroadcasts = vi.fn(() => okStep());
const outcomes = await orchestrateSyncResend({
syncSegments: okSegments,
syncTemplates: () => Promise.reject(new Error('templates boom')),
syncContacts: okStep,
syncEmails: okStep,
syncBroadcasts,
});
expect(syncBroadcasts).toHaveBeenCalledTimes(1);
const broadcasts = outcomes.find(
(outcome) => outcome.name === 'broadcasts',
);
expect(broadcasts?.status).toBe('ok');
});
it('skips broadcasts with structured reason when segments fails', async () => {
const syncBroadcasts = vi.fn(() => okStep());
const outcomes = await orchestrateSyncResend({
syncSegments: () => Promise.reject(new Error('segments boom')),
syncTemplates: okTemplates,
syncContacts: okStep,
syncEmails: okStep,
syncBroadcasts,
});
expect(syncBroadcasts).not.toHaveBeenCalled();
const broadcasts = outcomes.find(
(outcome) => outcome.name === 'broadcasts',
);
expect(broadcasts?.status).toBe('skipped');
if (broadcasts?.status !== 'skipped') {
throw new Error('expected skipped outcome');
}
expect(broadcasts.reason).toContain('segments');
});
});
describe('reportAndThrowIfErrors', () => {
it('does nothing when no step has errors', () => {
const outcomes: ReadonlyArray<StepOutcome<unknown>> = [
{
name: 'segments',
status: 'ok',
durationMs: 1,
result: emptyResult(),
value: undefined,
},
];
expect(() => reportAndThrowIfErrors(outcomes)).not.toThrow();
});
it('surfaces non-throwing per-item errors collected in result.errors', () => {
const outcomes: ReadonlyArray<StepOutcome<unknown>> = [
{
name: 'contacts',
status: 'ok',
durationMs: 1,
result: {
fetched: 1,
created: 0,
updated: 0,
errors: ['contact 123: boom'],
},
value: undefined,
},
];
expect(() => reportAndThrowIfErrors(outcomes)).toThrowError(
/Sync completed with 1 error/,
);
expect(() => reportAndThrowIfErrors(outcomes)).toThrowError(
/\[contacts\] contact 123: boom/,
);
});
it('surfaces step-level failures', () => {
const outcomes: ReadonlyArray<StepOutcome<unknown>> = [
{
name: 'segments',
status: 'failed',
durationMs: 5,
error: 'top level boom',
},
];
expect(() => reportAndThrowIfErrors(outcomes)).toThrowError(
/\[segments\] top level boom/,
);
});
it('truncates the thrown message after MAX_ERRORS_IN_THROWN_MESSAGE entries', () => {
const tooMany = MAX_ERRORS_IN_THROWN_MESSAGE + 7;
const outcomes: ReadonlyArray<StepOutcome<unknown>> = [
{
name: 'emails',
status: 'ok',
durationMs: 1,
result: {
fetched: tooMany,
created: 0,
updated: 0,
errors: Array.from({ length: tooMany }, (_, i) => `err-${i}`),
},
value: undefined,
},
];
let caught: Error | undefined;
try {
reportAndThrowIfErrors(outcomes);
} catch (error) {
caught = error as Error;
}
expect(caught).toBeInstanceOf(Error);
expect(caught?.message).toContain(`Sync completed with ${tooMany} error`);
expect(caught?.message).toContain('...and 7 more');
const renderedErrorLines = caught?.message
.split('\n')
.filter((line) => line.startsWith(' - ')) ?? [];
expect(renderedErrorLines).toHaveLength(MAX_ERRORS_IN_THROWN_MESSAGE);
});
});
@@ -0,0 +1,45 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk';
import { SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client';
import { logStepOutcome } from 'src/modules/resend/sync/utils/log-step-outcome';
import { orchestrateSyncResend } from 'src/modules/resend/sync/utils/orchestrate-sync-resend';
import { reportAndThrowIfErrors } from 'src/modules/resend/sync/utils/report-and-throw-if-errors';
import { syncBroadcasts } from 'src/modules/resend/sync/utils/sync-broadcasts';
import { syncContacts } from 'src/modules/resend/sync/utils/sync-contacts';
import { syncEmails } from 'src/modules/resend/sync/utils/sync-emails';
import { syncSegments } from 'src/modules/resend/sync/utils/sync-segments';
import { syncTemplates } from 'src/modules/resend/sync/utils/sync-templates';
const handler = async (): Promise<void> => {
const resend = getResendClient();
const client = new CoreApiClient();
const syncedAt = new Date().toISOString();
const outcomes = await orchestrateSyncResend({
syncSegments: () => syncSegments(resend, client, syncedAt),
syncTemplates: () => syncTemplates(resend, client),
syncContacts: () => syncContacts(resend, client, syncedAt),
syncEmails: () => syncEmails(resend, client, syncedAt),
syncBroadcasts: (segmentMap) => syncBroadcasts(resend, client, segmentMap),
});
for (const outcome of outcomes) {
logStepOutcome(outcome);
}
reportAndThrowIfErrors(outcomes);
};
export default defineLogicFunction({
universalIdentifier: SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'sync-resend-data',
description:
'Syncs emails, contacts, templates, broadcasts, and segments from Resend every 5 minutes',
timeoutSeconds: 300,
handler,
cronTriggerSettings: {
pattern: '*/5 * * * *',
},
});
@@ -0,0 +1,9 @@
import type { EmailsField } from 'src/modules/resend/shared/types/emails-field';
export type ContactDto = {
email: EmailsField;
name: { firstName: string; lastName: string };
unsubscribed: boolean;
createdAt: string;
lastSyncedFromResend: string;
};
@@ -0,0 +1,11 @@
import type { EmailsField } from 'src/modules/resend/shared/types/emails-field';
import type { UpdateBroadcastDto } from 'src/modules/resend/sync/types/update-broadcast.dto';
export type CreateBroadcastDto = UpdateBroadcastDto & {
name: string;
subject: string | null;
fromAddress: EmailsField;
replyTo: EmailsField;
previewText: string;
createdAt: string;
};
@@ -0,0 +1,8 @@
import type { UpdateEmailDto } from 'src/modules/resend/sync/types/update-email.dto';
export type CreateEmailDto = UpdateEmailDto & {
htmlBody: string;
textBody: string;
createdAt: string;
tags: Array<{ name: string; value: string }> | undefined;
};
@@ -0,0 +1,5 @@
import type { UpdateTemplateDto } from 'src/modules/resend/sync/types/update-template.dto';
export type CreateTemplateDto = UpdateTemplateDto & {
createdAt: string;
};

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