Compare commits

...
Author SHA1 Message Date
neo773 ea04a9ab0d move docs to oxfmt 2026-05-21 01:06:20 +05:30
neo773 8049394c97 move to oxfmt 2026-05-21 00:32:03 +05:30
Paul RastoinandGitHub 31842f7714 Ci server custom jest reporter (#20765)
# Introduction
Only display failing unit tests trace in the ci-server server-test jobs
So it's possible to identify what unit test are failing without having
to re run them locally
2026-05-20 17:58:27 +00:00
Charles BochetandGitHub 9988f98577 feat(server): idempotent CLI to rotate ENCRYPTION_KEY across enc:v2 rows (#20613)
## Summary
Adds the \`secret-encryption:rotate\` CLI command, which re-encrypts
every at-rest secret stored in an \`enc:v2:\` envelope under the current
\`ENCRYPTION_KEY\`. The command is **online** and **resumable**: a SQL
filter skips rows already on the current keyId, so interrupting it
(Ctrl-C, container restart, …) and re-running picks up where it left off
without re-rotating earlier rows.

### Sites covered (one handler each)
| Site | Table.column | Scope |
| --- | --- | --- |
| \`connected-account-tokens\` | \`connectedAccount.{accessToken,
refreshToken}\` | workspace |
| \`application-variable\` | \`applicationVariable.value\` (isSecret
only) | workspace |
| \`application-registration-variable\` |
\`applicationRegistrationVariable.encryptedValue\` | instance |
| \`signing-key-private-keys\` | \`signingKey.privateKey\` | instance |
| \`sensitive-config-storage\` | \`keyValuePair.value\` (isSensitive +
STRING configs) | instance |
| \`totp-secrets\` | \`twoFactorAuthenticationMethod.secret\` |
workspace |

Each handler:
- Filters at SQL level on \`value LIKE 'enc:v2:%' AND value NOT LIKE
'enc:v2:<primaryKeyId>:%'\` to enforce idempotency without re-decrypting
already-rotated rows.
- Uses cursor-based batching (default **200**, capped **5000**).
- Threads \`workspaceId\` into HKDF for workspace-scoped sites; runs
instance-scoped for the rest.

### CLI flags
| Flag | Description |
| --- | --- |
| \`-s, --site <site>\` | Limit to a single site. |
| \`-b, --batch-size <n>\` | Override per-batch row count. |
| \`-d, --dry-run\` | Decrypt + re-encrypt in memory, skip the
\`UPDATE\`. |

The runner logs progress via Nest \`Logger\` (per-site start,
completion, final summary) and exits non-zero when any site reports
\`errors > 0\`. \`FALLBACK_ENCRYPTION_KEY\` must be set to the previous
\`ENCRYPTION_KEY\` during rotation; the runner warns when it is unset.

Operator documentation lives in #20611 (docs PR).
2026-05-20 17:51:29 +00:00
3c458ce4ca i18n - docs translations (#20778)
Created by Github action

Co-authored-by: github-actions <[email protected]>
2026-05-20 19:28:51 +02:00
b869107a22 fix(messaging): preserve all gmail to/cc/bcc recipients as participants (#20491)
As title but I also refactored it a little to match our current file and
code conventions since the code was very old

Reported by a cloud customer

---------

Co-authored-by: martmull <[email protected]>
2026-05-20 15:40:29 +00:00
EtienneandGitHub 4c4dc4cb21 fix(ai-chat)-preference models import (#20776) 2026-05-20 15:20:09 +00:00
martmullGitHubcubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
237a943947 Update twenty sdk commands (#20735)
Performs twenty-sdk cli command migration:

Summary

``` ┌─────┬──────────────────────────┬────────────────────────────┬───────────────────────┐
 │  #  │       Old command        │        New command         │        Status         │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 1   │ twenty dev [appPath]     │ twenty dev [appPath]       │ Unchanged (now also   │
 │     │                          │                            │ DEFAULT)              │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 2   │ twenty dev --once        │ twenty dev --once          │ Unchanged             │
 │     │ [appPath]                │ [appPath]                  │                       │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 3   │ twenty dev --watch       │ twenty dev [appPath]       │ --watch flag removed  │
 │     │ [appPath]                │                            │ (was default)         │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 4   │ twenty dev --verbose     │ twenty dev --verbose       │ Unchanged             │
 │     │ [appPath]                │ [appPath]                  │                       │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 5   │ twenty dev --debug       │ twenty dev --debug         │ Unchanged             │
 │     │ [appPath]                │ [appPath]                  │                       │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 6   │ twenty dev --debounceMs  │ twenty dev --debounceMs    │ Unchanged             │
 │     │ <ms> [appPath]           │ <ms> [appPath]             │                       │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 7   │ twenty build [appPath]   │ twenty dev:build [appPath] │ Deprecated → colon    │
 │     │                          │                            │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 8   │ twenty build --tarball   │ twenty dev:build --tarball │ Deprecated → colon    │
 │     │ [appPath]                │  [appPath]                 │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 9   │ twenty typecheck         │ twenty dev:typecheck       │ Deprecated → colon    │
 │     │ [appPath]                │ [appPath]                  │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 10  │ twenty logs [appPath]    │ twenty dev:fn-logs         │ Deprecated → colon    │
 │     │                          │ [appPath]                  │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 11  │ twenty logs -n <name>    │ twenty dev:fn-logs -n      │ Deprecated → colon    │
 │     │ [appPath]                │ <name> [appPath]           │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 12  │ twenty logs -u <id>      │ twenty dev:fn-logs -u <id> │ Deprecated → colon    │
 │     │ [appPath]                │  [appPath]                 │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 13  │ twenty exec [appPath]    │ twenty dev:fn-exec         │ Deprecated → colon    │
 │     │                          │ [appPath]                  │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 14  │ twenty exec -n <name>    │ twenty dev:fn-exec -n      │ Deprecated → colon    │
 │     │ [appPath]                │ <name> [appPath]           │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 15  │ twenty exec -u <id>      │ twenty dev:fn-exec -u <id> │ Deprecated → colon    │
 │     │ [appPath]                │  [appPath]                 │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 16  │ twenty exec -p <json>    │ twenty dev:fn-exec -p      │ Deprecated → colon    │
 │     │ [appPath]                │ <json> [appPath]           │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 17  │ twenty exec              │ twenty dev:fn-exec         │ Deprecated → colon    │
 │     │ --postInstall [appPath]  │ --postInstall [appPath]    │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 18  │ twenty exec --preInstall │ twenty dev:fn-exec         │ Deprecated → colon    │
 │     │  [appPath]               │ --preInstall [appPath]     │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 19  │ twenty add [entityType]  │ twenty dev:add             │ Deprecated → colon    │
 │     │                          │ [entityType]               │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 20  │ twenty add --path <path> │ twenty dev:add --path      │ Deprecated → colon    │
 │     │  [entityType]            │ <path> [entityType]        │ command               │
 └─────┴──────────────────────────┴────────────────────────────┴───────────────────────┘

 App lifecycle commands

 ┌─────┬────────────────────────┬────────────────────────────┬─────────────────────────┐
 │  #  │      Old command       │        New command         │         Status          │
 ├─────┼────────────────────────┼────────────────────────────┼─────────────────────────┤
 │ 21  │ twenty publish         │ twenty app:publish         │ Deprecated → colon      │
 │     │ [appPath]              │ [appPath]                  │ command                 │
 ├─────┼────────────────────────┼────────────────────────────┼─────────────────────────┤
 │ 22  │ twenty publish --tag   │ twenty app:publish --tag   │ Deprecated → colon      │
 │     │ <tag> [appPath]        │ <tag> [appPath]            │ command                 │
 ├─────┼────────────────────────┼────────────────────────────┼─────────────────────────┤
 │ 23  │ twenty deploy          │ twenty app:publish         │ Deprecated → colon      │
 │     │ [appPath]              │ --private [appPath]        │ command + --private     │
 ├─────┼────────────────────────┼────────────────────────────┼─────────────────────────┤
 │ 24  │ twenty install         │ twenty app:install         │ Deprecated → colon      │
 │     │ [appPath]              │ [appPath]                  │ command                 │
 ├─────┼────────────────────────┼────────────────────────────┼─────────────────────────┤
 │ 25  │ twenty uninstall       │ twenty app:uninstall       │ Deprecated → colon      │
 │     │ [appPath]              │ [appPath]                  │ command                 │
 ├─────┼────────────────────────┼────────────────────────────┼─────────────────────────┤
 │ 26  │ twenty uninstall -y    │ twenty app:uninstall -y    │ Deprecated → colon      │
 │     │ [appPath]              │ [appPath]                  │ command                 │
 └─────┴────────────────────────┴────────────────────────────┴─────────────────────────┘

 Server commands

 ┌─────┬─────────────────────────┬─────────────────────────────┬──────────────────────┐
 │  #  │       Old command       │         New command         │        Status        │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 27  │ twenty server start     │ twenty docker:start         │ Deprecated → colon   │
 │     │                         │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 28  │ twenty server start -p  │ twenty docker:start -p      │ Deprecated → colon   │
 │     │ <port>                  │ <port>                      │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 29  │ twenty server start     │ twenty docker:start --test  │ Deprecated → colon   │
 │     │ --test                  │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 30  │ twenty server stop      │ twenty docker:stop          │ Deprecated → colon   │
 │     │                         │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 31  │ twenty server stop      │ twenty docker:stop --test   │ Deprecated → colon   │
 │     │ --test                  │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 32  │ twenty server status    │ twenty docker:status        │ Deprecated → colon   │
 │     │                         │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 33  │ twenty server status    │ twenty docker:status --test │ Deprecated → colon   │
 │     │ --test                  │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 34  │ twenty server logs      │ twenty docker:logs          │ Deprecated → colon   │
 │     │                         │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 35  │ twenty server logs -n   │ twenty docker:logs -n       │ Deprecated → colon   │
 │     │ <lines>                 │ <lines>                     │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 36  │ twenty server logs      │ twenty docker:logs --test   │ Deprecated → colon   │
 │     │ --test                  │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 37  │ twenty server reset     │ twenty docker:reset         │ Deprecated → colon   │
 │     │                         │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 38  │ twenty server reset     │ twenty docker:reset --test  │ Deprecated → colon   │
 │     │ --test                  │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 39  │ twenty server upgrade   │ twenty docker:upgrade       │ Deprecated → colon   │
 │     │ [version]               │ [version]                   │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 40  │ twenty server upgrade   │ twenty docker:upgrade       │ Deprecated → colon   │
 │     │ --test [version]        │ --test [version]            │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 41  │ twenty server           │ twenty app:catalog-sync  │ Deprecated → colon   │
 │     │ catalog-sync            │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 42  │ twenty server           │ twenty app:catalog-sync  │ Deprecated → colon   │
 │     │ catalog-sync -r <name>  │ -r <name>                   │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 43  │ twenty catalog-sync     │ (removed)                   │ Removed (was already │
 │     │                         │                             │  deprecated)         │
 └─────┴─────────────────────────┴─────────────────────────────┴──────────────────────┘

 Remote commands

 ┌─────┬────────────────────────┬──────────────────────────┬──────────────────────────┐
 │  #  │      Old command       │       New command        │          Status          │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 44  │ twenty remote add      │ twenty remote:add        │ Deprecated → colon       │
 │     │                        │                          │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 45  │ twenty remote add --as │ twenty remote:add --as   │ Deprecated → colon       │
 │     │  <name>                │ <name>                   │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 46  │ twenty remote add      │ twenty remote:add        │ Deprecated → colon       │
 │     │ --api-key <key>        │ --api-key <key>          │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 47  │ twenty remote add      │ twenty remote:add        │ Deprecated → colon       │
 │     │ --api-url <url>        │ --api-url <url>          │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 48  │ twenty remote add      │ twenty remote:add        │ Deprecated → colon       │
 │     │ --local                │ --local                  │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 49  │ twenty remote add      │ twenty remote:add --test │ Deprecated → colon       │
 │     │ --test                 │                          │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 50  │ twenty remote list     │ twenty remote:list       │ Deprecated → colon       │
 │     │                        │                          │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 51  │ twenty remote switch   │ twenty remote:use [name] │ Deprecated → colon       │
 │     │ [name]                 │                          │ syntax + renamed         │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 52  │ twenty remote status   │ twenty remote:status     │ Deprecated → colon       │
 │     │                        │                          │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 53  │ twenty remote remove   │ twenty remote:remove     │ Deprecated → colon       │
 │     │ <name>                 │ <name>                   │ syntax                   │
 └─────┴────────────────────────┴──────────────────────────┴──────────────────────────┘
```

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-05-20 15:12:39 +00:00
WeikoandGitHub 6e5e7963b5 fix(server): map PermissionsException to proper HTTP status on REST API (#20739)
## Summary

`PermissionsException` thrown by `SettingsPermissionGuard` (and other
permission code paths) was bubbling up through every typed REST
exception filter and landing in the global `UnhandledExceptionFilter`,
which falls back to **500** for anything that isn't an `HttpException`.
So a forbidden user (e.g. an API key whose role doesn't have
`DATA_MODEL`) calling `GET /rest/metadata/objects` got:
```
HTTP/1.1 500 Internal Server Error "Entity performing the request does not have permission"
```


GraphQL already had the right plumbing via
`permissionGraphqlApiExceptionHandler` (`ForbiddenError` → 403,
`UserInputError` → 400, `NotFoundError` → 404). This PR mirrors it on
the REST side.

## What

- New util `permissionRestApiExceptionCodeToHttpStatus` mapping every
`PermissionsExceptionCode` → HTTP status, with `assertUnreachable` to
force explicit handling of future codes.
- New filter `PermissionsRestApiExceptionFilter`
(`@Catch(PermissionsException)`) that delegates to
`HttpExceptionHandlerService.handleError(...)` with the resolved status.
- Wired `PermissionsRestApiExceptionFilter` (placed first, so the typed
filter wins over any sibling catch-all) into `@UseFilters(...)` of every
REST controller that uses `SettingsPermissionGuard` or whose service can
throw `PermissionsException`:
  - `object-metadata`, `field-metadata`, `webhook`, `api-key`
- `view`, `view-sort`, `view-group`, `view-filter`, `view-filter-group`,
`view-field`
  - `page-layout`, `page-layout-widget`, `page-layout-tab`
  - `front-component`, `ai-generate-text`
- Unit tests covering 403 / 400 / 404 / 500 mappings.

## Mapping

| Code | Status |
|------|--------|
| `PERMISSION_DENIED`, `NO_AUTHENTICATION_CONTEXT`,
`ROLE_LABEL_ALREADY_EXISTS`, `CANNOT_UNASSIGN_LAST_ADMIN`,
`CANNOT_UPDATE_SELF_ROLE`, `CANNOT_DELETE_LAST_ADMIN_USER`,
`ROLE_NOT_EDITABLE`, `CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT`,
`CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT` | **403** |
| `INVALID_ARG`, `INVALID_SETTING`,
`CANNOT_GIVE_WRITING_PERMISSION_ON_NON_READABLE_OBJECT`,
`CANNOT_GIVE_WRITING_PERMISSION_WITHOUT_READING_PERMISSION`,
`ONLY_FIELD_RESTRICTION_ALLOWED`,
`FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT`,
`FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT`,
`EMPTY_FIELD_PERMISSION_NOT_ALLOWED`,
`ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET`, `ROLE_CANNOT_BE_ASSIGNED_TO_USERS`
| **400** |
| `ROLE_NOT_FOUND`, `OBJECT_METADATA_NOT_FOUND`,
`FIELD_METADATA_NOT_FOUND`, `FIELD_PERMISSION_NOT_FOUND`,
`PERMISSION_NOT_FOUND` | **404** |
| All remaining "internal" codes (rethrown as-is in GraphQL) | **500** |

## Before
<img width="507" height="216" alt="Screenshot 2026-05-19 at 19 26 07"
src="https://github.com/user-attachments/assets/21d633aa-7ee8-4923-94e4-7ad57258a29e"
/>

## After
<img width="610" height="385" alt="Screenshot 2026-05-19 at 19 26 01"
src="https://github.com/user-attachments/assets/0103b7ee-7df7-4aef-999a-73c22901afd2"
/>
2026-05-20 13:02:58 +00:00
Félix MalfaitandGitHub a2acf88a57 feat(website): per-PR preview deploys via Worker versions (#20762)
## Summary
Adds review apps for the marketing site. Every PR that touches
`packages/twenty-website/**` or `packages/twenty-shared/**` gets a
per-version Worker preview URL, sticky-commented on the PR, auto-cleaned
up when the PR closes.

Same Cloudflare machinery skew protection rides on, just used for
previews — no extra plan, no extra services. Cleaner than the
GitHub-Actions-runner + Cloudflare-tunnel pattern: previews persist for
the life of the version, accessible from anywhere, no warm-up.

## Files
- **`.github/workflows/website-pr-preview.yaml`** — on PR
open/sync/reopen: builds the Worker with a per-PR `DEPLOYMENT_ID`, runs
`wrangler versions upload --tag pr-<N>` (no production traffic),
sticky-comments the preview URL. Skipped on fork PRs because GitHub
doesn't pass secrets to forks anyway.
- **`.github/workflows/website-pr-preview-cleanup.yaml`** — on PR close:
walks the Worker version list via the CF API, deletes anything tagged
`pr-<N>` (with message-based fallback if the annotation key changes),
updates the sticky comment.
- **`open-next.config.ts`** — `maxNumberOfVersions: 10 → 50` to leave
room for PR previews on top of skew protection's prod-version retention.

## How it looks on a PR

The bot leaves a sticky comment like:

> 🔍 **Website preview** is up at
**https://abc12345-twenty-website-dev.twentyhq.workers.dev**
>
> | | |
> |---|---|
> | Version | `abc12345-...` |
> | Commit | `<sha>` |
> | Bindings | shared with the `dev` Worker (R2 cache + secrets) |
>
> Updates on every push. Auto-deleted when the PR closes.

On close it becomes:

> 🧹 Website preview for this PR was cleaned up after close.

## Twenty repo credentials already provisioned
- `secret CLOUDFLARE_API_TOKEN` — same scoped token the `twenty-infra`
workflow uses
- `var CLOUDFLARE_ACCOUNT_ID` = `67b2bbe4381006564d2b0aa6ce6177be`
- `var CF_PREVIEW_DOMAIN` = `twentyhq` (no `.workers.dev` suffix —
OpenNext appends it;
[opennextjs-cloudflare#811](https://github.com/opennextjs/opennextjs-cloudflare/issues/811))

## Known limitations
- **Shared dev bindings**: PR previews use the dev Worker's R2 bucket +
secrets (Stripe test key, JWT private key). Fine for a read-mostly
marketing site; if two simultaneous PRs ever fight over ISR cache state
we can prefix R2 keys per-PR later.
- **Fork PRs don't get previews**. GitHub Actions doesn't pass
`secrets.*` to fork-PR runs (security), and the wrangler upload requires
the CF token. To enable forks, would need to switch to
`pull_request_target` and gate on a maintainer label — not done here
because the security tradeoff isn't worth it for a marketing-site
preview.
- **Version cap**: 50 versions is the new ceiling, and
`maxVersionAgeDays: 14` auto-prunes anything older. Cleanup-on-close
should keep us well under in steady state.

## Test plan
- [ ] CI on this PR triggers the preview workflow itself; check that the
sticky comment appears with a working URL
- [ ] Hit the URL, click around — should look like a fresh
marketing-site build with this PR's changes
- [ ] Close (don't merge) → cleanup workflow should run; sticky comment
switches to the "cleaned up" message; the version is gone from `wrangler
versions list --name twenty-website-dev`
2026-05-20 14:59:42 +02:00
Félix MalfaitandGitHub c002bc52bd fix(ci): repair preview-environment dispatch (use PAT, not GITHUB_TOKEN) (#20773)
## What
One-line token swap on the same-repo dispatch step in
[`preview-env-dispatch.yaml`](.github/workflows/preview-env-dispatch.yaml#L40):
`secrets.GITHUB_TOKEN` → `secrets.CI_PRIVILEGED_DISPATCH_TOKEN`.

## Why
Regression from [#20476](https://github.com/twentyhq/twenty/pull/20476)
("security: harden CI against supply-chain attacks"), merged 2026-05-12.
That PR replaced

```yaml
uses: peter-evans/repository-dispatch@v2
with:
  token: ${{ secrets.GITHUB_TOKEN }}
  ...
```

with a raw `gh api` call but kept `GITHUB_TOKEN`:

```yaml
env:
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
  gh api repos/"$REPOSITORY"/dispatches -f event_type=preview-environment ...
```

The auto-provisioned `GITHUB_TOKEN` can't fire `repository_dispatch` via
`gh api` even when the workflow declares `permissions: contents: write`.
The action used a different code path that worked; the CLI requires a
token with `repo` scope. So every dispatch from this workflow has
returned `403 Resource not accessible by integration` since that PR
merged — except for runs the `author_association` / `preview-app` label
gate skips entirely (which then show "success" because no jobs ran).

Recent failed example:
https://github.com/twentyhq/twenty/actions/runs/26162974597/job/76959379235?pr=20769

## The fix
`secrets.CI_PRIVILEGED_DISPATCH_TOKEN` already exists in repo secrets
and is **already used** by the immediately-following cross-repo dispatch
step in the same file. Using it for the same-repo dispatch too matches
the surrounding code and is consistent with the original hardening
intent (use a scoped PAT, not the auto-provisioned token).

## Test plan
- [ ] Merge this PR
- [ ] Next PR open / sync / reopen on a member's branch → check that
`Preview Environment Dispatch` succeeds (no 403)
- [ ] Confirm `Preview Environment Keep Alive` workflow gets triggered
(the downstream effect of the dispatch)
- [ ] Confirm the tunnel URL sticky comment lands on the PR

Discovered while testing an unrelated PR
([#20762](https://github.com/twentyhq/twenty/pull/20762)). Independent
fix.
2026-05-20 14:57:57 +02:00
martmullandGitHub 127fb2a470 Increase size of tarball upload (#20767)
- check size while reading stream instead of checking after reading all
stream
- move MAX_TARBALL_UPLOAD_SIZE_BYTES to config variables
- increase MAX_TARBALL_UPLOAD_SIZE_BYTES default from 50Mb to 100Mb
2026-05-20 12:39:11 +00:00
Paul RastoinandGitHub 3d49c17e34 [CONNECTED_ACCOUNT_BREAKING_CHANGE] Unify connected account permissions (#20732)
# Introduction
This PR is a followup of https://github.com/twentyhq/twenty/pull/20673

It aims to unify the authentication/permissions layer with all the
connectedAccount interactions across the application

## Deprecate
- findAll
- findById

## Email sync
An user can only sync the message of his own connected account

## Workflow email
- Related https://github.com/twentyhq/private-issues/issues/478
- Only reauthorize owned account
2026-05-20 11:36:58 +00:00
b454ad2aea fix(workflow): restore initial input fields on code step creation (#20756)
## Summary

- Fixes a regression from #20208 where creating a new CODE workflow step
shows no input fields
- The split-triggers PR removed `SEED_LOGIC_FUNCTION_INPUT_SCHEMA` and
replaced `toolInputSchema` with `workflowActionTriggerSettings`, but
`CodeStepBuildService.createCodeStepLogicFunction` was not updated to
pass the seed schema — causing `logicFunctionInput` to default to `{}`
and no fields to render
- Adds `SEED_WORKFLOW_ACTION_TRIGGER_SETTINGS` constant (matching the
seed template's `{ a: string, b: number }` params) and passes it when
creating the seed logic function

## Test plan

- [x] Unit test updated to assert `logicFunctionInput` contains `{ a:
null, b: null }` on code step creation
- [x] Create a new CODE step in the workflow builder and verify input
fields `a` and `b` appear immediately

Co-authored-by: Cursor <[email protected]>
2026-05-20 11:21:17 +00:00
fb47e4497a i18n - docs translations (#20764)
Created by Github action

Co-authored-by: github-actions <[email protected]>
2026-05-20 13:21:44 +02:00
Félix MalfaitandGitHub 00fad657f4 feat(website): enable OpenNext skew protection + tune CF cache (#20760)
## Summary
The original goal of this whole migration: **cross-deployment skew is
now handled by OpenNext's per-version routing instead of by users having
to refresh.**

A client holding a stale tab from deployment X requests assets with
`?dpl=X` — the Worker compares X to the current `DEPLOYMENT_ID`, looks
it up in `CF_DEPLOYMENT_MAPPING`, and routes to the matching old Worker
version via its per-version preview URL
(`<old-version>-twenty-website-<env>.twentyhq.workers.dev`). The old
version serves the old assets / RSC payloads / Server Actions
consistently.

**Verified end-to-end on dev**:
| | Marker in HTML |
|---|---|
| Current Worker (`twenty-main.com/`) | `9npeiytir8EPOtW71cqDZ` |
| Stale request (`twenty-main.com/?dpl=<previous-deploy-id>`) |
`B9OC_TNl1vaGcJ5oUUty6` |
| Direct hit on old preview URL | `B9OC_TNl1vaGcJ5oUUty6` ← matches the
skew-routed response |

## Changes

**`open-next.config.ts`** — enable skew protection
```ts
const baseConfig = defineCloudflareConfig({ incrementalCache: r2IncrementalCache });
export default {
  ...baseConfig,
  cloudflare: {
    ...baseConfig.cloudflare,
    skewProtection: {
      enabled: true,
      maxNumberOfVersions: 10,
      maxVersionAgeDays: 14,
    },
  },
};
```
(`defineCloudflareConfig` doesn't accept `skewProtection` directly — has
to be merged in)

**`next.config.ts`** — `deploymentId: process.env.DEPLOYMENT_ID`. CI
sets `DEPLOYMENT_ID` per-build; Next bakes it into prerendered HTML,
`?dpl=…` on asset URLs, Server Actions, and RSC fetch headers.

**`wrangler.jsonc`**:
- `compatibility_date: 2026-04-15` (was `2025-01-15`; build was warning)
- `assets.run_worker_first: true` — Worker must intercept asset requests
so the skew handler can route stale `/_next/static/*` to the old
version. CF edge cache absorbs hot paths so this isn't a 5×
billable-invocation tax
- `preview_urls: true` — required; skew routes via the per-version
preview URL which only exists when previews are enabled
- Per-env `services: [{ binding: WORKER_SELF_REFERENCE, service:
twenty-website-<env> }]` — OpenNext's recommended setup for
fire-and-forget ISR revalidation
- Per-env `vars`: `CF_WORKER_NAME` + `CF_PREVIEW_DOMAIN` (bare
`twentyhq`, *not* `twentyhq.workers.dev` — OpenNext appends
`.workers.dev` itself, see
[opennextjs-cloudflare#811](https://github.com/opennextjs/opennextjs-cloudflare/issues/811))
- Kept `global_fetch_strictly_public` in compat flags — without it, CF's
optimised intra-account routing self-loops the cross-version fetch and
522s out. With it, the fetch takes the public-Internet path which routes
correctly.

**`public/_headers`** — deleted (with `run_worker_first: true` the
assets pipeline doesn't process it; Next sets the same `Cache-Control:
immutable` on `/_next/static/*` anyway).

## Companion infra PR
https://github.com/twentyhq/twenty-infra/pull/__ — wires the four CF env
vars (`DEPLOYMENT_ID`, `CF_WORKER_NAME`, `CF_PREVIEW_DOMAIN`,
`CF_ACCOUNT_ID`, `CF_WORKERS_SCRIPTS_API_TOKEN`) into the deploy
workflow.

## Known limitation
Skew routing only works for Worker versions deployed AFTER this PR
(older versions don't have `preview_urls: true` and don't have
`DEPLOYMENT_ID` bindings OpenNext can read). Users on tabs older than
the first post-merge deploy still fall through to the current Worker
(same behaviour as today).

OpenNext marks `skewProtection` as **experimental** in their type docs
("might break on minor releases") — worth keeping an eye on.
2026-05-20 12:47:12 +02:00
c800eccc65 Slack workflow connector (#20427)
https://github.com/user-attachments/assets/5a746414-988b-473c-9401-b8863a3e1c15


https://github.com/user-attachments/assets/0cdebdb1-f7c8-43cb-beef-f279387b6ce9



https://github.com/user-attachments/assets/df31c631-0781-42d8-8e6e-e5a16573ee3b



https://github.com/user-attachments/assets/6adaeae4-f3c9-4a5f-b0df-50c1f9a78428

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: martmull <[email protected]>
2026-05-20 10:21:22 +00:00
nitinandGitHub a26fe3bb65 docs(sdk): document DatabaseEventPayload and simplify its type (#20754)
closes
https://discord.com/channels/1130383047699738754/1505967920163983502

Update logic-function docs to match the real `DatabaseEventPayload`
shape.

The docs now show database event payloads as record-level events with
`recordId` and `properties.before/after/diff/updatedFields`, including
compact examples for created, updated, and destroyed events. Route
payload type imports now use the preferred `twenty-sdk/logic-function`
surface.

Also clean up the shared payload type wrapper so it models event
metadata without over-promising actor fields; `userId`,
`userWorkspaceId`, and `workspaceMemberId` remain optional through the
underlying event type.
2026-05-20 09:22:58 +00:00
EtienneandGitHub 8d81496625 fix(ai-chat) - fixes on cost display (#20750)
- total cost conversion
- update metrics at message end
2026-05-20 08:50:23 +00:00
martmullandGitHub f1b7c21b6c Update create twenty app scaffolded front component (#20733)
- Replace inline SVG icons with proper Avatar and icon components
(IconBox, IconHierarchy, IconLayout, IconSettingsAutomation) from
twenty-sdk/ui in the scaffolded front component
  template
- Strip trailing slashes from workspace/API URLs in both
create-twenty-app CLI and twenty-sdk remote commands to prevent
malformed requests
  - Fix the application settings link to navigate to #installed anchor
- Bump twenty-sdk, twenty-client-sdk, and create-twenty-app versions to
2.6.0

<img width="1512" height="824" alt="image"
src="https://github.com/user-attachments/assets/8561d7bb-3458-46c4-b01e-664321634b4c"
/>
2026-05-20 08:23:10 +00:00
Félix MalfaitandGitHub f630ce34fe feat(website): mirror prod hostname pattern on dev (apex + www) (#20753)
## Summary
Drops the `website.` subdomain on dev entirely and serves the marketing
site from the bare zone + `www`, mirroring how prod is served at
`twenty.com` + `www.twenty.com`.

Also fixes a latent root-path substitution bug in the existing www→apex
redirect that was masked on prod by a CF-level redirect.

## What changes
- `wrangler.jsonc` env.dev routes: `twenty-main.com` (apex) +
`www.twenty-main.com` (was `website.twenty-main.com`)
- `next.config.ts`: extends host-based www→apex redirect to also cover
`www.twenty-main.com`, and adds explicit `source: '/'` rules for both
prod + dev before the catch-all `source: '/:path*'` (the `:path*`
parameter doesn't substitute properly when it matches the empty root
path against an absolute destination URL — Next.js leaves the literal
`:path*` in the `Location` header)

## Live verification (after redeploy)
| URL | Status | Notes |
|---|---|---|
| `https://twenty-main.com/` | 200 | `x-opennext: 1`, `x-nextjs-cache:
HIT` |
| `https://twenty-main.com/pricing` | 200 | Worker SSR |
| `https://www.twenty-main.com/` | 308 → `https://twenty-main.com/` |
Root-redirect fix applied |
| `https://www.twenty-main.com/pricing` | 308 →
`https://twenty-main.com/pricing` | Path preserved |
| `https://twenty.com/` | 200 | Unchanged |
| `https://www.twenty.com/` | 301 → `https://twenty.com/` | Still routed
via CF-level redirect, now also covered by the new explicit Next rule as
a defense-in-depth |
| `https://website.twenty-main.com/` | 503 | DNS record removed by
wrangler when route was deleted; hostname effectively retired |

## Companion infra PR
https://github.com/twentyhq/twenty-infra/pull/__ —
`cloudflare/website/dev.env` + `docs/4-environments.md` updated to the
new URL; also bundles the CI fix that should have landed in #683 (was
pushed too late).
2026-05-20 09:54:44 +02:00
martmullandGitHub 7ebcbe8801 Unify oAuth success and failure screen with autorize page (#20746)
Goal, matching authorize page design

<img width="2062" height="1376" alt="image"
src="https://github.com/user-attachments/assets/93d0f77a-c769-4a32-b41b-16459378314f"
/>


## Before

<img width="1540" height="942" alt="image"
src="https://github.com/user-attachments/assets/4de64f37-8519-4fdc-9388-70d98a69663e"
/>


## After

<img width="962" height="657" alt="image"
src="https://github.com/user-attachments/assets/c4fd19aa-13e3-452e-b6b7-c10202cb1edf"
/>

<img width="705" height="437" alt="image"
src="https://github.com/user-attachments/assets/e1f743f1-a862-4629-a3d0-c8e62e429e04"
/>
2026-05-20 07:43:34 +00:00
b821061526 fix(create-twenty-app): preserve .yarnrc.yml in template (#20623)
**Source:** https://sonarly.com/issue/37981?type=bug

## Summary

New apps created with `[email protected]` can fail at `yarn twenty
dev` with `Could not resolve "twenty-sdk/define"`, blocking onboarding
for app developers.

## Root cause

Proximate cause: manifest module loading in the SDK fails to resolve
`twenty-sdk/define` when the generated app uses Yarn PnP (no
`node_modules` tree), and esbuild is invoked with normal Node-style
resolution.

- The failing path is `extractManifestFromFile()` → `loadModule()` in
`packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config-from-file.ts`,
which calls `esbuild.build({ bundle: true, ... })` and does not
stub/mock `twenty-sdk/define` [ref: read
`manifest-extract-config-from-file.ts`].
- The scaffolded template imports `twenty-sdk/define` in
`src/application-config.ts` and `src/default-role.ts` [ref: grep in
`packages/create-twenty-app/src/constants/template/src/*`].
- If esbuild cannot resolve that import from the app environment, the
exact error matches the issue: `src/application-config.ts:1:34: ERROR:
Could not resolve "twenty-sdk/define"`.

Triggering cause (why now): `[email protected]` (the current npm
`latest`) ships a template tarball without `.yarnrc.yml`, so new
projects silently default to Yarn PnP instead of `node-modules`.

Evidence:
- Source template contains `.yarnrc.yml` with `nodeLinker: node-modules`
[ref: read
`packages/create-twenty-app/src/constants/template/.yarnrc.yml`].
- Published npm tarball for `create-twenty-app-2.5.0.tgz` does **not**
contain `template/.yarnrc.yml` (but does contain renamed
`gitignore`/`github`) [ref: tarball listing command output: `hasYarnrc:
false`].
- Dotfile-preservation logic in `copyBaseApplicationProject()` only
renames `gitignore` and `github`; it does not preserve `.yarnrc.yml`
[ref: read `packages/create-twenty-app/src/utils/app-template.ts`].
- `npm dist-tags` shows `latest: 2.5.0`, so users following docs with
`@latest` receive this broken scaffold path now [ref: npm registry
query].

Why this is attributable to a specific change:
- Commit `15eb3e7edccdf4e9770a00a07bfbd026420f7c3b` introduced
dotfile-preservation mechanics for template publish
(`gitignore`/`github`) but left out `.yarnrc.yml`, creating the
regression window for newly scaffolded apps [ref: `git show --stat
15eb3e7...`, `git blame` on `renameDotfiles()`].

## Fix

Implemented a targeted fix in `create-twenty-app` so `.yarnrc.yml` is
preserved through npm packaging the same way `.gitignore` and `.github`
are handled.

What changed:
1) Template dotfile preservation
- Removed
`packages/create-twenty-app/src/constants/template/.yarnrc.yml`
- Added `packages/create-twenty-app/src/constants/template/yarnrc.yml`
with identical content:
  - `nodeLinker: node-modules`

This avoids npm stripping the file from the published tarball.

2) Scaffold rename logic
- Updated `packages/create-twenty-app/src/utils/app-template.ts`:
- Added `{ from: 'yarnrc.yml', to: '.yarnrc.yml' }` in
`renameDotfiles()`
  - Updated progress text and inline comment to include `.yarnrc.yml`

So generated apps reliably restore `.yarnrc.yml` after template copy.

3) Regression test
- Updated
`packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts`:
  - Added a test asserting `yarnrc.yml` is renamed to `.yarnrc.yml`
  - Added a small constant for the test path

This locks the behavior and prevents reintroducing the publish omission
regression.

Validation notes:
- Attempted to run the focused Jest test, but execution failed due
missing workspace dependency state (`@nx/jest/preset` / node_modules
state not installed in this environment).

## Original request

fix(create-twenty-app): preserve .yarnrc.yml in template

_Created by Sonarly by autonomous analysis (run 43375)._

---------

Co-authored-by: sonarly-bot <[email protected]>
Co-authored-by: martmull <[email protected]>
2026-05-20 07:39:53 +00:00
f3aadbbb66 chore(server): drop leftover favorite and favoriteFolder workspace objects (#20744)
## Summary

- Adds a 2.7.0 workspace upgrade command
`upgrade:2-7:drop-favorite-objects` that removes the legacy `favorite`
and `favoriteFolder` object metadata (and their workspace tables) from
every active or suspended workspace.
- The records were migrated to `navigationMenuItem` in the 1.17/1.18
upgrades and the entity code was deleted in #19536, but the
per-workspace metadata rows were never cleaned up — so they still
surface in the "Existing objects" settings list and expose stale CRUD
tools to the AI/MCP layer (e.g. the model can hallucinate
`create_favorite_folder` against a real-looking schema).

## Implementation notes

- Modeled on
[`upgrade:2-3:drop-message-direction-field`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/database/commands/upgrade-version-command/2-3/2-3-workspace-command-1777400000000-drop-message-direction-field.command.ts),
but at object granularity.
- Uses `ObjectMetadataService.deleteOneObject({ isSystemBuild: true })`
so all cascading is handled by the existing pipeline: field metadata,
indexes, relation fields on other workspace entities, command menu
items, and the workspace data tables. Views and orphaned
`navigationMenuItem` rows pointing at favorite views are removed by the
existing `onDelete: 'CASCADE'` FKs.
- Deletion order: `favorite` first (holds a relation to
`favoriteFolder`), then `favoriteFolder`.
- Both objects are flagged `isSystem: true`, hence `isSystemBuild: true`
on the call.
- Idempotent: workspaces where the object is already absent are logged
and skipped.
- Honors `--dry-run`.
- Universal identifiers are hard-coded because the matching
`STANDARD_OBJECTS` entries were deleted in #19536.

## Test plan

- [ ] Run on a workspace that still has `favorite` / `favoriteFolder` in
`core.objectMetadata` (verify in prod-like DB beforehand) and confirm
both objects, their fields, indexes, relation fields on linked objects,
views, and the workspace data tables are gone after running.
- [ ] Re-run on the same workspace — confirm it logs "already absent"
and exits clean (idempotency).
- [ ] Run on a workspace where the objects don't exist (e.g. fresh
local) — confirm clean no-op.
- [ ] Run with \`--dry-run\` first — confirm log output and no DB
mutations.
- [ ] Confirm the "Existing objects" settings page no longer lists
Favorites / Favorite Folders after the migration.

## Safety check before rollout

Before running in prod, verify no workspace has live (non-soft-deleted)
favorite data that didn't make it to \`navigationMenuItem\`:

\`\`\`sql
-- Per workspace
SELECT count(*) FROM workspace_xxx.favorite WHERE "deletedAt" IS NULL;
\`\`\`

Should be ~0 in workspaces that ran the 1.17 / 1.18 migrations.

---------

Co-authored-by: prastoin <[email protected]>
2026-05-20 07:21:52 +00:00
e494bc7006 chore: sync AI model catalog from models.dev (#20751)
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 <[email protected]>
2026-05-20 09:06:14 +02:00
martmullandGitHub 91e0e07af4 fix: render PAGE_LAYOUT nav items with standard icon tile and compute… (#20743)
## Before

<img width="434" height="583" alt="image"
src="https://github.com/user-attachments/assets/a336620f-3f85-4d20-ba58-b20d779ba3b9"
/>


## after

<img width="539" height="600" alt="image"
src="https://github.com/user-attachments/assets/f88e4793-e1a1-4215-baf1-289c413d9dac"
/>
2026-05-20 06:17:39 +00:00
martmullandGitHub 7ab6f5719f Update default widget gridPosition (#20740)
move DEFAULT_WIDGET_SIZE to twenty-shared and use it in sync application
manifest
2026-05-20 06:15:53 +00:00
3214 changed files with 71272 additions and 47758 deletions
+1 -1
View File
@@ -50,4 +50,4 @@ runs:
- name: Deploy
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty deploy --remote target
run: yarn twenty app:publish --private --remote target
@@ -50,4 +50,4 @@ runs:
- name: Install
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty install --remote target
run: yarn twenty app:install --remote target
@@ -105,7 +105,7 @@ jobs:
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --workspace-url http://localhost:3000
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --url http://localhost:3000
- name: Install scaffolded app dependencies
run: |
@@ -153,7 +153,7 @@ jobs:
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
npx --no-install twenty remote:add --api-key ${{ env.TWENTY_API_KEY }} --url ${{ env.TWENTY_API_URL }}
- name: Run scaffolded app integration test (deploys, installs, and verifies the app)
run: |
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
steps:
- name: Trigger preview environment workflow
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REPOSITORY: ${{ github.repository }}
@@ -0,0 +1,69 @@
name: 'Website Preview Dispatch'
permissions:
contents: read
on:
pull_request:
types: [opened, synchronize, reopened, closed, labeled]
paths:
- packages/twenty-website/**
- .github/workflows/website-preview-dispatch.yaml
concurrency:
# Keyed on PR number so independent PRs don't cancel each other. `github.ref`
# would resolve to the base branch under pull_request and collide.
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
trigger-build:
# Same fork PRs from outside the org don't have `secrets.*` so the dispatch
# call would fail anyway — skip explicitly to avoid noise.
if: |
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.action != 'closed' && (
(github.event.action == 'labeled' && github.event.label.name == 'preview-website') ||
(
(
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'COLLABORATOR'
) && contains(fromJSON('["opened","synchronize","reopened"]'), github.event.action)
)
)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Dispatch website-preview-build to ci-privileged
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=website-preview-build \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
-f "client_payload[pr_head_ref]=$PR_HEAD_REF"
trigger-cleanup:
# Covers both merge and close-without-merge — pull_request `closed` fires
# for both. PRs left open forever are covered by OpenNext's
# `maxVersionAgeDays: 14` + `maxNumberOfVersions: 50` auto-pruning in
# open-next.config.ts, so nothing leaks even if cleanup never runs.
if: |
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.action == 'closed'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Dispatch website-preview-cleanup to ci-privileged
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=website-preview-cleanup \
-f "client_payload[pr_number]=$PR_NUMBER"
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf",
"printWidth": 80,
"sortPackageJson": false,
"ignorePatterns": [
"**/dist/**",
"**/build/**",
"**/lib/**",
"**/.next/**",
"**/coverage/**",
"**/generated/**",
"**/generated-admin/**",
"**/generated-metadata/**",
"**/.cache/**",
"**/node_modules/**",
"**/*.min.js",
"**/*.snap",
"**/*.md",
"**/*.mdx",
"**/seed-project/**/*.mjs",
"packages/twenty-zapier/build/**"
]
}
+1 -1
View File
@@ -63,7 +63,7 @@ export default defineObject({
Then ship it to your workspace:
```bash
npx twenty deploy
npx twenty app:publish --private
```
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
+9 -13
View File
@@ -44,12 +44,12 @@
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "npx oxlint -c .oxlintrc.json . && (prettier . --check --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
"command": "npx oxlint -c .oxlintrc.json . && (npx oxfmt --check . || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
},
"configurations": {
"ci": {},
"fix": {
"command": "npx oxlint --fix -c .oxlintrc.json . && prettier . --write --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata"
"command": "npx oxlint --fix -c .oxlintrc.json . && npx oxfmt ."
}
},
"dependsOn": ["^build", "twenty-oxlint-rules:build"]
@@ -59,12 +59,12 @@
"cache": false,
"dependsOn": ["twenty-oxlint-rules:build"],
"options": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (prettier --check $FILES || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (npx oxfmt --check $FILES || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
"pattern": "\\.(ts|tsx|js|jsx)$"
},
"configurations": {
"fix": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && prettier --write $FILES)"
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && npx oxfmt $FILES)"
}
}
},
@@ -73,18 +73,14 @@
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "prettier {args.files} --check --cache {args.cache} --cache-location {args.cacheLocation} --write {args.write} --cache-strategy {args.cacheStrategy}",
"cache": true,
"cacheLocation": "../../.cache/prettier/{projectRoot}",
"cacheStrategy": "metadata",
"write": false
"command": "npx oxfmt --check {args.files} {args.write}",
"files": ".",
"write": ""
},
"configurations": {
"ci": {
"cacheStrategy": "content"
},
"ci": {},
"fix": {
"write": true
"command": "npx oxfmt {args.files}"
}
},
"dependsOn": ["^build"]
+1
View File
@@ -13,6 +13,7 @@
"concurrently": "^8.2.2",
"http-server": "^14.1.1",
"nx": "22.5.4",
"oxfmt": "0.50.0",
"tsx": "^4.17.0",
"verdaccio": "^6.3.1"
},
+3 -3
View File
@@ -35,7 +35,7 @@ The scaffolder will:
| `--name <name>` | Set the app name |
| `--display-name <displayName>` | Set the display name |
| `--description <description>` | Set the description |
| `--workspace-url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
| `--url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
| `--authentication-method <method>` | `oauth` or `apiKey` (default: `apiKey` for local, `oauth` for remote) |
## Documentation
@@ -48,8 +48,8 @@ Full documentation is available at **[docs.twenty.com/developers/extend/apps](ht
## Troubleshooting
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
- Auth not working: run `yarn twenty remote add --local` to re-authenticate.
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty docker:logs`.
- Auth not working: run `yarn twenty remote:add --local` to re-authenticate.
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
## Contributing
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.5.1",
"version": "2.7.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
+7 -10
View File
@@ -18,11 +18,8 @@ const program = new Command(packageJson.name)
.option('-n, --name <name>', 'Application name')
.option('-d, --display-name <displayName>', 'Application display name')
.option('--description <description>', 'Application description')
.option(
'--workspace-url <workspaceUrl>',
'Twenty workspace URL (default: http://localhost:2020)',
)
.option('--api-url <apiUrl>', '[deprecated: use --workspace-url]')
.option('--url <url>', 'Twenty server URL (default: http://localhost:2020)')
.option('--api-url <apiUrl>', '[deprecated: use --url]')
.option(
'--authentication-method <method>',
'Authentication method: oauth or apiKey (default: apiKey for local, oauth for remote)',
@@ -35,7 +32,7 @@ const program = new Command(packageJson.name)
name?: string;
displayName?: string;
description?: string;
workspaceUrl?: string;
url?: string;
apiUrl?: string;
authenticationMethod?: AuthenticationMethod;
},
@@ -68,18 +65,18 @@ const program = new Command(packageJson.name)
if (options?.apiUrl) {
console.warn(
chalk.yellow(
'Warning: --api-url is deprecated. Use --workspace-url instead.',
),
chalk.yellow('Warning: --api-url is deprecated. Use --url instead.'),
);
}
const serverUrl = (options?.url ?? options?.apiUrl)?.replace(/\/+$/, '');
await new CreateAppCommand().execute({
directory,
name: options?.name,
displayName: options?.displayName,
description: options?.description,
workspaceUrl: options?.workspaceUrl ?? options?.apiUrl,
serverUrl,
authenticationMethod: options?.authenticationMethod,
});
},
@@ -49,19 +49,19 @@
## Best practice
It's highly recommended to create new app entities using `yarn twenty add`. These are the options:
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ------------------------------------ | ------------------------------------- |
| Object | `yarn twenty add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty add pageLayout` | `src/page-layouts/<name>.ts` |
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
This helps automatically generate required IDs etc.
@@ -11,8 +11,8 @@ Run `yarn twenty help` to list all available commands.
## Useful Commands
- `yarn twenty dev` - Start the development server and sync your app
- `yarn twenty server status` - Check the local Twenty server status
- `yarn twenty server start` - Start the local Twenty server
- `yarn twenty docker:status` - Check the local Twenty server status
- `yarn twenty docker:start` - Start the local Twenty server
- `yarn test` - Run integration tests
## Learn More
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
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' +
'Start a local server: yarn twenty docker:start\n' +
'Or set them in vitest env config.',
);
}
@@ -1,4 +1,11 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
Avatar,
IconBox,
IconHierarchy,
IconLayout,
IconSettingsAutomation,
} from 'twenty-sdk/ui';
import { useState } from 'react';
import {
@@ -30,7 +37,7 @@ const CATEGORIES = [
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'SERVERLESS FUNCT.',
label: 'LOGIC FUNCTION',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
@@ -59,15 +66,6 @@ const CATEGORIES = [
},
] as const;
const ItemIcon = ({ color }: { color: string }) => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2" y="2" width="5" height="5" rx="1" fill={color} />
<rect x="9" y="2" width="5" height="5" rx="1" fill={color} opacity="0.6" />
<rect x="2" y="9" width="5" height="5" rx="1" fill={color} opacity="0.6" />
<rect x="9" y="9" width="5" height="5" rx="1" fill={color} opacity="0.3" />
</svg>
);
const ArrowUpRight = ({ color = '#999' }: { color?: string }) => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
@@ -93,6 +91,18 @@ const CategoryCard = ({
}) => {
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
const CategoryIcon = () => {
if (title === 'Data model') {
return <IconHierarchy color={color} size={'20px'} />;
}
if (title === 'Logic') {
return <IconSettingsAutomation color={color} size={'20px'} />;
}
if (title === 'Layout') {
return <IconLayout color={color} size={'20px'} />;
}
};
return (
<div
style={{
@@ -111,8 +121,12 @@ const CategoryCard = ({
style={{
padding: '16px 20px',
background: `${color}22`,
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<CategoryIcon />
<span
style={{
fontSize: '16px',
@@ -154,7 +168,7 @@ const CategoryCard = ({
transition: 'background 0.15s',
}}
>
<ItemIcon color={color} />
<IconBox color={color} size={'20px'} />
<span
style={{
fontSize: '13px',
@@ -190,13 +204,11 @@ const MainPage = () => {
padding: '40px',
}}
>
{/*<Avatar
avatarUrl={getPublicAssetUrl('logo.svg')}
<Avatar
placeholder={APP_DISPLAY_NAME}
placeholderColorSeed={APP_DISPLAY_NAME}
type="squared"
size="xl"
/>*/}
/>
<span
style={{
fontSize: '24px',
@@ -220,7 +232,7 @@ const MainPage = () => {
You can now add content to your app.
</span>
<a
href="/settings/applications"
href="/settings/applications#installed"
style={{
display: 'inline-flex',
alignItems: 'center',
@@ -17,7 +17,7 @@ import {
DEV_API_URL,
serverStart,
} from 'twenty-sdk/cli';
import { isDefined } from 'twenty-shared/utils';
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
import {
getDockerInstallInstructions,
isDockerInstalled,
@@ -33,7 +33,7 @@ type CreateAppOptions = {
name?: string;
displayName?: string;
description?: string;
workspaceUrl?: string;
serverUrl?: string;
authenticationMethod?: AuthenticationMethod;
};
@@ -45,9 +45,9 @@ export class CreateAppCommand {
const { appName, appDisplayName, appDirectory, appDescription } =
this.getAppInfos(options);
const workspaceUrl = options.workspaceUrl ?? DEV_API_URL;
const serverUrl = options.serverUrl ?? DEV_API_URL;
const skipLocalInstance = workspaceUrl !== DEV_API_URL;
const skipLocalInstance = serverUrl !== DEV_API_URL;
if (!skipLocalInstance && !isDockerInstalled()) {
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
@@ -118,7 +118,7 @@ export class CreateAppCommand {
console.log('');
let authSucceeded = false;
let resolvedWorkspaceUrl = workspaceUrl;
let resolvedServerUrl = serverUrl;
let serverReady = skipLocalInstance;
if (!skipLocalInstance) {
@@ -126,7 +126,7 @@ export class CreateAppCommand {
const serverResult = await this.ensureDockerServer(dockerPullPromise);
if (isDefined(serverResult.url)) {
resolvedWorkspaceUrl = serverResult.url;
resolvedServerUrl = serverResult.url;
serverReady = true;
}
}
@@ -134,18 +134,16 @@ export class CreateAppCommand {
if (serverReady) {
this.logNextStep('Authenticating');
authSucceeded = await this.tryExistingAuth(resolvedWorkspaceUrl);
authSucceeded = await this.tryExistingAuth(resolvedServerUrl);
if (authSucceeded) {
this.logDetail('Reusing existing credentials');
} else if (authenticationMethod === 'oauth') {
this.logDetail('Starting OAuth flow');
authSucceeded =
await this.authenticateWithOAuth(resolvedWorkspaceUrl);
authSucceeded = await this.authenticateWithOAuth(resolvedServerUrl);
} else {
this.logDetail('Using development API key');
authSucceeded =
await this.authenticateWithDevKey(resolvedWorkspaceUrl);
authSucceeded = await this.authenticateWithDevKey(resolvedServerUrl);
}
}
@@ -165,10 +163,10 @@ export class CreateAppCommand {
}
if (syncSucceeded) {
await this.openMainPage(appDirectory, resolvedWorkspaceUrl);
await this.openMainPage(appDirectory, resolvedServerUrl);
}
this.logSuccess(appDirectory, resolvedWorkspaceUrl, authSucceeded);
this.logSuccess(appDirectory, resolvedServerUrl, authSucceeded);
} catch (error) {
console.error(
chalk.red('\nCreate application failed:'),
@@ -315,7 +313,7 @@ export class CreateAppCommand {
private async openMainPage(
appDirectory: string,
workspaceUrl: string,
serverUrl: string,
): Promise<void> {
try {
const configService = new ConfigService();
@@ -328,7 +326,7 @@ export class CreateAppCommand {
const [universalIdentifier, frontUrl] = await Promise.all([
this.readMainPageLayoutUniversalIdentifier(appDirectory),
this.resolveWorkspaceFrontUrl(workspaceUrl, token),
this.resolveWorkspaceFrontUrl(serverUrl, token),
]);
if (!universalIdentifier || !frontUrl) {
@@ -336,7 +334,7 @@ export class CreateAppCommand {
}
const pageLayoutId = await this.resolvePageLayoutId(
workspaceUrl,
serverUrl,
universalIdentifier,
token,
);
@@ -355,12 +353,12 @@ export class CreateAppCommand {
}
private async resolveWorkspaceFrontUrl(
workspaceUrl: string,
serverUrl: string,
token: string,
): Promise<string | null> {
const query = `{ currentWorkspace { workspaceUrls { subdomainUrl customUrl } } }`;
const response = await fetch(`${workspaceUrl}/metadata`, {
const response = await fetch(`${serverUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -389,7 +387,7 @@ export class CreateAppCommand {
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
return frontUrl?.replace(/\/+$/, '') ?? null;
return frontUrl ? normalizeUrl(frontUrl) : null;
}
private async readMainPageLayoutUniversalIdentifier(
@@ -410,13 +408,13 @@ export class CreateAppCommand {
}
private async resolvePageLayoutId(
workspaceUrl: string,
serverUrl: string,
universalIdentifier: string,
token: string,
): Promise<string | null> {
const query = `{ getPageLayouts { id universalIdentifier } }`;
const response = await fetch(`${workspaceUrl}/metadata`, {
const response = await fetch(`${serverUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -503,7 +501,7 @@ export class CreateAppCommand {
});
}
private async tryExistingAuth(workspaceUrl: string): Promise<boolean> {
private async tryExistingAuth(serverUrl: string): Promise<boolean> {
try {
const configService = new ConfigService();
const remoteNames = await configService.getRemotes();
@@ -511,7 +509,7 @@ export class CreateAppCommand {
for (const remoteName of remoteNames) {
const remoteConfig = await configService.getConfigForRemote(remoteName);
if (remoteConfig.apiUrl !== workspaceUrl) {
if (remoteConfig.apiUrl !== serverUrl) {
continue;
}
@@ -521,7 +519,7 @@ export class CreateAppCommand {
continue;
}
const response = await fetch(`${workspaceUrl}/metadata`, {
const response = await fetch(`${serverUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -555,11 +553,11 @@ export class CreateAppCommand {
}
}
private async authenticateWithDevKey(workspaceUrl: string): Promise<boolean> {
private async authenticateWithDevKey(serverUrl: string): Promise<boolean> {
try {
const result = await authLogin({
apiKey: DEV_API_KEY,
apiUrl: workspaceUrl,
apiUrl: serverUrl,
remote: 'local',
});
@@ -574,7 +572,7 @@ export class CreateAppCommand {
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote add --local` manually.',
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
),
);
@@ -582,7 +580,7 @@ export class CreateAppCommand {
} catch {
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote add --local` manually.',
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
),
);
@@ -598,21 +596,21 @@ export class CreateAppCommand {
}
}
private async authenticateWithOAuth(workspaceUrl: string): Promise<boolean> {
private async authenticateWithOAuth(serverUrl: string): Promise<boolean> {
try {
const remoteName = this.deriveRemoteName(workspaceUrl);
const remoteName = this.deriveRemoteName(serverUrl);
ConfigService.setActiveRemote(remoteName);
this.logDetail('Opening browser for OAuth...');
const result = await authLoginOAuth({ apiUrl: workspaceUrl });
const result = await authLoginOAuth({ apiUrl: serverUrl });
if (result.success) {
const configService = new ConfigService();
await configService.setDefaultRemote(remoteName);
this.logDetail(`Authenticated via OAuth to ${workspaceUrl}`);
this.logDetail(`Authenticated via OAuth to ${serverUrl}`);
return true;
}
@@ -620,7 +618,7 @@ export class CreateAppCommand {
console.log(
chalk.yellow(
` OAuth failed: ${result.error.message}\n` +
` Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
` Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
),
);
@@ -628,7 +626,7 @@ export class CreateAppCommand {
} catch {
console.log(
chalk.yellow(
` Authentication failed. Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
` Authentication failed. Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
),
);
@@ -638,7 +636,7 @@ export class CreateAppCommand {
private logSuccess(
appDirectory: string,
workspaceUrl: string,
serverUrl: string,
authSucceeded: boolean,
): void {
const dirName = basename(appDirectory);
@@ -656,9 +654,7 @@ export class CreateAppCommand {
if (!authSucceeded) {
console.log(chalk.white(` ${stepNumber}. Connect to a Twenty instance`));
console.log(
chalk.cyan(
' yarn twenty remote add --api-url <your-instance-url>\n',
),
chalk.cyan(' yarn twenty remote:add --url <your-instance-url>\n'),
);
stepNumber++;
}
@@ -668,7 +664,7 @@ export class CreateAppCommand {
stepNumber++;
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
console.log(chalk.cyan(` ${workspaceUrl}\n`));
console.log(chalk.cyan(` ${serverUrl}\n`));
console.log(
chalk.gray(
@@ -17,6 +17,7 @@ const UNIVERSAL_IDENTIFIERS_PATH = join(
'constants',
'universal-identifiers.ts',
);
const YARNRC_PATH = 'yarnrc.yml';
// Template content matching template/src/constants/universal-identifiers.ts
const TEMPLATE_UNIVERSAL_IDENTIFIERS = `export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
@@ -173,6 +174,27 @@ describe('copyBaseApplicationProject', () => {
expect(publicDirectoryContents).toHaveLength(0);
});
it('should rename yarnrc.yml to .yarnrc.yml in the scaffolded project', async () => {
await fs.writeFile(
join(testAppDirectory, YARNRC_PATH),
'nodeLinker: node-modules',
);
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
expect(await fs.pathExists(join(testAppDirectory, YARNRC_PATH))).toBe(
false,
);
expect(await fs.pathExists(join(testAppDirectory, '.yarnrc.yml'))).toBe(
true,
);
});
it('should handle empty description', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
@@ -22,7 +22,7 @@ export const copyBaseApplicationProject = async ({
onProgress?.('Copying base template');
await fs.copy(join(__dirname, './constants/template'), appDirectory);
onProgress?.('Configuring dotfiles (.gitignore, .github)');
onProgress?.('Configuring dotfiles (.gitignore, .github, .yarnrc.yml)');
await renameDotfiles({ appDirectory });
onProgress?.('Mirroring AGENTS.md to CLAUDE.md');
@@ -47,6 +47,7 @@ const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
const renames = [
{ from: 'gitignore', to: '.gitignore' },
{ from: 'github', to: '.github' },
{ from: 'yarnrc.yml', to: '.yarnrc.yml' },
];
for (const { from, to } of renames) {
@@ -28,6 +28,6 @@ export const getDockerInstallInstructions = (): string => {
' Then run this command again.',
'',
' Alternatively, connect to an existing Twenty instance:',
' npx create-twenty-app@latest my-twenty-app --workspace-url <your-instance-url>',
' npx create-twenty-app@latest my-twenty-app --url <your-instance-url>',
].join('\n');
};
@@ -93,7 +93,7 @@ yarn install
# Register your local Twenty server as a remote (interactive prompt).
# When asked for the URL use http://localhost:2021 and paste an API key
# from Settings -> Developers in the Twenty UI.
yarn twenty remote add
yarn twenty remote:add
# Build, install, and watch for changes.
yarn twenty dev
@@ -107,8 +107,8 @@ watching `src/`. Edit any file and the change is re-synced within seconds.
```bash
cd packages/twenty-apps/community/github-connector
yarn install
yarn twenty remote add # same prompts as above
yarn twenty install # builds and installs once
yarn twenty remote:add # same prompts as above
yarn twenty app:install # builds and installs once
```
## Configure authentication
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote add --api-url http://localhost:2020 --as local
yarn twenty remote:add --api-url http://localhost:2020 --as local
```
Then, start development mode to sync your app and watch for changes:
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote:status # Check auth status
yarn twenty remote:use # Set default remote
yarn twenty remote:list # List all configured remotes
yarn twenty remote:remove <name> # Remove a remote
# Application
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty dev:function:logs # Stream function logs
yarn twenty dev:function:exec # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
```
## Integration Tests
@@ -13,7 +13,7 @@ beforeAll(async () => {
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' +
'Start a local server: yarn twenty docker:start\n' +
'Or set them in vitest env config.',
);
}
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
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' +
'Start a local server: yarn twenty docker:start\n' +
'Or set them in vitest env config.',
);
}
@@ -10,8 +10,8 @@
"packageManager": "[email protected]",
"scripts": {
"dev": "twenty dev",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"exec": "twenty dev:fn-exec",
"uninstall": "twenty app:uninstall",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote add --api-url http://localhost:2020 --as local
yarn twenty remote:add --api-url http://localhost:2020 --as local
```
Then, start development mode to sync your app and watch for changes:
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote:status # Check auth status
yarn twenty remote:use # Set default remote
yarn twenty remote:list # List all configured remotes
yarn twenty remote:remove <name> # Remove a remote
# Application
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty dev:function:logs # Stream function logs
yarn twenty dev:function:exec # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
```
## LLMs instructions
@@ -13,7 +13,7 @@ beforeAll(async () => {
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' +
'Start a local server: yarn twenty docker:start\n' +
'Or set them in vitest env config.',
);
}
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
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' +
'Start a local server: yarn twenty docker:start\n' +
'Or set them in vitest env config.',
);
}
@@ -64,15 +64,15 @@ cd packages/twenty-apps/internal/twenty-linear
# For day-to-day development (publish + install + watch in one):
yarn twenty dev
# Manual publish flow (deploy registers the app, install activates it):
yarn twenty deploy
yarn twenty install
# Manual publish flow (publish registers the app, install activates it):
yarn twenty app:publish --private
yarn twenty app:install
```
`twenty dev` is recommended for iteration — it publishes, installs, and
watches for changes in one command. Use `twenty deploy` + `twenty install`
when you want to control each step separately (e.g. deploying to a
production server without auto-installing).
watches for changes in one command. Use `twenty app:publish --private` +
`twenty app:install` when you want to control each step separately (e.g.
deploying to a production server without auto-installing).
This serves as the reference implementation for Twenty's
`defineConnectionProvider({ type: 'oauth' })` flow — useful as a template
@@ -0,0 +1,18 @@
{
"$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,126 @@
# twenty-slack
Slack tools for **Twenty workflows** and **agents** (the same logic functions
are available as workflow steps and as tools where your deployment exposes
them). Uses the official
[`@slack/web-api`](https://github.com/slackapi/node-slack-sdk) `WebClient`
(Slack retries and error types).
## What you can do
Once the app is installed and Slack is **connected** (see **Twenty setup**
below):
- **Workflow steps** — post, update, or delete bot messages; send ephemerals;
add reactions; list channels. Pick a **workspace shared** or **just for me**
connection; steps run with that token.
- **Agents / AI** — when your Twenty instance surfaces app tools to the model,
these functions can be invoked the same way as other app logic functions.
- **Quick-send** — command menu **Send Slack message** opens a side panel to
pick a channel and post (same Slack connection as workflows).
## Tools
| Name | Slack API |
|------|-----------|
| `slack-post-message` | `chat.postMessage` |
| `slack-post-ephemeral-message` | `chat.postEphemeral` |
| `slack-update-message` | `chat.update` |
| `slack-delete-message` | `chat.delete` |
| `slack-add-reaction` | `reactions.add` |
| `slack-list-channels` | `conversations.list` |
### Workflow field names (for authors)
Fields use camelCase names in the step UI, for example **`slackChannelId`** (Slack channel or DM: **name** or **ID**), **`messageText`**, and **`messageTimestamp`** (Slacks per-message id — same value as tool output **`slackTs`** when chaining steps). Optional **`parentMessageTimestamp`** is only for **thread replies**. Post / update / ephemeral steps support optional **`messageFormat`**: **`markdown`** sends the body as Slack **`markdown_text`** (e.g. **`**bold**`**), **`plain`** sends **`text`** with markup disabled, omit uses Slacks default for **`text`**. Ephemeral steps use **`recipientSlackUserId`**; reactions use **`emojiName`** (Slack shortcode, for example `white_check_mark`). Updating a message uses **`newMessageText`**.
### Quick-send command menu item
This app also ships a global command menu item — **Send Slack message** — that opens a side-panel form to pick a channel (from `conversations.list`) and post a message via `chat.postMessage`. The form is backed by two HTTP routes exposed by the app:
- `GET /slack/channels` — lists channels visible to the bot (mirrors `slack-list-channels`).
- `POST /slack/messages` — posts a message (mirrors `slack-post-message`).
Both routes require an authenticated Twenty user and use the same shared Slack connection as the workflow tools.
### Prerequisites (Slack workspace)
- You can **install** the Slack app on a workspace you administer (or get an
admin to approve it).
- For **posting**: invite the bot to the channel, **or** rely on
**`chat:write.public`** (included in OAuth) to post to **public** channels
without joining — private channels still require membership.
- **`slack-list-channels`** and the quick-send channel picker need
**`channels:read`** / **`groups:read`** on the token (requested at connect
time; see below).
## Slack app setup
1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps)
(dedicated to this Twenty app — do not reuse for other Twenty apps).
2. **OAuth & Permissions****Bot Token Scopes**. Twenty uses Slacks **bot**
OAuth (`oauth/v2/authorize` with `scope=…`). You must add scopes here — not
only under **User Token Scopes** — or Slack will refuse install with *“doesnt
have a bot user to install”* until at least one bot scope exists.
The scopes **requested at connect time** are defined in
`src/connection-providers/slack-connection.ts` and must also appear under
**Bot Token Scopes** on the Slack app (Slack validates the set). Current
list:
- `channels:read``conversations.list` / channel picker (public)
- `chat:write` — post, update, delete, ephemeral
- `chat:write.public` — post to public channels without the bot joining
- `groups:read` — list private channels the bot is in
- `reactions:write` — add reactions
If you **add or remove** scopes in that file or in the Slack app, existing
installs must **re-authorize** (disconnect and **Add connection** again, or
reinstall the Slack app to the workspace) so the token picks up new scopes.
3. Set the **Redirect URL** on the Slack app to
`<YOUR_TWENTY_SERVER_URL>/apps/oauth/callback` — the same origin your
Twenty **server** uses for API routes (the callback is not served by the SPA
alone). Local monorepo dev often uses `http://localhost:3000` (confirm the
port your `twenty-server` / `SERVER_URL` actually uses).
**Slack “PKCE” app setting vs `localhost`:** If you turn on **PKCE** for the
Slack app under **OAuth & Permissions**, Slack treats `http://localhost…`
redirect URLs as **desktop** redirects. **Desktop redirects cannot request
bot scopes**, so OAuth will fail for this integration while you use a
localhost callback. For local dev you can either **leave Slacks PKCE
opt-in disabled** on that Slack app, or use an **`https://` redirect** (for
example a tunnel such as ngrok or Cloudflare Tunnel to your local server),
register that URL in the Slack app, and point Twentys `SERVER_URL` at the
same public base URL. See Slacks [Using
PKCE](https://docs.slack.dev/authentication/using-pkce) docs (this is
separate from Twenty sending a PKCE challenge on the authorize request).
4. Copy the Slack **Client ID** and **Client Secret**.
## Twenty setup
1. Register / install this app on your Twenty server (`twenty-slack`).
2. In **Settings → Applications → Twenty Slack**, open the **Application registration**
tab (admin-only) and set:
- `SLACK_CLIENT_ID`
- `SLACK_CLIENT_SECRET`
3. In the same app, open the **Connections** tab and click **Add connection**.
Choose **Just for me** or **Workspace shared**, then complete the Slack sign-in.
Once connected, workflow steps use the connection access token: a
**workspace** connection is preferred when present; otherwise the first
connection returned for the Slack provider is used (see
`src/logic-functions/utils/get-slack-connection.ts`).
## Development
```bash
cd packages/twenty-apps/internal/twenty-slack
yarn install
yarn lint
yarn test
```
Use `yarn twenty dev` from this directory to develop against a local Twenty
instance (see other internal apps in this monorepo).
@@ -0,0 +1,35 @@
{
"name": "twenty-slack",
"version": "0.1.0",
"description": "Slack workflow connector for Twenty",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [
"twenty-app"
],
"packageManager": "[email protected]",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@slack/web-api": "^7.8.0",
"twenty-sdk": "2.4.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"oxlint": "^0.16.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.9.3",
"vitest": "^3.1.1"
}
}
@@ -0,0 +1,26 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 127 127"
fill="none"
role="img"
>
<title>Slack</title>
<path
fill="#E01E5A"
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
/>
<path
fill="#36C5F0"
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
/>
<path
fill="#2EB67D"
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
/>
<path
fill="#ECB22E"
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,37 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APPLICATION_UNIVERSAL_IDENTIFIER,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Twenty Slack',
description:
'Connect Slack to Twenty. Each workspace member (or a shared workspace connection) can authenticate Slack; workflow steps then post messages, ephemerals, updates, deletes, and reactions on behalf of that connection.',
logoUrl: 'public/twenty-slack.svg',
author: 'Twenty',
category: 'Communication',
aboutDescription:
'Official Slack connector for Twenty CRM. Install a Slack app on api.slack.com, add the OAuth client ID and secret as server variables, then connect Slack per member or as a shared workspace connection. Use workflow actions to post, update, or delete messages, send ephemeral notes, and add reactions using the connected account.',
websiteUrl: 'https://docs.twenty.com/developers/extend/apps/getting-started',
termsUrl: 'https://www.twenty.com/terms',
emailSupport: '[email protected]',
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
serverVariables: {
SLACK_CLIENT_ID: {
description:
'OAuth client ID from your Slack app (api.slack.com/apps). Public in OAuth flows; only the client secret must stay confidential.',
isSecret: false,
isRequired: true,
},
SLACK_CLIENT_SECRET: {
description:
'OAuth client secret from your Slack app. Stored encrypted; never exposed in API responses.',
isSecret: true,
isRequired: true,
},
},
});
@@ -0,0 +1,17 @@
import { defineCommandMenuItem } from 'twenty-sdk/define';
import {
SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineCommandMenuItem({
universalIdentifier: SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER,
label: 'Send Slack message',
shortLabel: 'Slack message',
icon: 'IconBrandSlack',
isPinned: false,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier:
SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,673 @@
import {
useCallback,
useEffect,
useState,
type CSSProperties,
type SyntheticEvent,
} from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
closeSidePanel,
enqueueSnackbar,
unmountFrontComponent,
} from 'twenty-sdk/front-component';
import { themeCssVariables } from 'twenty-sdk/ui';
import { SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
type SlackChannel = {
id: string;
name: string;
isPrivate: boolean;
isArchived: boolean;
isMember: boolean;
numMembers: number;
topic: string;
purpose: string;
};
type ListChannelsResponse = {
success: boolean;
channels?: SlackChannel[];
error?: string;
message?: string;
};
type PostMessageResponse = {
success: boolean;
slackTs?: string;
error?: string;
message?: string;
};
type PostedMessage = {
channelId: string;
channelName: string;
slackTs?: string;
};
type MessageFormat = 'plain' | 'markdown';
const FORMAT_OPTIONS: { value: MessageFormat; label: string }[] = [
{ value: 'plain', label: 'Plain text' },
{ value: 'markdown', label: 'Markdown' },
];
const isMessageFormat = (value: string): value is MessageFormat =>
value === 'plain' || value === 'markdown';
const readSerializedValue = (
event: SyntheticEvent<HTMLElement>,
): string | undefined => {
const object = event as {
detail?: { value?: string };
value?: string;
target?: { value?: string };
};
if (typeof object.detail?.value === 'string') {
return object.detail.value;
}
if (typeof object.value === 'string') {
return object.value;
}
if (typeof object.target?.value === 'string') {
return object.target.value;
}
return undefined;
};
const onValueChange =
(setValue: (value: string) => void) =>
(event: SyntheticEvent<HTMLElement>) => {
const value = readSerializedValue(event);
if (typeof value === 'string') {
setValue(value);
}
};
const callAppRoute = async <TResponse,>(
path: string,
method: 'GET' | 'POST',
body?: Record<string, unknown>,
): Promise<TResponse> => {
const apiBaseUrl = process.env.TWENTY_API_URL;
const token =
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
if (!apiBaseUrl || !token) {
throw new Error('App is missing API URL or access token configuration.');
}
const response = await fetch(`${apiBaseUrl}/s${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
...(body ? { body: JSON.stringify(body) } : {}),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
let errorMessage: string | undefined;
try {
const parsed = JSON.parse(text) as {
messages?: string[];
message?: string;
error?: string;
};
errorMessage = parsed.messages?.[0] ?? parsed.message ?? parsed.error;
} catch {
// Body is not JSON; fall through to raw text or status message.
}
throw new Error(
errorMessage ??
(text.length > 0
? text.slice(0, 200)
: `Request failed with status ${response.status}.`),
);
}
return response.json() as Promise<TResponse>;
};
const sortChannels = (channels: SlackChannel[]): SlackChannel[] =>
[...channels].sort((a, b) => {
if (a.isMember !== b.isMember) {
return a.isMember ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
const formatChannelOptionLabel = (channel: SlackChannel): string => {
const prefix = channel.isPrivate ? '🔒' : '#';
const suffix = channel.isMember ? '' : ' — bot is not a member';
return `${prefix} ${channel.name}${suffix}`;
};
const getChannelHelperText = (
selectedChannel: SlackChannel | undefined,
): string => {
if (selectedChannel === undefined) {
return 'Pick a channel to post to.';
}
if (selectedChannel.isMember) {
return selectedChannel.isPrivate
? 'Private channel · bot is a member.'
: 'Public channel · bot is a member.';
}
return 'Bot is not a member of this channel — it must be invited before it can post.';
};
const getStyles = (): Record<string, CSSProperties> => ({
container: {
fontFamily: themeCssVariables.font.family,
fontSize: themeCssVariables.font.size.sm,
color: themeCssVariables.font.color.primary,
background: themeCssVariables.background.primary,
display: 'flex',
flexDirection: 'column',
height: '100%',
boxSizing: 'border-box',
},
header: {
padding: themeCssVariables.spacing[4],
borderBottom: `1px solid ${themeCssVariables.border.color.light}`,
flexShrink: 0,
},
headerTitleBlock: {
marginBottom: themeCssVariables.spacing[4],
},
pageTitle: {
fontSize: themeCssVariables.font.size.md,
fontWeight: themeCssVariables.font.weight.semiBold,
color: themeCssVariables.font.color.primary,
margin: 0,
},
pageSubtitle: {
fontSize: themeCssVariables.font.size.md,
fontWeight: themeCssVariables.font.weight.regular,
color: themeCssVariables.font.color.tertiary,
margin: 0,
marginTop: themeCssVariables.spacing[2],
lineHeight: 1.5,
},
body: {
flex: 1,
minHeight: 0,
padding: themeCssVariables.spacing[4],
display: 'flex',
flexDirection: 'column',
gap: themeCssVariables.spacing[4],
overflowY: 'auto',
},
field: {
display: 'flex',
flexDirection: 'column',
gap: themeCssVariables.spacing[1],
},
fieldGrowing: {
display: 'flex',
flexDirection: 'column',
gap: themeCssVariables.spacing[1],
flex: 1,
minHeight: 0,
},
label: {
fontSize: themeCssVariables.font.size.xs,
fontWeight: themeCssVariables.font.weight.medium,
color: themeCssVariables.font.color.secondary,
},
helperText: {
fontSize: themeCssVariables.font.size.xs,
color: themeCssVariables.font.color.tertiary,
},
select: {
appearance: 'none',
WebkitAppearance: 'none',
background: themeCssVariables.background.secondary,
border: `1px solid ${themeCssVariables.border.color.medium}`,
borderRadius: themeCssVariables.border.radius.sm,
padding: `${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[3]}`,
color: themeCssVariables.font.color.primary,
fontSize: themeCssVariables.font.size.sm,
fontFamily: themeCssVariables.font.family,
height: themeCssVariables.spacing[8],
cursor: 'pointer',
outline: 'none',
width: '100%',
boxSizing: 'border-box',
},
textarea: {
background: themeCssVariables.background.secondary,
border: `1px solid ${themeCssVariables.border.color.medium}`,
borderRadius: themeCssVariables.border.radius.sm,
padding: themeCssVariables.spacing[3],
color: themeCssVariables.font.color.primary,
fontSize: themeCssVariables.font.size.sm,
fontFamily: themeCssVariables.font.family,
lineHeight: 1.5,
width: '100%',
boxSizing: 'border-box',
outline: 'none',
resize: 'none',
flex: 1,
minHeight: themeCssVariables.spacing[20],
},
footer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: themeCssVariables.spacing[2],
padding: themeCssVariables.spacing[3],
borderTop: `1px solid ${themeCssVariables.border.color.light}`,
flexShrink: 0,
},
buttonBase: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: themeCssVariables.spacing[1],
height: themeCssVariables.spacing[8],
padding: `0 ${themeCssVariables.spacing[3]}`,
borderRadius: themeCssVariables.border.radius.sm,
fontSize: themeCssVariables.font.size.sm,
fontFamily: themeCssVariables.font.family,
fontWeight: themeCssVariables.font.weight.medium,
cursor: 'pointer',
border: '1px solid transparent',
boxSizing: 'border-box',
},
secondaryButton: {
background: themeCssVariables.background.secondary,
color: themeCssVariables.font.color.secondary,
border: `1px solid ${themeCssVariables.border.color.medium}`,
},
primaryButton: {
background: themeCssVariables.color.blue,
color: themeCssVariables.font.color.inverted,
},
primaryButtonDisabled: {
background: themeCssVariables.accent.accent4060,
cursor: 'not-allowed',
},
centeredState: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: themeCssVariables.spacing[4],
height: '100%',
boxSizing: 'border-box',
},
stateBlock: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: themeCssVariables.spacing[3],
maxWidth: '320px',
textAlign: 'center',
},
stateTitle: {
fontSize: themeCssVariables.font.size.md,
fontWeight: themeCssVariables.font.weight.medium,
color: themeCssVariables.font.color.primary,
margin: 0,
},
stateDescription: {
fontSize: themeCssVariables.font.size.sm,
color: themeCssVariables.font.color.tertiary,
margin: 0,
lineHeight: 1.5,
},
stateError: {
fontSize: themeCssVariables.font.size.sm,
color: themeCssVariables.font.color.danger,
margin: 0,
lineHeight: 1.5,
},
});
const SendMessageForm = () => {
const [channels, setChannels] = useState<SlackChannel[]>([]);
const [channelsLoading, setChannelsLoading] = useState(true);
const [channelsError, setChannelsError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [postedMessage, setPostedMessage] = useState<PostedMessage | null>(
null,
);
const [channelId, setChannelId] = useState('');
const [messageText, setMessageText] = useState('');
const [messageFormat, setMessageFormat] = useState<MessageFormat>('markdown');
const styles = getStyles();
const fetchChannels = useCallback(async () => {
try {
setChannelsError(null);
setChannelsLoading(true);
const result = await callAppRoute<ListChannelsResponse>(
'/slack/channels?limit=200',
'GET',
);
if (!result.success) {
setChannelsError(
result.error ?? result.message ?? 'Failed to load Slack channels.',
);
return;
}
const sorted = sortChannels(result.channels ?? []);
setChannels(sorted);
const firstMemberChannel = sorted.find((channel) => channel.isMember);
if (firstMemberChannel !== undefined) {
setChannelId(firstMemberChannel.id);
} else if (sorted.length === 1) {
setChannelId(sorted[0].id);
}
} catch (error) {
setChannelsError(
error instanceof Error
? error.message
: 'Failed to load Slack channels.',
);
} finally {
setChannelsLoading(false);
}
}, []);
useEffect(() => {
fetchChannels();
}, [fetchChannels]);
const handleClose = () => {
unmountFrontComponent();
closeSidePanel();
};
const handleSendAnother = () => {
setMessageText('');
setPostedMessage(null);
};
const handleSubmit = async () => {
const trimmedMessage = messageText.trim();
if (channelId === '' || trimmedMessage === '') {
return;
}
setSubmitting(true);
try {
const result = await callAppRoute<PostMessageResponse>(
'/slack/messages',
'POST',
{
slackChannelId: channelId,
messageText: trimmedMessage,
messageFormat,
},
);
if (!result.success) {
await enqueueSnackbar({
message:
result.error ?? result.message ?? 'Failed to send Slack message.',
variant: 'error',
});
return;
}
const channel = channels.find(
(channelItem) => channelItem.id === channelId,
);
setPostedMessage({
channelId,
channelName: channel?.name ?? channelId,
slackTs: result.slackTs,
});
await enqueueSnackbar({
message: `Message sent to #${channel?.name ?? channelId}`,
variant: 'success',
});
} catch (error) {
await enqueueSnackbar({
message:
error instanceof Error
? error.message
: 'Failed to send Slack message.',
variant: 'error',
});
} finally {
setSubmitting(false);
}
};
if (channelsLoading) {
return (
<div style={styles.centeredState}>
<div style={styles.stateBlock}>
<h2 style={styles.stateTitle}>Loading channels</h2>
<p style={styles.stateDescription}>
Fetching the list of Slack channels your bot can post to.
</p>
</div>
</div>
);
}
if (channelsError !== null) {
return (
<div style={styles.centeredState}>
<div style={styles.stateBlock}>
<h2 style={styles.stateTitle}>Couldn't load channels</h2>
<p style={styles.stateError}>{channelsError}</p>
<button
type="button"
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
onClick={fetchChannels}
>
Retry
</button>
</div>
</div>
);
}
if (channels.length === 0) {
return (
<div style={styles.centeredState}>
<div style={styles.stateBlock}>
<h2 style={styles.stateTitle}>No channels available</h2>
<p style={styles.stateDescription}>
The bot doesn't have access to any Slack channels yet. Invite it to
a channel and try again.
</p>
<button
type="button"
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
onClick={fetchChannels}
>
Refresh
</button>
</div>
</div>
);
}
if (postedMessage !== null) {
return (
<div style={styles.container}>
<div style={styles.header}>
<div style={styles.headerTitleBlock}>
<h2 style={styles.pageTitle}>Message sent</h2>
<p style={styles.pageSubtitle}>
{`Posted to #${postedMessage.channelName}.`}
</p>
</div>
</div>
<div style={styles.body}>
<p style={styles.stateDescription}>
Your message is now visible in Slack.
</p>
</div>
<div style={styles.footer}>
<button
type="button"
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
onClick={handleClose}
>
Close
</button>
<button
type="button"
style={{ ...styles.buttonBase, ...styles.primaryButton }}
onClick={handleSendAnother}
>
Send another
</button>
</div>
</div>
);
}
const trimmedMessage = messageText.trim();
const canSubmit = channelId !== '' && trimmedMessage !== '' && !submitting;
const selectedChannel = channels.find(
(channelItem) => channelItem.id === channelId,
);
const channelHelperText = getChannelHelperText(selectedChannel);
return (
<div style={styles.container}>
<div style={styles.header}>
<div style={styles.headerTitleBlock}>
<h2 style={styles.pageTitle}>Send Slack message</h2>
<p style={styles.pageSubtitle}>
Post a message to any channel your bot has access to.
</p>
</div>
</div>
<div style={styles.body}>
<div style={styles.field}>
<label htmlFor="slack-channel-select" style={styles.label}>
Channel
</label>
<select
id="slack-channel-select"
value={channelId}
onChange={onValueChange(setChannelId)}
style={styles.select}
disabled={submitting}
>
<option value="" disabled>
Select a channel
</option>
{channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{formatChannelOptionLabel(channel)}
</option>
))}
</select>
<span style={styles.helperText}>{channelHelperText}</span>
</div>
<div style={styles.field}>
<label htmlFor="slack-message-format-select" style={styles.label}>
Format
</label>
<select
id="slack-message-format-select"
value={messageFormat}
onChange={onValueChange((value) => {
if (isMessageFormat(value)) {
setMessageFormat(value);
}
})}
style={styles.select}
disabled={submitting}
>
{FORMAT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<span style={styles.helperText}>
{messageFormat === 'markdown'
? 'Markdown is rendered using Slack mrkdwn (bold, italics, code, links).'
: 'The message will be sent exactly as written, with no formatting.'}
</span>
</div>
<div style={styles.fieldGrowing}>
<label htmlFor="slack-message-textarea" style={styles.label}>
Message
</label>
<textarea
id="slack-message-textarea"
value={messageText}
onInput={onValueChange(setMessageText)}
onChange={onValueChange(setMessageText)}
placeholder="Write your message…"
style={styles.textarea}
disabled={submitting}
/>
</div>
</div>
<div style={styles.footer}>
<button
type="button"
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
onClick={handleClose}
disabled={submitting}
>
Cancel
</button>
<button
type="button"
style={{
...styles.buttonBase,
...styles.primaryButton,
...(canSubmit ? {} : styles.primaryButtonDisabled),
}}
onClick={handleSubmit}
disabled={!canSubmit}
>
{submitting ? 'Sending…' : 'Send message'}
</button>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'send-slack-message-form',
description:
'Form to send a Slack message to any channel the bot can post to, with plain-text or markdown formatting.',
component: SendMessageForm,
});
@@ -0,0 +1,26 @@
import { defineConnectionProvider } from 'twenty-sdk/define';
import { SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineConnectionProvider({
universalIdentifier: SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER,
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://slack.com/oauth/v2/authorize',
tokenEndpoint: 'https://slack.com/api/oauth.v2.access',
revokeEndpoint: 'https://slack.com/api/auth.revoke',
scopes: [
'channels:read',
'chat:write',
'chat:write.public',
'groups:read',
'reactions:write',
],
clientIdVariable: 'SLACK_CLIENT_ID',
clientSecretVariable: 'SLACK_CLIENT_SECRET',
tokenRequestContentType: 'form-urlencoded',
usePkce: true,
},
});
@@ -0,0 +1,38 @@
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'a8c47f21-3b9e-4d2a-8f61-9c0e7d4a2b51';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b7d36e10-2a8d-4c1b-9e50-8bfd6c3a1940';
export const SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER =
'8b6c6fd9-8d61-4b6f-9f25-3d92a0f2cc5b';
export const SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER =
'c6f25d09-1b7c-4e3f-ad42-7aec5b29830f';
export const SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER =
'd5e14c98-0a6b-4e2e-ac31-69db4a18720e';
export const SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER =
'e4d03b87-9a5b-4f1d-8b20-58ca3917620d';
export const SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER =
'f3c92a76-8b4a-4a09-ba19-47b9280651c9';
export const SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER =
'2a8c7f91-4d3e-5b6f-a7c8-9d0e1f2a3b4c';
export const SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER =
'3b7d8a92-5e4f-4c7a-b8d9-0e1f2a3b4c5d';
export const SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER =
'6e0c3d5f-9a4f-4f62-bc0e-3d5a7f9b4c6e';
export const SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER =
'7f1d4e60-ab50-4273-9d1f-4e6b8a0c5d7f';
export const SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'4c8a1b3f-7e2d-4f50-9a8c-1b3e5d7f2a4c';
export const SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER =
'5d9b2c4e-8f3e-4f50-ab9d-2c4f6e8a3b5d';
@@ -0,0 +1,37 @@
import { type SlackAddReactionInput } from 'src/logic-functions/types/slack-add-reaction-input.type';
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
export const slackAddReactionHandler = async (
parameters: SlackAddReactionInput,
): Promise<SlackToolResult> => {
const slackClientResult = await getSlackClient();
if (!slackClientResult.success) {
return {
success: false,
message: 'Slack is not connected',
error: slackClientResult.error,
};
}
const { client } = slackClientResult;
try {
await client.reactions.add({
channel: parameters.slackChannelId,
timestamp: parameters.messageTimestamp,
name: parameters.emojiName.trim(),
});
return {
success: true,
message: `Reaction "${parameters.emojiName.trim()}" added to the message.`,
slackTs: parameters.messageTimestamp,
channel: parameters.slackChannelId,
};
} catch (error) {
return slackToolFailure('Failed to add Slack reaction', error);
}
};
@@ -0,0 +1,36 @@
import { type SlackDeleteMessageInput } from 'src/logic-functions/types/slack-delete-message-input.type';
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
export const slackDeleteMessageHandler = async (
parameters: SlackDeleteMessageInput,
): Promise<SlackToolResult> => {
const slackClientResult = await getSlackClient();
if (!slackClientResult.success) {
return {
success: false,
message: 'Slack is not connected',
error: slackClientResult.error,
};
}
const { client } = slackClientResult;
try {
await client.chat.delete({
channel: parameters.slackChannelId,
ts: parameters.messageTimestamp,
});
return {
success: true,
message: 'Slack message deleted.',
slackTs: parameters.messageTimestamp,
channel: parameters.slackChannelId,
};
} catch (error) {
return slackToolFailure('Failed to delete Slack message', error);
}
};
@@ -0,0 +1,104 @@
import { isDefined } from 'twenty-shared/utils';
import {
type SlackChannelType,
type SlackListChannelsInput,
} from 'src/logic-functions/types/slack-list-channels-input.type';
import {
type SlackListChannelsResult,
type SlackListChannelsResultChannel,
} from 'src/logic-functions/types/slack-list-channels-result.type';
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 1000;
const SLACK_PAGE_SIZE = 200;
const SLACK_TYPES_BY_CHANNEL_TYPE = {
Public: 'public_channel',
Private: 'private_channel',
All: 'public_channel,private_channel',
} as const satisfies Record<SlackChannelType, string>;
export const slackListChannelsHandler = async (
parameters: SlackListChannelsInput,
): Promise<SlackListChannelsResult> => {
const slackClientResult = await getSlackClient();
if (!slackClientResult.success) {
return {
success: false,
channels: [],
count: 0,
error: slackClientResult.error,
};
}
const { client } = slackClientResult;
const limit = Math.min(
Math.max(parameters.limit ?? DEFAULT_LIMIT, 1),
MAX_LIMIT,
);
const excludeArchived = parameters.excludeArchived ?? true;
const types = SLACK_TYPES_BY_CHANNEL_TYPE[parameters.channelType ?? 'All'];
const channels: SlackListChannelsResultChannel[] = [];
let cursor: string | undefined;
try {
while (channels.length < limit) {
const remaining = limit - channels.length;
const response = await client.conversations.list({
types,
exclude_archived: excludeArchived,
limit: Math.min(SLACK_PAGE_SIZE, remaining),
cursor,
});
const page = response.channels ?? [];
for (const channel of page) {
if (channels.length >= limit) {
break;
}
if (!isDefined(channel.id) || !isDefined(channel.name)) {
continue;
}
channels.push({
id: channel.id,
name: channel.name,
isPrivate: channel.is_private === true,
isArchived: channel.is_archived === true,
isMember: channel.is_member === true,
numMembers: channel.num_members ?? 0,
topic: channel.topic?.value ?? '',
purpose: channel.purpose?.value ?? '',
});
}
const nextCursor = response.response_metadata?.next_cursor;
if (!isDefined(nextCursor) || nextCursor.length === 0) {
break;
}
cursor = nextCursor;
}
return {
success: true,
channels,
count: channels.length,
};
} catch (error) {
return {
success: false,
channels: [],
count: 0,
error: error instanceof Error ? error.message : 'Slack request failed',
};
}
};
@@ -0,0 +1,44 @@
import { type SlackPostEphemeralMessageInput } from 'src/logic-functions/types/slack-post-ephemeral-message-input.type';
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields';
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
export const slackPostEphemeralMessageHandler = async (
parameters: SlackPostEphemeralMessageInput,
): Promise<SlackToolResult> => {
const slackClientResult = await getSlackClient();
if (!slackClientResult.success) {
return {
success: false,
message: 'Slack is not connected',
error: slackClientResult.error,
};
}
const { client } = slackClientResult;
try {
const bodyFields = getSlackChatMessageBodyFields(
parameters.messageText,
parameters.messageFormat,
);
const postEphemeralPayload = {
channel: parameters.slackChannelId,
user: parameters.recipientSlackUserId,
...bodyFields,
};
await client.chat.postEphemeral(postEphemeralPayload);
return {
success: true,
message: 'Ephemeral message sent to the user in the channel.',
channel: parameters.slackChannelId,
};
} catch (error) {
return slackToolFailure('Failed to post Slack ephemeral message', error);
}
};
@@ -0,0 +1,52 @@
import { isDefined } from 'twenty-shared/utils';
import { type SlackPostMessageInput } from 'src/logic-functions/types/slack-post-message-input.type';
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields';
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
export const slackPostMessageHandler = async (
parameters: SlackPostMessageInput,
): Promise<SlackToolResult> => {
const slackClientResult = await getSlackClient();
if (!slackClientResult.success) {
return {
success: false,
message: 'Slack is not connected',
error: slackClientResult.error,
};
}
const { client } = slackClientResult;
const parentTimestamp = parameters.parentMessageTimestamp;
try {
const bodyFields = getSlackChatMessageBodyFields(
parameters.messageText,
parameters.messageFormat,
);
const data = await client.chat.postMessage({
channel: parameters.slackChannelId,
thread_ts:
isDefined(parentTimestamp) && parentTimestamp.trim().length > 0
? parentTimestamp.trim()
: undefined,
...bodyFields,
});
return {
success: true,
message: data.ts
? `Message posted to Slack (ts=${data.ts}).`
: 'Message posted to Slack.',
slackTs: data.ts,
channel: data.channel,
};
} catch (error) {
return slackToolFailure('Failed to post Slack message', error);
}
};
@@ -0,0 +1,45 @@
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
import { type SlackUpdateMessageInput } from 'src/logic-functions/types/slack-update-message-input.type';
import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields';
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
export const slackUpdateMessageHandler = async (
parameters: SlackUpdateMessageInput,
): Promise<SlackToolResult> => {
const slackClientResult = await getSlackClient();
if (!slackClientResult.success) {
return {
success: false,
message: 'Slack is not connected',
error: slackClientResult.error,
};
}
const { client } = slackClientResult;
try {
const bodyFields = getSlackChatMessageBodyFields(
parameters.newMessageText,
parameters.messageFormat,
);
const updatePayload = {
channel: parameters.slackChannelId,
ts: parameters.messageTimestamp,
...bodyFields,
};
const data = await client.chat.update(updatePayload);
return {
success: true,
message: 'Slack message updated.',
slackTs: data.ts,
channel: parameters.slackChannelId,
};
} catch (error) {
return slackToolFailure('Failed to update Slack message', error);
}
};
@@ -0,0 +1,27 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const slackAddReactionInputSchema: InputJsonSchema = {
type: 'object',
properties: {
slackChannelId: {
type: 'string',
label: 'Slack channel (name or ID)',
description:
'Channel where the message lives (Slack channel ID like C…).',
},
messageTimestamp: {
type: 'string',
label: 'Message timestamp',
description:
'The message to react to: use the **timestamp** from Slack (same value as `slackTs` after posting, or visible in “Copy link” URLs as the last number).',
},
emojiName: {
type: 'string',
label: 'Emoji name',
description:
'Emoji to add, **without** colons — for example `thumbsup`, `eyes`, or `white_check_mark`. Do not paste `:thumbsup:` style names.',
},
},
required: ['slackChannelId', 'messageTimestamp', 'emojiName'],
additionalProperties: false,
};
@@ -0,0 +1,21 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const slackDeleteMessageInputSchema: InputJsonSchema = {
type: 'object',
properties: {
slackChannelId: {
type: 'string',
label: 'Slack channel (name or ID)',
description:
'Channel that contains the message (Slack channel ID like C…).',
},
messageTimestamp: {
type: 'string',
label: 'Message timestamp',
description:
'Which message to remove: its **timestamp** from Slack (same value as `slackTs` when the bot posted it). Only messages sent by this bot can be deleted.',
},
},
required: ['slackChannelId', 'messageTimestamp'],
additionalProperties: false,
};
@@ -0,0 +1,29 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const slackListChannelsInputSchema: InputJsonSchema = {
type: 'object',
properties: {
channelType: {
type: 'string',
label: 'Channel type',
enum: ['Public', 'Private', 'All'],
description:
'Optional. Which kinds of channels to include. `All` returns both public and private channels. Default: `All`.',
},
excludeArchived: {
type: 'boolean',
label: 'Exclude archived',
description:
'Optional. Hide archived channels from the list. Default: `true`.',
},
limit: {
type: 'integer',
label: 'Max results',
minimum: 1,
maximum: 1000,
description:
'Optional. Cap the total number of channels returned across pagination. Default: `200`. Max: `1000`. Larger workspaces may not be fully covered within the function timeout.',
},
},
additionalProperties: false,
};
@@ -0,0 +1,35 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const slackPostEphemeralMessageInputSchema: InputJsonSchema = {
type: 'object',
properties: {
slackChannelId: {
type: 'string',
label: 'Slack channel (name or ID)',
description:
'Channel where the note appears (Slack channel ID like C…). The person you choose must already be in this channel, or they will not see the message.',
},
recipientSlackUserId: {
type: 'string',
label: 'Recipient (Slack user ID)',
description:
'Who can see this message: their Slack **member ID** (starts with U…). Only that person sees it; everyone else in the channel does not. In Slack: click their profile → three dots → Copy member ID (wording may vary by client).',
},
messageText: {
type: 'string',
label: 'Message',
multiline: true,
description:
'Short note shown only to the recipient above — for example a private hint, validation result, or next step.',
},
messageFormat: {
type: 'string',
label: 'Message format',
enum: ['plain', 'markdown'],
description:
'Optional. Same as **Send Slack Message**: `markdown` / `plain` / omit — see that actions `messageFormat` description.',
},
},
required: ['slackChannelId', 'recipientSlackUserId', 'messageText'],
additionalProperties: false,
};
@@ -0,0 +1,35 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const slackPostMessageInputSchema: InputJsonSchema = {
type: 'object',
properties: {
slackChannelId: {
type: 'string',
label: 'Slack channel (name or ID)',
description:
'Where to post: the Slack channel or DM ID (looks like C0123…, G… for private channels, or D… for DMs). In Slack: open the channel → channel name → scroll down → copy Channel ID. Using the ID is more reliable than a channel name.',
},
messageText: {
type: 'string',
label: 'Message',
multiline: true,
description:
'The message people will read in Slack. Keep it concise; very long messages may be rejected by Slack.',
},
parentMessageTimestamp: {
type: 'string',
label: 'Parent message timestamp',
description:
'Optional. Only when you want a **thread reply**: paste the **Message timestamp** from an earlier Slack step (the value returned as `slackTs` after posting). Leave empty for a normal new message at the bottom of the channel.',
},
messageFormat: {
type: 'string',
label: 'Message format',
enum: ['plain', 'markdown'],
description:
'Optional. `markdown`: send as Slack `markdown_text` so usual Markdown in `messageText` works (**bold**, lists, fenced code). `plain`: send as `text` with `mrkdwn: false` (no markup). Omit: send as `text` and let Slack use its default (legacy mrkdwn may still apply in `text`).',
},
},
required: ['slackChannelId', 'messageText'],
additionalProperties: false,
};
@@ -0,0 +1,35 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const slackUpdateMessageInputSchema: InputJsonSchema = {
type: 'object',
properties: {
slackChannelId: {
type: 'string',
label: 'Slack channel (name or ID)',
description:
'Channel that contains the message (Slack channel ID like C…).',
},
messageTimestamp: {
type: 'string',
label: 'Message timestamp',
description:
'Which message to edit: its **timestamp** from Slack (same value as `slackTs` returned when the bot posted the message). You cannot edit other peoples messages—only ones this bot sent.',
},
newMessageText: {
type: 'string',
label: 'New message',
multiline: true,
description:
'Replacement text shown in Slack instead of the old content.',
},
messageFormat: {
type: 'string',
label: 'Message format',
enum: ['plain', 'markdown'],
description:
'Optional. Same as **Send Slack Message**: `markdown` → `markdown_text`, `plain` → plain `text` without markup, omit → Slack default on `text`.',
},
},
required: ['slackChannelId', 'messageTimestamp', 'newMessageText'],
additionalProperties: false,
};
@@ -0,0 +1,35 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { slackAddReactionHandler } from 'src/logic-functions/handlers/slack-add-reaction-handler';
import { slackAddReactionInputSchema } from './schemas/slack-add-reaction-input.schema';
export default defineLogicFunction({
universalIdentifier: SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER,
name: 'slack-add-reaction',
description:
'Add an emoji reaction to a message (for example a checkmark as `white_check_mark`) so the channel can see status at a glance.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: slackAddReactionInputSchema,
},
workflowActionTriggerSettings: {
label: 'Add Slack Reaction',
icon: 'IconBrandSlack',
inputSchema: jsonSchemaToInputSchema(slackAddReactionInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
slackTs: { type: 'string' },
channel: { type: 'string' },
},
},
],
},
handler: slackAddReactionHandler,
});
@@ -0,0 +1,35 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { slackDeleteMessageHandler } from 'src/logic-functions/handlers/slack-delete-message-handler';
import { slackDeleteMessageInputSchema } from './schemas/slack-delete-message-input.schema';
export default defineLogicFunction({
universalIdentifier: SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER,
name: 'slack-delete-message',
description:
'Remove a message this bot sent (for example after a mistake or when a workflow is cancelled).',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: slackDeleteMessageInputSchema,
},
workflowActionTriggerSettings: {
label: 'Delete Slack Message',
icon: 'IconBrandSlack',
inputSchema: jsonSchemaToInputSchema(slackDeleteMessageInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
slackTs: { type: 'string' },
channel: { type: 'string' },
},
},
],
},
handler: slackDeleteMessageHandler,
});
@@ -0,0 +1,45 @@
import type { RoutePayload } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { slackListChannelsHandler } from 'src/logic-functions/handlers/slack-list-channels-handler';
import { type SlackChannelType } from 'src/logic-functions/types/slack-list-channels-input.type';
const VALID_CHANNEL_TYPES: SlackChannelType[] = ['Public', 'Private', 'All'];
const isSlackChannelType = (value: unknown): value is SlackChannelType =>
typeof value === 'string' &&
VALID_CHANNEL_TYPES.includes(value as SlackChannelType);
const handler = async (event: RoutePayload) => {
const params = event.queryStringParameters ?? {};
const rawLimit = params.limit;
const parsedLimit =
typeof rawLimit === 'string' && rawLimit.length > 0
? Number(rawLimit)
: undefined;
return slackListChannelsHandler({
channelType: isSlackChannelType(params.channelType)
? params.channelType
: undefined,
excludeArchived: params.excludeArchived === 'false' ? false : undefined,
limit:
typeof parsedLimit === 'number' && Number.isFinite(parsedLimit)
? parsedLimit
: undefined,
});
};
export default defineLogicFunction({
universalIdentifier: SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'slack-list-channels-route',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/slack/channels',
httpMethod: 'GET',
isAuthRequired: true,
},
});
@@ -0,0 +1,49 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { slackListChannelsHandler } from 'src/logic-functions/handlers/slack-list-channels-handler';
import { slackListChannelsInputSchema } from './schemas/slack-list-channels-input.schema';
export default defineLogicFunction({
universalIdentifier: SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER,
name: 'slack-list-channels',
description:
'List Slack channels visible to the bot, with basic metadata (id, name, privacy, archive state, membership, member count, topic, purpose). Useful for picking a channel by name or branching a workflow on whether the bot is a member.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: slackListChannelsInputSchema,
},
workflowActionTriggerSettings: {
label: 'List Slack Channels',
icon: 'IconBrandSlack',
inputSchema: jsonSchemaToInputSchema(slackListChannelsInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
channels: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
isPrivate: { type: 'boolean' },
isArchived: { type: 'boolean' },
isMember: { type: 'boolean' },
numMembers: { type: 'number' },
topic: { type: 'string' },
purpose: { type: 'string' },
},
},
},
count: { type: 'number' },
error: { type: 'string' },
},
},
],
},
handler: slackListChannelsHandler,
});
@@ -0,0 +1,34 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { slackPostEphemeralMessageHandler } from 'src/logic-functions/handlers/slack-post-ephemeral-message-handler';
import { slackPostEphemeralMessageInputSchema } from './schemas/slack-post-ephemeral-message-input.schema';
export default defineLogicFunction({
universalIdentifier: SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER,
name: 'slack-post-ephemeral-message',
description:
'Send a private-on-channel note: only the chosen teammate sees it in that channel (not a DM broadcast to everyone).',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: slackPostEphemeralMessageInputSchema,
},
workflowActionTriggerSettings: {
label: 'Send Slack Ephemeral Message',
icon: 'IconBrandSlack',
inputSchema: jsonSchemaToInputSchema(slackPostEphemeralMessageInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
channel: { type: 'string' },
},
},
],
},
handler: slackPostEphemeralMessageHandler,
});
@@ -0,0 +1,39 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/define';
import { SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { slackPostMessageHandler } from 'src/logic-functions/handlers/slack-post-message-handler';
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
const VALID_MESSAGE_FORMATS: SlackMessageBodyFormat[] = ['plain', 'markdown'];
const isSlackMessageBodyFormat = (
value: unknown,
): value is SlackMessageBodyFormat =>
typeof value === 'string' &&
VALID_MESSAGE_FORMATS.includes(value as SlackMessageBodyFormat);
const handler = async (event: RoutePayload) => {
const body = event.body as Record<string, unknown> | null;
return slackPostMessageHandler({
slackChannelId: (body?.slackChannelId as string | undefined) ?? '',
messageText: (body?.messageText as string | undefined) ?? '',
parentMessageTimestamp: body?.parentMessageTimestamp as string | undefined,
messageFormat: isSlackMessageBodyFormat(body?.messageFormat)
? body.messageFormat
: undefined,
});
};
export default defineLogicFunction({
universalIdentifier: SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'slack-post-message-route',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/slack/messages',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,35 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { slackPostMessageHandler } from 'src/logic-functions/handlers/slack-post-message-handler';
import { slackPostMessageInputSchema } from './schemas/slack-post-message-input.schema';
export default defineLogicFunction({
universalIdentifier: SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER,
name: 'slack-post-message',
description:
'Send a message to a Slack channel or DM. Optionally reply inside an existing thread using the parent messages timestamp from a previous step.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: slackPostMessageInputSchema,
},
workflowActionTriggerSettings: {
label: 'Send Slack Message',
icon: 'IconBrandSlack',
inputSchema: jsonSchemaToInputSchema(slackPostMessageInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
slackTs: { type: 'string' },
channel: { type: 'string' },
},
},
],
},
handler: slackPostMessageHandler,
});
@@ -0,0 +1,35 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { slackUpdateMessageHandler } from 'src/logic-functions/handlers/slack-update-message-handler';
import { slackUpdateMessageInputSchema } from './schemas/slack-update-message-input.schema';
export default defineLogicFunction({
universalIdentifier: SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER,
name: 'slack-update-message',
description:
'Change the text of a message this bot already sent. You need the channel ID and the messages timestamp from when it was posted.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: slackUpdateMessageInputSchema,
},
workflowActionTriggerSettings: {
label: 'Update Slack Message',
icon: 'IconBrandSlack',
inputSchema: jsonSchemaToInputSchema(slackUpdateMessageInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
slackTs: { type: 'string' },
channel: { type: 'string' },
},
},
],
},
handler: slackUpdateMessageHandler,
});
@@ -0,0 +1,5 @@
export type SlackAddReactionInput = {
slackChannelId: string;
messageTimestamp: string;
emojiName: string;
};
@@ -0,0 +1,4 @@
export type SlackDeleteMessageInput = {
slackChannelId: string;
messageTimestamp: string;
};
@@ -0,0 +1,7 @@
export type SlackChannelType = 'Public' | 'Private' | 'All';
export type SlackListChannelsInput = {
channelType?: SlackChannelType;
excludeArchived?: boolean;
limit?: number;
};
@@ -0,0 +1,17 @@
export type SlackListChannelsResultChannel = {
id: string;
name: string;
isPrivate: boolean;
isArchived: boolean;
isMember: boolean;
numMembers: number;
topic: string;
purpose: string;
};
export type SlackListChannelsResult = {
success: boolean;
channels: SlackListChannelsResultChannel[];
count: number;
error?: string;
};
@@ -0,0 +1 @@
export type SlackMessageBodyFormat = 'plain' | 'markdown';
@@ -0,0 +1,8 @@
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
export type SlackPostEphemeralMessageInput = {
slackChannelId: string;
recipientSlackUserId: string;
messageText: string;
messageFormat?: SlackMessageBodyFormat;
};
@@ -0,0 +1,8 @@
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
export type SlackPostMessageInput = {
slackChannelId: string;
messageText: string;
parentMessageTimestamp?: string;
messageFormat?: SlackMessageBodyFormat;
};
@@ -0,0 +1,7 @@
export type SlackToolResult = {
success: boolean;
message: string;
error?: string;
slackTs?: string;
channel?: string;
};
@@ -0,0 +1,8 @@
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
export type SlackUpdateMessageInput = {
slackChannelId: string;
messageTimestamp: string;
newMessageText: string;
messageFormat?: SlackMessageBodyFormat;
};
@@ -0,0 +1,19 @@
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
type SlackChatMessageBodyFields =
| { markdown_text: string; text?: never; mrkdwn?: never }
| { text: string; markdown_text?: never; mrkdwn?: boolean };
export const getSlackChatMessageBodyFields = (
messageText: string,
messageFormat: SlackMessageBodyFormat | undefined,
): SlackChatMessageBodyFields => {
switch (messageFormat) {
case 'markdown':
return { markdown_text: messageText };
case 'plain':
return { text: messageText, mrkdwn: false };
default:
return { text: messageText };
}
};
@@ -0,0 +1,18 @@
import { WebClient } from '@slack/web-api';
import { getSlackConnection } from 'src/logic-functions/utils/get-slack-connection';
export const getSlackClient = async (): Promise<
{ success: true; client: WebClient } | { success: false; error: string }
> => {
const connectionResult = await getSlackConnection();
if (!connectionResult.success) {
return connectionResult;
}
return {
success: true,
client: new WebClient(connectionResult.accessToken),
};
};
@@ -0,0 +1,29 @@
import { listConnections } from 'twenty-sdk/logic-function';
export const getSlackConnection = async (): Promise<
{ success: true; accessToken: string } | { success: false; error: string }
> => {
try {
const connections = await listConnections({ providerName: 'slack' });
const connection =
connections.find((item) => item.visibility === 'workspace') ??
connections[0];
if (!connection) {
return {
success: false,
error:
'Slack is not connected. Open the Slack app settings and click "Add connection" first.',
};
}
return { success: true, accessToken: connection.accessToken };
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Failed to load Slack connection.';
return { success: false, error: message };
}
};
@@ -0,0 +1,10 @@
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
export const slackToolFailure = (
message: string,
error: unknown,
): SlackToolResult => ({
success: false,
message,
error: error instanceof Error ? error.message : 'Slack request failed',
});
@@ -0,0 +1,21 @@
import { defineRole } from 'twenty-sdk/define';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Twenty Slack tools role',
description:
'No CRM data access — tools only forward requests to Slack using the configured connected account.',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [],
fieldPermissions: [],
permissionFlags: [],
});
@@ -0,0 +1,31 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2020",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
"version": "2.5.1",
"version": "2.7.0",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
@@ -1,2 +1,2 @@
// Stub — overwritten by `twenty build` or `twenty dev`
// Stub — overwritten by `twenty dev:build` or `twenty dev`
export type CoreSchema = {};
@@ -2998,8 +2998,6 @@ type Query {
myMessageFolders(messageChannelId: UUID): [MessageFolder!]!
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
connectedAccountById(id: UUID!): ConnectedAccountPublicDTO
connectedAccounts: [ConnectedAccountPublicDTO!]!
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
webhooks: [Webhook!]!
webhook(id: UUID!): Webhook
@@ -2598,8 +2598,6 @@ export interface Query {
myMessageFolders: MessageFolder[]
myMessageChannels: MessageChannel[]
myConnectedAccounts: ConnectedAccountPublicDTO[]
connectedAccountById?: ConnectedAccountPublicDTO
connectedAccounts: ConnectedAccountPublicDTO[]
myCalendarChannels: CalendarChannel[]
webhooks: Webhook[]
webhook?: Webhook
@@ -5631,8 +5629,6 @@ export interface QueryGenqlSelection{
myMessageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
connectedAccountById?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
connectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
webhooks?: WebhookGenqlSelection
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
@@ -6176,18 +6176,6 @@ export default {
"myConnectedAccounts": [
269
],
"connectedAccountById": [
269,
{
"id": [
3,
"UUID!"
]
}
],
"connectedAccounts": [
269
],
"myCalendarChannels": [
300,
{
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "../../node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf",
"printWidth": 80,
"sortPackageJson": false,
"ignorePatterns": [
"dist/**",
"build/**",
".next/**",
"coverage/**",
"generated/**",
".cache/**",
"node_modules/**",
],
}
+26 -14
View File
@@ -4,7 +4,10 @@
"ignorePatterns": ["node_modules", ".next", "storybook-static"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"no-console": [
"warn",
{ "allow": ["group", "groupCollapsed", "groupEnd"] }
],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
@@ -13,22 +16,31 @@
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"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-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",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
]
}
}
+8
View File
@@ -3,18 +3,21 @@
## What Was Migrated
### Documentation Files
- **69 MDX files** copied from twenty-website to twenty-docs
- **45 User Guide articles**
- **22 Developer documentation articles**
- **2 Getting Started guides** (existing)
### Images & Assets
- **81 images** copied to `public/images/`
- User guide screenshots
- Developer documentation images
- Logo and branding assets
### Navigation Structure
- Complete `mint.json` configuration with tabs and nested navigation
- User Guide tab with 11 sections
- Developers tab with 6 sections
@@ -22,12 +25,15 @@
## Components Converted
### Custom Components → Mintlify Equivalents
- `<ArticleWarning>``<Warning>`
- `<ArticleLink href="...">text</ArticleLink>``[text](...)`
- `<ArticleEditContent>` → Removed (not needed)
### Still Need Manual Review
Some components may need additional conversion:
- `<ArticleTabs>` - Mintlify uses `<Tabs>` component
- Embedded iframes/videos - May need adjustment
- Custom styled elements - Review for Mintlify compatibility
@@ -63,6 +69,7 @@ packages/twenty-docs/
## Testing
Start the local Mintlify dev server:
```bash
npx nx run twenty-docs:dev
```
@@ -72,6 +79,7 @@ Open http://localhost:3000 to preview all migrated documentation.
## Deployment
To deploy to Mintlify:
1. Push changes to GitHub
2. Connect the repo in Mintlify dashboard
3. Set subdirectory to `packages/twenty-docs`
+2
View File
@@ -9,6 +9,7 @@ Visit the documentation at [docs.twenty.com](https://docs.twenty.com)
## 📚 Content
This repository contains:
- **User Guide** (46 pages) - Complete guide for Twenty users
- **Developers** (24 pages) - Technical documentation for developers
- **Twenty UI** (25 pages) - UI component library documentation
@@ -57,6 +58,7 @@ Your content here...
1. Place images in the `/images/` directory
2. Reference them in MDX: `![Alt text](/images/your-image.png)`
3. Or use Mintlify Frame component:
```mdx
<Frame>
<img src="/images/your-image.png" alt="Description" />
+6 -6
View File
@@ -12,8 +12,8 @@
opacity: 0.85 !important;
}
:is(.dark, [data-theme="dark"]) #topbar-cta-button a,
:is(.dark, [data-theme="dark"]) #topbar-cta-button a span {
:is(.dark, [data-theme='dark']) #topbar-cta-button a,
:is(.dark, [data-theme='dark']) #topbar-cta-button a span {
background-color: #ffffff !important;
color: #141414 !important;
}
@@ -35,14 +35,14 @@
* We use :has() to scope the rule to only the sidebar group that contains
* the developers/introduction link, so other tabs are unaffected.
*/
div:has(> .sidebar-group a[href="/developers/introduction"]),
div:has(> .sidebar-group a[href="/user-guide/introduction"]) {
div:has(> .sidebar-group a[href='/developers/introduction']),
div:has(> .sidebar-group a[href='/user-guide/introduction']) {
display: none;
}
/* Remove the top margin on the group that follows the hidden overview group,
so it sits flush at the top of the sidebar without a gap. */
div:has(> .sidebar-group a[href="/developers/introduction"]) + div,
div:has(> .sidebar-group a[href="/user-guide/introduction"]) + div {
div:has(> .sidebar-group a[href='/developers/introduction']) + div,
div:has(> .sidebar-group a[href='/user-guide/introduction']) + div {
margin-top: 0 !important;
}
@@ -2,7 +2,6 @@
title: Best Practices
---
This document outlines the best practices you should follow when working on the backend.
## Follow a modular approach
@@ -21,4 +20,3 @@ You should also expose services that you want to use in other modules. Exposing
When you declare a variable as `any`, TypeScript's type checker doesn't perform any type checking, making it possible to assign any type of values to the variable. TypeScript uses type inference to determine the type of variable based on the value. By declaring it as `any`, TypeScript can no longer infer the type. This makes it hard to catch type-related errors during development, leading to runtime errors and makes the code less maintainable, less reliable, and harder to understand for others.
This is why everything should have a type. So if you create a new object with a first name and last name, you should create an interface or type that contains a first name and last name that defines the shape of the object you are manipulating.
@@ -2,21 +2,22 @@
title: Custom Objects
---
Objects are structures that allow you to store data (records, attributes, and values) specific to an organization. Twenty provides both standard and custom objects.
Standard objects are in-built objects with a set of attributes available for all users. Examples of standard objects in Twenty include Company and Person. Standard objects have standard fields that are also available for all Twenty users, like Company.displayName.
Custom objects are objects that you can create to store information that is unique to your organization. They are not built-in; members of your workspace can create and customize custom objects to hold information that standard objects aren't suitable for.
## High-level schema
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/custom-object-schema.png" alt="High level schema" />
<div style={{ textAlign: 'center' }}>
<img
src="/images/docs/server/custom-object-schema.png"
alt="High level schema"
/>
</div>
<br/>
<br />
## How it works
@@ -27,15 +28,21 @@ Custom objects come from metadata tables that determine the shape, name, and typ
- **Field**: Outlines an Object's fields and connects to the Object.
To add a custom object, the workspaceMember will query the /metadata API. This updates the metadata accordingly and computes a GraphQL schema based on the metadata, storing it in a GQL cache for later use.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/add-custom-objects.jpeg" alt="Query the /metadata API to add custom objects" />
<div style={{ textAlign: 'center' }}>
<img
src="/images/docs/server/add-custom-objects.jpeg"
alt="Query the /metadata API to add custom objects"
/>
</div>
<br/>
<br />
To fetch data, the process involves making queries through the /graphql endpoint and passing them through the Query Resolver.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/custom-object-schema.png" alt="Query the /graphql endpoint to fetch data" />
<div style={{ textAlign: 'center' }}>
<img
src="/images/docs/server/custom-object-schema.png"
alt="Query the /graphql endpoint to fetch data"
/>
</div>
@@ -2,7 +2,6 @@
title: Feature Flags
---
Feature flags are used to hide experimental features. For Twenty, they are set on workspace level and not on a user level.
## Adding a new feature flag
@@ -24,8 +23,6 @@ enum FeatureFlagKeys {
}
```
To apply a feature flag on a **backend** feature use:
```ts
@@ -40,12 +37,10 @@ To apply a feature flag on a **frontend** feature use:
const isFeatureNameEnabled = useIsFeatureEnabled('IS_FEATURENAME_ENABLED');
```
## Configure feature flags for the deployment
Change the corresponding record in the Table `core.featureFlag`:
| id | key | workspaceId | value |
|----------|--------------------------|---------------|--------|
| Random | `IS_FEATURENAME_ENABLED` | WorkspaceID | `true` |
| id | key | workspaceId | value |
| ------ | ------------------------ | ----------- | ------ |
| Random | `IS_FEATURENAME_ENABLED` | WorkspaceID | `true` |
@@ -3,7 +3,6 @@ title: Folder Architecture
info: A detailed look into our server folder architecture
---
The backend directory structure is as follows:
```
@@ -84,7 +83,6 @@ workspace
└───workspace.factory.ts
```
The root of the workspace directory includes the `workspace.factory.ts`, a file containing the `createGraphQLSchema` function. This function generates workspace-specific schema by using the metadata to tailor a schema for individual workspaces. By separating the schema and resolver construction, we use the `makeExecutableSchema` function, which combines these discrete elements.
This strategy is not just about organization, but also helps with optimization, such as caching generated type definitions to enhance performance and scalability.
@@ -96,6 +94,7 @@ Generates the GraphQL schema, and includes:
#### Factories:
Specialised constructors to generate GraphQL-related constructs.
- The type.factory translates field metadata into GraphQL types using `TypeMapperService`.
- The type-definition.factory creates GraphQL input or output objects derived from `objectMetadata`.
@@ -124,4 +123,3 @@ Each factory in this directory is responsible for producing a distinct resolver
### Workspace Query Runner
Runs the generated queries on the database and parses the result.
@@ -2,7 +2,6 @@
title: Message Queue
---
Queues facilitate async operations to be performed. They can be used for performing background tasks such as sending a welcome email on register.
Each use case will have its own queue class extended from `MessageQueueServiceBase`.
@@ -16,9 +15,12 @@ Currently, we only support `bull-mq`[bull-mq](https://bullmq.io/) as the queue d
4. Add worker class with token based injection just like producer.
### Example usage
```ts
class Resolver {
constructor(@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService) {}
constructor(
@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService,
) {}
async onSomeAction() {
//business logic
@@ -28,7 +30,9 @@ class Resolver {
//async worker
class CustomWorker {
constructor(@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService) {
constructor(
@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService,
) {
this.initWorker();
}
@@ -39,5 +43,3 @@ class CustomWorker {
}
}
```
@@ -1,9 +1,8 @@
---
title: Backend Commands
icon: "terminal"
icon: 'terminal'
---
## Useful commands
These commands should be executed from packages/twenty-server folder.
@@ -33,6 +32,7 @@ npx nx run twenty-server:lint # pass --fix to fix lint errors
npx nx run twenty-server:test:unit # run unit tests
npx nx run twenty-server:test:integration # run integration tests
```
Note: you can run `npx nx run twenty-server:test:integration:with-db-reset` in case you need to reset the database before running the integration tests.
### Resetting the database
@@ -59,26 +59,29 @@ Prisma was the first ORM we used. But in order to allow users to create custom f
Here's what the tech stack now looks like.
**Core**
- [NestJS](https://nestjs.com/)
- [TypeORM](https://typeorm.io/)
- [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server)
**Database**
- [Postgres](https://www.postgresql.org/)
**Third-party integrations**
- [Sentry](https://sentry.io/welcome/) for tracking bugs
**Testing**
- [Jest](https://jestjs.io/)
**Tooling**
- [Yarn](https://yarnpkg.com/)
- [Oxlint](https://oxc.rs/docs/guide/usage/linter.html)
**Development**
- [AWS EKS](https://aws.amazon.com/eks/)
@@ -2,7 +2,6 @@
title: Zapier App
---
Effortlessly sync Twenty with 3000+ apps using [Zapier](https://zapier.com/). Automate tasks, boost productivity, and supercharge your customer relationships!
## About Zapier
@@ -36,6 +35,7 @@ From the `packages/twenty-zapier` folder, run:
```bash
cp .env.example .env
```
Run the application locally, go to [http://localhost:3000/settings/api-webhooks](http://localhost:3000/settings/api-webhooks), and generate an API key.
Replace the **YOUR_API_KEY** value in the `.env` file with the API key you just generated.
@@ -49,28 +49,37 @@ Make sure to run `yarn build` before any `zapier` command.
</Warning>
### Test
```bash
yarn test
```
### Lint
```bash
yarn format
```
### Watch and compile as you edit code
```bash
yarn watch
```
### Validate your Zapier app
```bash
yarn validate
```
### Deploy your Zapier app
```bash
yarn deploy
```
### List all Zapier CLI commands
```bash
zapier
```
@@ -1,10 +1,9 @@
---
title: Bugs, Requests & Pull Requests
icon: "bug"
icon: 'bug'
info: Report issues, request features, and contribute code
---
## Reporting Bugs
To report a bug, please [create an issue on GitHub](https://github.com/twentyhq/twenty/issues/new).
@@ -29,12 +28,14 @@ Contributing code to Twenty starts with a pull request (PR).
1. Fork the repository on GitHub
2. Clone your fork:
```bash
git clone https://github.com/YOUR_USERNAME/twenty.git
cd twenty
```
3. Add upstream remote:
```bash
git remote add upstream https://github.com/twentyhq/twenty.git
```
@@ -46,6 +47,7 @@ git checkout -b feature/your-feature-name
```
Use descriptive branch names:
- `feature/add-export-button`
- `fix/login-redirect-issue`
- `docs/update-api-guide`
@@ -60,6 +62,7 @@ Use descriptive branch names:
### Submit Your PR
1. Push your branch:
```bash
git push origin feature/your-feature-name
```
@@ -74,4 +77,3 @@ git push origin feature/your-feature-name
- [ ] Tests pass locally
- [ ] Documentation is updated
- [ ] PR description explains the changes
@@ -1,9 +1,8 @@
---
title: Best Practices
icon: "star"
icon: 'star'
---
This document outlines the best practices you should follow when working on the frontend.
## State management
@@ -34,13 +33,10 @@ export const MyComponent = () => {
return (
<div>
<input
value={myAtom}
onChange={(e) => setMyAtom(e.target.value)}
/>
<input value={myAtom} onChange={(e) => setMyAtom(e.target.value)} />
</div>
);
}
};
```
### Do not use `useRef` to store state
@@ -91,7 +87,7 @@ export const PageComponent = () => {
const [someDependency] = useAtomState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
if (someDependency !== data) {
setData(someDependency);
}
}, [someDependency]);
@@ -99,9 +95,7 @@ export const PageComponent = () => {
return <div>{data}</div>;
};
export const App = () => (
<PageComponent />
);
export const App = () => <PageComponent />;
```
```tsx
@@ -118,7 +112,7 @@ export const PageData = () => {
const [someDependency] = useAtomState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
if (someDependency !== data) {
setData(someDependency);
}
}, [someDependency]);
@@ -169,6 +163,7 @@ Make sure you remove all `console.logs` before pushing the code to production.
Variable names ought to precisely depict the purpose or function of the variable.
#### The issue with generic names
Generic names in programming are not ideal because they lack specificity, leading to ambiguity and reduced code readability. Such names fail to convey the variable or function's purpose, making it challenging for developers to understand the code's intent without deeper investigation. This can result in increased debugging time, higher susceptibility to errors, and difficulties in maintenance and collaboration. Meanwhile, descriptive naming makes the code self-explanatory and easier to navigate, enhancing code quality and developer productivity.
```tsx
@@ -184,7 +179,7 @@ const [email, setEmail] = useState('');
#### Some words to avoid in variable names
- dummy
- dummy
### Event handlers
@@ -287,14 +282,11 @@ When importing, opt for the designated aliases rather than specifying complete o
```
**Usage**
```tsx
// ❌ Bad, specifies the entire relative path
import {
CatalogDecorator
} from '../../../../../testing/decorators/CatalogDecorator';
import {
ComponentDecorator
} from '../../../../../testing/decorators/ComponentDecorator';
import { CatalogDecorator } from '../../../../../testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '../../../../../testing/decorators/ComponentDecorator';
```
```tsx
@@ -323,9 +315,6 @@ const validationSchema = z
type Form = z.infer<typeof validationSchema>;
```
## Breaking Changes
Always perform thorough manual testing before proceeding to guarantee that modifications havent caused disruptions elsewhere, given that tests have not yet been extensively integrated.
@@ -1,10 +1,9 @@
---
title: Folder Architecture
icon: "folder-tree"
icon: 'folder-tree'
info: A detailed look into our folder architecture
---
In this guide, you will explore the details of the project directory structure and how it contributes to the organization and maintainability of Twenty.
By following this folder architecture convention, it's easier to find the files related to specific features and ensure that the application is scalable and maintainable.
@@ -94,7 +93,6 @@ React's built-in state management still handles state within a component.
Should just contain reusable pure functions. Otherwise, create custom hooks in the `hooks` folder.
## UI
Contains all the reusable UI components used in the application.
@@ -110,4 +108,3 @@ You can import other module code from any module except for the `ui` folder. Thi
### Internal
Each part (hooks, states, ...) of a module can have an `internal` folder, which contains parts that are just used within the module.
@@ -1,9 +1,8 @@
---
title: Frontend Commands
icon: "terminal"
icon: 'terminal'
---
## Useful commands
### Starting the app
@@ -17,7 +16,9 @@ npx nx start twenty-front
```bash
npx nx run twenty-front:graphql:generate --configuration=metadata
```
OR
```bash
npx nx run twenty-front:graphql:generate
```
@@ -88,5 +89,3 @@ See [best practices](/developers/contribute/capabilities/frontend-development/be
Jest is mainly for testing utility functions, and not components themselves.
Storybook is for testing the behavior of isolated components, as well as displaying the design system.
@@ -2,7 +2,6 @@
title: Hotkeys
---
## Introduction
When you need to listen to a hotkey, you would normally use the `onKeyDown` event listener.
@@ -20,6 +19,7 @@ You place it in a component, and it will listen to the hotkeys only when the com
## How to listen for hotkeys in practice?
There are two steps involved in setting up hotkey listening :
1. Set the [hotkey scope](#what-is-a-hotkey-scope-) that will listen to hotkeys
2. Use the `useScopedHotkeys` hook to listen to hotkeys
@@ -28,6 +28,7 @@ Setting up hotkey scopes is required even in simple pages, because other UI elem
## Use cases for hotkeys
In general, you'll have two use cases that require hotkeys :
1. In a page or a component mounted in a page
2. In a modal-type component that takes the focus due to a user action
@@ -88,9 +89,7 @@ const ExamplePageWithModal = () => {
const handleOpenModalClick = () => {
// 1. Set the hotkey scope when user opens the modal
setShowModal(true);
setHotkeyScopeAndMemorizePreviousScope(
ExampleHotkeyScopes.ExampleModal,
);
setHotkeyScopeAndMemorizePreviousScope(ExampleHotkeyScopes.ExampleModal);
};
const handleModalClose = () => {
@@ -99,11 +98,13 @@ const ExamplePageWithModal = () => {
goBackToPreviousHotkeyScope();
};
return <div>
<h1>My page with a modal</h1>
<button onClick={handleOpenModalClick}>Open modal</button>
{showModal && <MyModalComponent onClose={handleModalClose} />}
</div>;
return (
<div>
<h1>My page with a modal</h1>
<button onClick={handleOpenModalClick}>Open modal</button>
{showModal && <MyModalComponent onClose={handleModalClose} />}
</div>
);
};
```
@@ -117,7 +118,7 @@ const MyDropdownComponent = ({ onClose }: { onClose: () => void }) => {
useScopedHotkeys(
Key.Escape,
() => {
onClose()
onClose();
},
ExampleHotkeyScopes.ExampleModal,
);
@@ -6,4 +6,3 @@ description: Browse Twenty's UI component library
View our complete component library and documentation in Storybook.
[Open Storybook →](https://storybook.twenty.com)
@@ -1,9 +1,8 @@
---
title: Style Guide
icon: "paintbrush"
icon: 'paintbrush'
---
This document includes the rules to follow when writing code.
The goal here is to have a consistent codebase, which is easy to read and easy to maintain.
@@ -33,7 +32,7 @@ export default MyComponent;
// ✅ Good, easy to read, easy to import with code completion
export function MyComponent() {
return <div>Hello World</div>;
};
}
```
### Props
@@ -51,7 +50,9 @@ type MyComponentProps = {
name: string;
};
export const MyComponent = ({ name }: MyComponentProps) => <div>Hello {name}</div>;
export const MyComponent = ({ name }: MyComponentProps) => (
<div>Hello {name}</div>
);
```
#### Refrain from using `React.FC` or `React.FunctionComponent` to define prop types
@@ -92,7 +93,7 @@ Avoid using single variable prop spreading in JSX elements, like `{...props}`. T
*/
const MyComponent = (props: OwnProps) => {
return <OtherComponent {...props} />;
}
};
```
```tsx
@@ -105,6 +106,7 @@ const MyComponent = ({ prop1, prop2, prop3 }: MyComponentProps) => {
```
Rationale:
- At a glance, it's clearer which props the code passes down, making it easier to understand and maintain.
- It helps to prevent tight coupling between components via their props.
- Linting tools make it easier to identify misspelled or unused props when you list props explicitly.
@@ -158,9 +160,9 @@ You can see why TypeScript recommends avoiding enums [here](https://www.typescri
```tsx
// ❌ Bad, utilizes an enum
enum Color {
Red = "red",
Green = "green",
Blue = "blue",
Red = 'red',
Green = 'green',
Blue = 'blue',
}
let color = Color.Red;
@@ -169,7 +171,7 @@ let color = Color.Red;
```tsx
// ✅ Good, utilizes a string literal
let color: "red" | "green" | "blue" = "red";
let color: 'red' | 'green' | 'blue' = 'red';
```
#### GraphQL and internal libraries
@@ -237,7 +239,6 @@ Avoid using `px` or `rem` values directly within the styled components. The nece
Refrain from introducing new colors; instead, use the existing palette from the theme. Should there be a situation where the palette does not align, please leave a comment so that the team can rectify it.
```tsx
// ❌ Bad, directly specifies style values without utilizing the theme
const StyledButton = styled.button`
@@ -256,9 +257,10 @@ const StyledButton = styled.button`
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-left: ${({ theme }) => theme.spacing(1)};
border-radius: ${({ theme }) => theme.border.rounded};
border-radius: ${({ theme }) => theme.border.rounded};
`;
```
## Enforcing No-Type Imports
Avoid type imports. To enforce this standard, an Oxlint rule checks for and reports any type imports. This helps maintain consistency and readability in the TypeScript code.

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