Compare commits

..
Author SHA1 Message Date
sonarly-bot 504141e1b9 fix(front): handle missing morph target metadata gracefully
https://sonarly.com/issue/39017?type=bug

A front-end runtime error crashes `/objects/programs` when building GraphQL fields for a morph relation whose target object metadata is missing from the loaded metadata list.

Fix: I replaced the hard crash behavior for morph relation metadata drift with graceful degradation in the exact failing code path:

- In `generateDepthRecordGqlFieldsFromFields`, missing morph targets are now skipped instead of throwing.
- Existing valid morph targets still generate their GraphQL fields/IDs normally, so the page remains usable.
- When one or more morph targets are missing, the code reports a structured warning event (instead of an exception) through a dedicated helper.

I also added a targeted unit test that reproduces the mismatch (one present morph target + one missing target) and verifies:
1) no throw/crash behavior (function returns usable gql fields),
2) warning reporting is triggered with the expected payload.

Authored by Sonarly by autonomous analysis (run 44356).
2026-05-20 14:45:06 +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 <cursoragent@cursor.com>
2026-05-20 11:21:17 +00:00
fb47e4497a i18n - docs translations (#20764)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
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 <cursoragent@cursor.com>
Co-authored-by: martmull <martmull@hotmail.fr>
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 `create-twenty-app@2.5.0` 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): `create-twenty-app@2.5.0` (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 <sonarly@sonarly.com>
Co-authored-by: martmull <martmull@hotmail.fr>
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 <paul@twenty.com>
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 <6399865+FelixMalfait@users.noreply.github.com>
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
Félix MalfaitandGitHub d17393e91d feat(website): migrate dev hostname website-new.twenty-main.com → website.twenty-main.com (#20747)
## Summary
Last "-new" trace in the source repo, follow-up to the rename in #20745.
The dev Worker custom domain swaps from `website-new.twenty-main.com` to
`website.twenty-main.com`, matching the prod pattern (no "-new"
anywhere).

## Live operations already performed
- Deleted the legacy CNAME at `website.twenty-main.com` that pointed at
the dev EKS NLB (record id `52b4a4174dfd382ecf38111b7f08e642`, was the
Docusaurus dev deploy that had been 503'ing)
- Redeployed `twenty-website-dev` Worker — Wrangler provisioned the new
custom domain via the CF API
- Verified `https://website.twenty-main.com/` returns 200 with
`x-opennext: 1` and `x-nextjs-cache: HIT`

The old hostname `website-new.twenty-main.com` is now unbound; Wrangler
removed its DNS record when the route disappeared from this file.
Visitors get 522, which is the desired state for a retired hostname.

## Companion infra PR
https://github.com/twentyhq/twenty-infra/pull/683 — removes
`charts/dev/apps/website` Helm chart (the legacy Docusaurus deploy) +
ArgoCD app, updates `cloudflare/website/dev.env` and
`docs/4-environments.md`.

## Out of scope
- Legacy `website.twenty-staging.com` and `website.twenty.com` are still
alive serving Docusaurus content. Decommissioning those is a separate
decision (those URLs may still be linked externally).
2026-05-19 23:55:08 +02:00
Félix MalfaitandGitHub 658bdf3e57 chore(website): rename twenty-website-new → twenty-website (#20745)
## Summary
Follow-up to the Cloudflare/OpenNext migration (#20741). Now that the
legacy `twenty-website` package was already removed in #20270, the
`-new` suffix on the marketing site package is no longer meaningful.

## What changes
- **Directory rename**: `git mv packages/twenty-website-new
packages/twenty-website` (1213 files moved, no content change)
- **Package + nx config**: `package.json` and `project.json` name fields
updated, `sourceRoot` repointed
- **Source refs**: `load-local-articles.ts` and
`load-local-release-notes.ts` had a hardcoded `'twenty-website-new'`
segment in their monorepo-root fallback path;
`app/[locale]/releases/page.tsx` had display strings showing where to
add content
- **External refs**: root `package.json` workspaces, root `CLAUDE.md` /
`README.md`, `twenty-sdk` + `create-twenty-app` READMEs,
`.vscode/twenty.code-workspace`, `.cursor/rules/changelog-process.mdc`,
Crowdin config + the three `website-i18n-*` CI workflows +
`ci-website.yaml`
- **Docker cleanup**:
`packages/twenty-docker/twenty-website-new/Dockerfile` deleted; the two
Makefile targets (`prod-website-new-build` / `prod-website-new-run`)
that referenced it removed — EKS deploy was retired in the Cloudflare
migration
- **`yarn.lock`** regenerated against the new workspace path

## What's deliberately not in this PR
The dev hostname `website-new.twenty-main.com` in `wrangler.jsonc` stays
for now. Migrating it to `website.twenty-main.com` needs coordinated DNS
deletion (current CNAME points at the legacy Docusaurus NLB and serves
503s) and removal of the matching legacy `website` Helm chart in
`twenty-infra`. Flagged as a separate cleanup.

Companion infra PR: https://github.com/twentyhq/twenty-infra/pull/682
(workflow paths + Terraform ECR + docs)

## Test plan
- [x] `yarn install --immutable` resolves clean against the new path
- [x] `npx nx typecheck twenty-website` passes
- [x] `npx nx lint twenty-website` passes
- [ ] CI on this PR confirms the same on a fresh checkout
- [ ] After merge: trigger `Deploy Website` workflow against
`environment=dev` to confirm the renamed working-directory deploys
correctly
2026-05-19 23:42:09 +02:00
Félix MalfaitandGitHub 24a836cc7d feat(website-new): add Cloudflare Workers deployment via OpenNext (#20741)
## Summary
- Adds `@opennextjs/cloudflare` adapter so `packages/twenty-website-new`
can deploy to Cloudflare Workers
- Two environments (`dev` / `prod`) wired via `wrangler.jsonc` env
blocks
- Existing Docker / EKS build path is untouched in this PR — the cutover
happens in the paired infra PR

Pairs with: https://github.com/twentyhq/twenty-infra/pull/__ (to be
opened, will swap CI + decommission Helm/ArgoCD)

## Files added
- `packages/twenty-website-new/wrangler.jsonc` — Worker config,
`nodejs_compat` flag, R2 incremental cache, Cloudflare `IMAGES` binding,
env-specific routes (`website-new.twenty-main.com` for dev; `twenty.com`
+ `www.twenty.com` for prod)
- `packages/twenty-website-new/open-next.config.ts` — minimal config
using `r2IncrementalCache`
- `packages/twenty-website-new/.dev.vars.example` — local secrets
template (`STRIPE_SECRET_KEY`, `ENTERPRISE_JWT_PRIVATE_KEY`)
- `packages/twenty-website-new/public/_headers` — immutable cache
headers for `/_next/static/*`

## Files modified
- `packages/twenty-website-new/package.json` — adds
`@opennextjs/cloudflare`, `wrangler` to devDeps; adds `preview`,
`deploy:dev`, `deploy:prod`, `cf-typegen` scripts
- `packages/twenty-website-new/next.config.ts` — calls
`initOpenNextCloudflareForDev()` (no-op outside `next dev`); preserves
Linaria CommonJS export
- `packages/twenty-website-new/.gitignore` — ignores `.open-next/`,
`.wrangler/`, `.dev.vars`, generated `cloudflare-env.d.ts`

## Compatibility notes
- `enterprise-jwt.ts` uses Node `crypto` + `Buffer` — works on Workers
with the `nodejs_compat` flag (compat date 2025-01-15, well past the
2024-09-23 minimum)
- `sharp` stays as a build-time dep (Next/Image asset processing);
runtime image optimization routes through the Cloudflare `IMAGES`
binding
- Linaria runs at build time, unaffected
- Stripe SDK is HTTP-based, fine on Workers

## One-time CF setup required before this PR is useful
The infra PR adds GitHub Actions wiring, but the Cloudflare account
itself needs:
- R2 buckets: `twenty-website-cache-dev`, `twenty-website-cache-prod`
- Worker secrets per env (via `wrangler secret put --env <dev|prod>`):
`STRIPE_SECRET_KEY`, `ENTERPRISE_JWT_PRIVATE_KEY`
- An API token with `Workers Scripts:Edit`, `Workers R2 Storage:Edit`,
`Zone DNS:Edit` on the `twenty.com` zone — stored as
`CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` in the infra repo's
GitHub secrets
- The Cloudflare Images subscription enabled on the account (binding is
configured; \$5/mo + per-transformation pricing)

## Follow-up (out of scope)
- Rename `packages/twenty-website-new` → `packages/twenty-website` and
delete the legacy `packages/twenty-website` (mechanical, separate PR to
keep this diff reviewable)
- Remove `packages/twenty-docker/twenty-website-new/` once the EKS
deploy is fully retired

## Test plan
- [ ] `yarn install` resolves new devDeps cleanly
- [ ] `cd packages/twenty-website-new && npx next build` still succeeds
(Linaria path untouched)
- [ ] `yarn preview` builds the Worker locally and serves on
http://localhost:8788
- [ ] Smoke: `/`, `/pricing`, an enterprise-key-signing flow (needs
`.dev.vars` populated)
- [ ] After CF resources are provisioned: `yarn deploy:dev` succeeds and
`website-new.twenty-main.com` serves the new Worker
2026-05-19 23:23:54 +02:00
Charles BochetandGitHub 1a9f786e42 refactor(filters): pass fieldMetadataItems array to dispatcher (#20737)
## Summary

Alternative to #20717. Same goal (clean up the filter dispatcher API
after #20670) but smaller and follows the codebase's "pass data, not
behavior" style.

The dispatcher takes a `fieldMetadataItems: FieldShared[]` array
directly instead of a `findFieldMetadataItemById: (id) => FieldShared |
undefined` callback. The util builds the id lookup internally — once per
call, used for both source-field and relation-target-field lookups. No
new types, no separate hydration step.

## What changes

**`twenty-shared`**
- `computeRecordGqlOperationFilter` /
`turnRecordFilterIntoRecordGqlOperationFilter` /
`turnRecordFilterGroupsIntoGqlOperationFilter`: replace
`findFieldMetadataItemById` param with `fieldMetadataItems` /
`fieldMetadataItemById` (internal Map).
- Remove the exported `FindFieldMetadataItemById` type.
- `turnAnyFieldFilterIntoRecordGqlFilter`: rename its internal
`fieldById` Map for consistency.
- Tests updated to pass arrays.

**Frontend (15 call sites)**
- Switch from `fieldMetadataItemByIdMapSelector` to
`flattenedFieldMetadataItemsSelector`.
- Pass `fieldMetadataItems: flattenedFieldMetadataItems` to the
dispatcher.
- `useFindManyRecordsSelectedInContextStore` keeps the Map selector
because it still does a per-filter lookup for the soft-delete check.

**Server (5 call sites)**
- Pass
`Object.values(flatFieldMetadataMaps.byUniversalIdentifier).filter(isDefined)`.

## Why this over #20717

#20717 moves resolution into a separate hydration step + introduces a
`HydratedRecordFilter` type. The bug that #20717 originally surfaced was
Sentry catching 4 critical runtime errors during review
(`fieldMetadataItemByIdMap` declared but not passed). The added type and
the explicit hydration boundary are extra surface area for not much
benefit — the existing API was a callback wrapping a Map at every call
site, and the natural simplification is to just pass the Map (or its
array) directly.

Net diff: **196 insertions, 203 deletions** (~7 lines net removed). 32
files.

## Test plan
- [x] Shared filter unit tests pass (461 tests)
- [x] Frontend filter/context-store tests pass (13 tests)
- [x] Frontend typecheck passes
- [x] Server typecheck passes
- [x] Lint passes (frontend + server)
- [ ] Integration tests on #20670 still pass — workflow find-records +
chart-data with relation-traversal filter still work end-to-end through
the new array param
2026-05-19 22:49:53 +02:00
1406 changed files with 11465 additions and 1154 deletions
+21 -21
View File
@@ -106,24 +106,24 @@ Replace `{VERSION}` with the actual version number (e.g., `1.9.0`)
### 2. Create File Structure
**Create changelog file:**
- Path: `packages/twenty-website-new/src/content/releases/{VERSION}.mdx`
- Example: `packages/twenty-website-new/src/content/releases/1.9.0.mdx`
- Path: `packages/twenty-website/src/content/releases/{VERSION}.mdx`
- Example: `packages/twenty-website/src/content/releases/1.9.0.mdx`
**Create image folder:**
- Path: `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
- Example for version 1.9.0: `packages/twenty-website-new/public/images/releases/1.9/`
- Example for version 2.0.0: `packages/twenty-website-new/public/images/releases/2.0/`
- Path: `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
- Example for version 1.9.0: `packages/twenty-website/public/images/releases/1.9/`
- Example for version 2.0.0: `packages/twenty-website/public/images/releases/2.0/`
```bash
# Create the image folder
mkdir -p packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
mkdir -p packages/twenty-website/public/images/releases/{MINOR_VERSION}
```
### 3. Move Illustration Files
**Source:** `/Users/thomascolasdesfrancs/Downloads/🆕`
**Destination:** `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
**Destination:** `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
**Naming Convention:** `{VERSION}-descriptive-name.webp`
@@ -133,8 +133,8 @@ Examples:
```bash
# Move and rename source files, then convert to webp if needed
cp ~/Downloads/🆕/source-file.png packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
cd packages/twenty-website-new && node scripts/convert-png-to-webp.mjs
cp ~/Downloads/🆕/source-file.png packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
cd packages/twenty-website && node scripts/convert-png-to-webp.mjs
```
### 4. Research Features (if needed)
@@ -183,7 +183,7 @@ Description of the third feature.
- **NEVER mention the brand name "Twenty"** in changelog text - use "your workspace", "the platform", or similar neutral references instead
**Reference Previous Changelogs:**
- Check `packages/twenty-website-new/src/content/releases/` for examples
- Check `packages/twenty-website/src/content/releases/` for examples
- Recent releases: 1.7.0.mdx, 1.6.0.mdx, 1.5.0.mdx
### 6. Review
@@ -191,10 +191,10 @@ Description of the third feature.
Open the changelog file for review:
```bash
# Open in Cursor
cursor packages/twenty-website-new/src/content/releases/{VERSION}.mdx
cursor packages/twenty-website/src/content/releases/{VERSION}.mdx
# Open image folder to verify illustrations
open packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
open packages/twenty-website/public/images/releases/{MINOR_VERSION}
```
Review checklist:
@@ -222,8 +222,8 @@ I've created the changelog for version {VERSION}. Here's the content for your re
[Show full MDX content]
Images moved to:
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
Please review the content. Once you approve, I'll commit the changes and create the pull request.
```
@@ -242,8 +242,8 @@ Possible user responses:
git status
# Add files
git add packages/twenty-website-new/src/content/releases/{VERSION}.mdx
git add packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
git add packages/twenty-website/src/content/releases/{VERSION}.mdx
git add packages/twenty-website/public/images/releases/{MINOR_VERSION}/
# Commit
git commit -m "Add {VERSION} release changelog"
@@ -266,7 +266,7 @@ This release includes:
- Feature 2
- Feature 3
Changelog file: \`packages/twenty-website-new/src/content/releases/{VERSION}.mdx\`
Changelog file: \`packages/twenty-website/src/content/releases/{VERSION}.mdx\`
Release date: {DATE}" \
--base main \
--head {VERSION}
@@ -280,13 +280,13 @@ Or visit: `https://github.com/twentyhq/twenty/pull/new/{VERSION}`
- **Format**: `{MAJOR}.{MINOR}.{PATCH}.mdx`
- **Convention**: One file per complete version
- **Examples**: `1.6.0.mdx`, `1.7.0.mdx`, `2.0.0.mdx`
- **Location**: `packages/twenty-website-new/src/content/releases/`
- **Location**: `packages/twenty-website/src/content/releases/`
### Image Folders
- **Format**: `{MAJOR}.{MINOR}/`
- **Convention**: One folder per minor version (shared across patches)
- **Examples**: `1.6/`, `1.7/`, `2.0/`
- **Location**: `packages/twenty-website-new/public/images/releases/`
- **Location**: `packages/twenty-website/public/images/releases/`
### Image Files
- **Format**: `{VERSION}-descriptive-name.webp`
@@ -311,8 +311,8 @@ Features to document:
3. ___________________________
Branch name: {VERSION}
Changelog path: packages/twenty-website-new/src/content/releases/{VERSION}.mdx
Images path: packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
Changelog path: packages/twenty-website/src/content/releases/{VERSION}.mdx
Images path: packages/twenty-website/public/images/releases/{MINOR_VERSION}/
```
## Tips
+2 -2
View File
@@ -1,5 +1,5 @@
#
# Crowdin CLI configuration for Website translations (twenty-website-new)
# Crowdin CLI configuration for Website translations (twenty-website)
# Project ID: 4
# See https://crowdin.github.io/crowdin-cli/configuration for more information
#
@@ -16,7 +16,7 @@ files:
#
# Source file - PO file for Lingui
#
- source: packages/twenty-website-new/src/locales/en.po
- source: packages/twenty-website/src/locales/en.po
#
# Translation files path
#
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
files: |
package.json
yarn.lock
packages/twenty-website-new/**
packages/twenty-website/**
packages/twenty-shared/**
website-task:
needs: changed-files-check
+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 }}
+5 -5
View File
@@ -54,7 +54,7 @@ jobs:
# Strict mode fails if there are missing website translations.
- name: Compile website translations
id: compile_translations_strict
run: npx nx run twenty-website-new:lingui:compile --strict
run: npx nx run twenty-website:lingui:compile --strict
continue-on-error: true
- name: Stash any changes before pulling translations
@@ -71,8 +71,8 @@ jobs:
upload_sources: false
upload_translations: false
download_translations: true
source: 'packages/twenty-website-new/src/locales/en.po'
translation: 'packages/twenty-website-new/src/locales/%locale%.po'
source: 'packages/twenty-website/src/locales/en.po'
translation: 'packages/twenty-website/src/locales/%locale%.po'
export_only_approved: false
localization_branch_name: i18n-website
base_url: 'https://twenty.api.crowdin.com'
@@ -101,9 +101,9 @@ jobs:
- name: Compile website translations
id: compile_translations
run: |
npx nx run twenty-website-new:lingui:compile
npx nx run twenty-website:lingui:compile
git status
git add packages/twenty-website-new/src/locales
git add packages/twenty-website/src/locales
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
+5 -5
View File
@@ -10,7 +10,7 @@ on:
push:
branches: ['main']
paths:
- 'packages/twenty-website-new/**'
- 'packages/twenty-website/**'
- '.github/crowdin-website.yml'
- '.github/workflows/website-i18n-push.yaml'
@@ -41,14 +41,14 @@ jobs:
run: npx nx build twenty-shared
- name: Extract website translations
run: npx nx run twenty-website-new:lingui:extract
run: npx nx run twenty-website:lingui:extract
- name: Check and commit extracted files
id: check_extract_changes
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add packages/twenty-website-new/src/locales
git add packages/twenty-website/src/locales
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: extract website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
@@ -57,14 +57,14 @@ jobs:
fi
- name: Compile website translations
run: npx nx run twenty-website-new:lingui:compile
run: npx nx run twenty-website:lingui:compile
- name: Check and commit compiled files
id: check_compile_changes
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add packages/twenty-website-new/src/locales/generated
git add packages/twenty-website/src/locales/generated
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
@@ -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"
+2 -2
View File
@@ -81,8 +81,8 @@
"path": "../packages/twenty-sdk"
},
{
"name": "packages/twenty-website-new",
"path": "../packages/twenty-website-new"
"name": "packages/twenty-website",
"path": "../packages/twenty-website"
}
],
"settings": {
+1 -1
View File
@@ -110,7 +110,7 @@ packages/
├── twenty-ui/ # Shared UI components library
├── twenty-shared/ # Common types and utilities
├── twenty-emails/ # Email templates with React Email
├── twenty-website-new/ # Next.js marketing website
├── twenty-website/ # Next.js marketing website
├── twenty-docs/ # Documentation website
├── twenty-zapier/ # Zapier integration
└── twenty-e2e-testing/ # Playwright E2E tests
+43 -43
View File
@@ -1,19 +1,19 @@
<p align="center">
<a href="https://www.twenty.com">
<img src="./packages/twenty-website-new/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
<img src="./packages/twenty-website/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
</a>
</p>
<h2 align="center" >The #1 Open-Source CRM</h2>
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website-new/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website-new/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
<p align="center">
<a href="https://www.twenty.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/github-cover-light.webp" alt="Twenty banner" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/github-cover-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/github-cover-light.webp" />
<img src="./packages/twenty-website/public/images/readme/github-cover-light.webp" alt="Twenty banner" />
</picture>
</a>
</p>
@@ -24,17 +24,17 @@
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
<a href="https://twenty.com/resources/why-twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
<a href="https://twenty.com/resources/why-twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
<br />
# Installation
### <img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
### <img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
The fastest way to get started. Sign up at [twenty.com](https://twenty.com) and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.
### <img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
### <img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
Scaffold a new app with the Twenty CLI:
@@ -68,7 +68,7 @@ npx twenty deploy
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
### <img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
### <img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.com/developers/self-host/capabilities/docker-compose), or contribute locally via the [local setup guide](https://docs.twenty.com/developers/contribute/capabilities/local-setup).
@@ -79,61 +79,61 @@ Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.
Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
<table align="center">
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp" alt="Create your apps" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-build-apps-light.webp" alt="Create your apps" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-version-control-light.webp" alt="Stay on top with version control" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-version-control-light.webp" alt="Stay on top with version control" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website-new/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp" alt="All the tools you need to build anything" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-all-tools-light.webp" alt="All the tools you need to build anything" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-tools-light.webp" alt="Customize your layouts" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-tools-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-tools-light.webp" alt="Customize your layouts" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp" alt="AI agents and chats" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-ai-agents-light.webp" alt="AI agents and chats" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp" alt="Plus all the tools of a good CRM" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-crm-tools-light.webp" alt="Plus all the tools of a good CRM" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
</td>
</tr>
</table>
@@ -142,23 +142,23 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
# Stack
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
- <a href="https://nx.dev/"><img src="./packages/twenty-website-new/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
- <a href="https://nestjs.com/"><img src="./packages/twenty-website-new/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website-new/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
- <a href="https://reactjs.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
- <a href="https://nx.dev/"><img src="./packages/twenty-website/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
- <a href="https://nestjs.com/"><img src="./packages/twenty-website/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
- <a href="https://reactjs.org/"><img src="./packages/twenty-website/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
# Thanks
<p align="center">
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website-new/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://greptile.com"><img src="./packages/twenty-website-new/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sentry.io/"><img src="./packages/twenty-website-new/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://crowdin.com/"><img src="./packages/twenty-website-new/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
</p>
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
@@ -166,4 +166,4 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
# Join the Community
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website-new/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website-new/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website-new/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
+1 -1
View File
@@ -53,7 +53,7 @@
"packages/twenty-ui",
"packages/twenty-utils",
"packages/twenty-zapier",
"packages/twenty-website-new",
"packages/twenty-website",
"packages/twenty-docs",
"packages/twenty-e2e-testing",
"packages/twenty-shared",
+1 -1
View File
@@ -1,7 +1,7 @@
<div align="center">
<a href="https://twenty.com">
<picture>
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website-new/public/images/core/logo.svg" height="128">
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website/public/images/core/logo.svg" height="128">
</picture>
</a>
<h1>Create Twenty App</h1>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.5.1",
"version": "2.6.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
+6 -1
View File
@@ -74,12 +74,17 @@ const program = new Command(packageJson.name)
);
}
const workspaceUrl = (options?.workspaceUrl ?? options?.apiUrl)?.replace(
/\/+$/,
'',
);
await new CreateAppCommand().execute({
directory,
name: options?.name,
displayName: options?.displayName,
description: options?.description,
workspaceUrl: options?.workspaceUrl ?? options?.apiUrl,
workspaceUrl,
authenticationMethod: options?.authenticationMethod,
});
},
@@ -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,
@@ -389,7 +389,7 @@ export class CreateAppCommand {
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
return frontUrl?.replace(/\/+$/, '') ?? null;
return frontUrl ? normalizeUrl(frontUrl) : null;
}
private async readMainPageLayoutUniversalIdentifier(
@@ -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) {
@@ -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": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
},
"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: 'contact@twenty.com',
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.6.0",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
@@ -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,
{
-6
View File
@@ -26,12 +26,6 @@ prod-run:
prod-postgres-run:
@docker run -d -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres --name twenty-postgres twenty-postgres:$(TAG)
prod-website-new-build:
@cd ../.. && docker build -f ./packages/twenty-docker/twenty-website-new/Dockerfile --platform $(PLATFORM) --tag twenty-website-new:$(TAG) . && cd -
prod-website-new-run:
@docker run -d -p 3000:3000 --name twenty-website-new twenty-website-new:$(TAG)
# =============================================================================
# Local Development Services
# Run these from the repository root: make -C packages/twenty-docker <target>
@@ -1,39 +0,0 @@
FROM node:24.15.0-alpine3.23@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS twenty-website-new-build
WORKDIR /app
COPY ./package.json .
COPY ./yarn.lock .
COPY ./.yarnrc.yml .
COPY ./tsconfig.base.json .
COPY ./nx.json .
COPY ./.yarn/releases /app/.yarn/releases
COPY ./.yarn/patches /app/.yarn/patches
COPY ./packages/twenty-oxlint-rules /app/packages/twenty-oxlint-rules
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/package.json
COPY ./packages/twenty-website-new/package.json /app/packages/twenty-website-new/package.json
RUN yarn
COPY ./packages/twenty-shared /app/packages/twenty-shared
COPY ./packages/twenty-website-new /app/packages/twenty-website-new
RUN npx nx build twenty-website-new
FROM node:24.15.0-alpine3.23@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS twenty-website-new
WORKDIR /app/packages/twenty-website-new
COPY --from=twenty-website-new-build /app /app
WORKDIR /app/packages/twenty-website-new
LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty
LABEL org.opencontainers.image.description="This image provides a consistent and reproducible environment for the new marketing website."
RUN chown -R 1000 /app
# Use non root user with uid 1000
USER 1000
CMD ["/bin/sh", "-c", "npx nx start twenty-website-new"]
@@ -13,8 +13,8 @@ Each function file uses `defineLogicFunction()` to export a configuration with a
```ts src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
@@ -79,10 +79,10 @@ yarn twenty logs
When a route trigger invokes your logic function, it receives a `RoutePayload` object that follows the
[AWS HTTP API v2 format](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html).
Import the `RoutePayload` type from `twenty-sdk`:
Import the `RoutePayload` type from `twenty-sdk/logic-function`:
```ts
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
@@ -141,6 +141,115 @@ const handler = async (event: RoutePayload) => {
Header names are normalized to lowercase. Access them using lowercase keys (e.g., `event.headers['content-type']`).
</Note>
#### Database event trigger payload
When a database event trigger invokes your logic function, it receives one `DatabaseEventPayload` per changed record. The payload combines metadata about the source workspace and object with the record-level event.
```ts
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';
type Person = {
id: string;
emails?: { primaryEmail?: string };
};
```
The payload includes:
| Property | Description |
|----------|-------------|
| `name` | Event name, such as `person.updated`. |
| `workspaceId` | Workspace where the event happened. |
| `objectMetadata` | Metadata for the object that changed. |
| `recordId` | Id of the changed record. |
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Actor fields when the event was caused by a workspace user. |
| `properties` | Record data for the event, with `before`, `after`, `diff`, and `updatedFields` depending on the operation. |
| Event | Record data |
|-------|-------------|
| `person.created` | `event.properties.after` |
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
| `person.destroyed` | `event.properties.before` |
For soft deletes, `.deleted` follows the update-style shape because the record's `deletedAt` field changes.
For permanent deletes, use `.destroyed`.
<Note>
`databaseEventTriggerSettings.updatedFields` filters which update events trigger the function.
`event.properties.updatedFields` tells you which fields actually changed on the current event.
</Note>
Created event example:
```ts
type PersonCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Person>
>;
const handler = async (event: PersonCreatedEvent) => {
const person = event.properties.after;
return {
personId: event.recordId,
email: person.emails?.primaryEmail,
};
};
```
Updated event example:
```ts
type PersonUpdatedEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<Person>
>;
const handler = async (event: PersonUpdatedEvent) => {
const { before, after, diff, updatedFields } = event.properties;
return {
personId: event.recordId,
updatedFields,
previousEmail: before.emails?.primaryEmail,
currentEmail: after.emails?.primaryEmail,
emailDiff: diff.emails,
};
};
```
Trigger only on email updates:
```ts
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['emails'],
},
});
```
Destroyed event example:
```ts
type PersonDestroyedEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<Person>
>;
const handler = async (event: PersonDestroyedEvent) => {
const personBeforeDestroy = event.properties.before;
return {
personId: event.recordId,
email: personBeforeDestroy.emails?.primaryEmail,
};
};
```
#### Exposing a function as an AI tool or workflow action
Logic functions can be exposed on two surfaces, each with its own trigger:
@@ -13,8 +13,8 @@ Jede Funktionsdatei verwendet `defineLogicFunction()`, um eine Konfiguration mit
```ts src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
@@ -78,10 +78,10 @@ yarn twenty logs
#### Routen-Trigger-Payload
Wenn ein Route-Trigger Ihre Logikfunktion aufruft, erhält sie ein `RoutePayload`-Objekt, das dem [AWS-HTTP-API-v2-Format](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) folgt.
Importieren Sie den Typ `RoutePayload` aus `twenty-sdk`:
Importieren Sie den Typ `RoutePayload` aus `twenty-sdk/logic-function`:
```ts
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
@@ -140,6 +140,115 @@ const handler = async (event: RoutePayload) => {
Header-Namen werden in Kleinbuchstaben normalisiert. Greifen Sie mit Schlüsseln in Kleinbuchstaben darauf zu (z. B. `event.headers['content-type']`).
</Note>
#### Datenbank-Event-Trigger-Payload
Wenn ein Datenbank-Event-Trigger Ihre Logic Function aufruft, erhält sie eine `DatabaseEventPayload` pro geändertem Datensatz. Die Payload kombiniert Metadaten über den Quell-Workspace und das Objekt mit dem Ereignis auf Datensatzebene.
```ts
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';
type Person = {
id: string;
emails?: { primaryEmail?: string };
};
```
Die Nutzlast umfasst:
| Eigenschaft | Beschreibung |
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| `name` | Ereignisname, z. B. `person.updated`. |
| `workspaceId` | Arbeitsbereich, in dem das Ereignis stattgefunden hat. |
| `objectMetadata` | Metadaten für das Objekt, das geändert wurde. |
| `recordId` | ID des geänderten Datensatzes. |
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Akteurfelder, wenn das Ereignis von einem Benutzer des Arbeitsbereichs ausgelöst wurde. |
| `properties` | Datensatzdaten für das Ereignis mit `before`, `after`, `diff` und `updatedFields`, abhängig von der Operation. |
| Ereignis | Datensatzdaten |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `person.created` | `event.properties.after` |
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
| `person.destroyed` | `event.properties.before` |
Bei Soft Deletes folgt `.deleted` der Aktualisierungsstruktur, da sich das Feld `deletedAt` des Datensatzes ändert.
Für dauerhafte Löschvorgänge verwende `.destroyed`.
<Note>
`databaseEventTriggerSettings.updatedFields` filtert, welche Aktualisierungsereignisse die Funktion auslösen.
`event.properties.updatedFields` gibt an, welche Felder beim aktuellen Ereignis tatsächlich geändert wurden.
</Note>
Beispiel für ein Created-Ereignis:
```ts
type PersonCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Person>
>;
const handler = async (event: PersonCreatedEvent) => {
const person = event.properties.after;
return {
personId: event.recordId,
email: person.emails?.primaryEmail,
};
};
```
Beispiel für ein Updated-Ereignis:
```ts
type PersonUpdatedEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<Person>
>;
const handler = async (event: PersonUpdatedEvent) => {
const { before, after, diff, updatedFields } = event.properties;
return {
personId: event.recordId,
updatedFields,
previousEmail: before.emails?.primaryEmail,
currentEmail: after.emails?.primaryEmail,
emailDiff: diff.emails,
};
};
```
Nur bei E-Mail-Aktualisierungen auslösen:
```ts
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['emails'],
},
});
```
Beispiel für ein Destroyed-Ereignis:
```ts
type PersonDestroyedEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<Person>
>;
const handler = async (event: PersonDestroyedEvent) => {
const personBeforeDestroy = event.properties.before;
return {
personId: event.recordId,
email: personBeforeDestroy.emails?.primaryEmail,
};
};
```
#### Eine Funktion als KI-Tool oder Workflow-Aktion verfügbar machen
Logikfunktionen können auf zwei Oberflächen verfügbar gemacht werden, jeweils mit eigenem Trigger:
@@ -13,8 +13,8 @@ Cada arquivo de função usa `defineLogicFunction()` para exportar uma configura
```ts src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
@@ -78,10 +78,10 @@ yarn twenty logs
#### Payload de gatilho de rota
Quando um gatilho de rota invoca sua função de lógica, ela recebe um objeto `RoutePayload` que segue o [formato HTTP API v2 da AWS](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html).
Importe o tipo `RoutePayload` de `twenty-sdk`:
Importe o tipo `RoutePayload` de `twenty-sdk/logic-function`:
```ts
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
@@ -140,6 +140,115 @@ const handler = async (event: RoutePayload) => {
Os nomes dos cabeçalhos são normalizados para minúsculas. Acesse-os usando chaves em minúsculas (por exemplo, `event.headers['content-type']`).
</Note>
#### Payload do gatilho de evento do banco de dados
Quando um gatilho de evento do banco de dados invoca sua função lógica, ela recebe um `DatabaseEventPayload` por registro alterado. O payload combina metadados sobre o workspace e o objeto de origem com o evento em nível de registro.
```ts
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';
type Person = {
id: string;
emails?: { primaryEmail?: string };
};
```
O payload inclui:
| Propriedade | Descrição |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| `name` | Nome do evento, como `person.updated`. |
| `workspaceId` | Workspace onde o evento aconteceu. |
| `objectMetadata` | Metadados do objeto que foi alterado. |
| `recordId` | ID do registro alterado. |
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Campos do autor quando o evento foi causado por um usuário do workspace. |
| `properties` | Dados do registro para o evento, com `before`, `after`, `diff` e `updatedFields`, dependendo da operação. |
| Evento | Dados do registro |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `person.created` | `event.properties.after` |
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
| `person.destroyed` | `event.properties.before` |
Para exclusões lógicas, `.deleted` segue o formato de atualização porque o campo `deletedAt` do registro é alterado.
Para exclusões permanentes, use `.destroyed`.
<Note>
`databaseEventTriggerSettings.updatedFields` filtra quais eventos de atualização disparam a função.
`event.properties.updatedFields` informa quais campos realmente foram alterados no evento atual.
</Note>
Exemplo de evento de criação:
```ts
type PersonCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Person>
>;
const handler = async (event: PersonCreatedEvent) => {
const person = event.properties.after;
return {
personId: event.recordId,
email: person.emails?.primaryEmail,
};
};
```
Exemplo de evento de atualização:
```ts
type PersonUpdatedEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<Person>
>;
const handler = async (event: PersonUpdatedEvent) => {
const { before, after, diff, updatedFields } = event.properties;
return {
personId: event.recordId,
updatedFields,
previousEmail: before.emails?.primaryEmail,
currentEmail: after.emails?.primaryEmail,
emailDiff: diff.emails,
};
};
```
Disparar somente em atualizações de email:
```ts
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['emails'],
},
});
```
Exemplo de evento de destruição:
```ts
type PersonDestroyedEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<Person>
>;
const handler = async (event: PersonDestroyedEvent) => {
const personBeforeDestroy = event.properties.before;
return {
personId: event.recordId,
email: personBeforeDestroy.emails?.primaryEmail,
};
};
```
#### Expor uma função como ferramenta de IA ou como ação de fluxo de trabalho
As funções de lógica podem ser expostas em duas superfícies, cada uma com seu próprio gatilho:
@@ -13,8 +13,8 @@ Fiecare fișier de funcție folosește `defineLogicFunction()` pentru a exporta
```ts src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
@@ -79,10 +79,10 @@ yarn twenty logs
Când un declanșator de rută invocă funcția logică, aceasta primește un obiect `RoutePayload` care urmează
[AWS HTTP API v2 format](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html).
Importați tipul `RoutePayload` din `twenty-sdk`:
Importați tipul `RoutePayload` din `twenty-sdk/logic-function`:
```ts
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
@@ -141,6 +141,115 @@ const handler = async (event: RoutePayload) => {
Numele anteturilor sunt normalizate la litere mici. Accesați-le folosind chei cu litere mici (de exemplu, `event.headers['content-type']`).
</Note>
#### Payload-ul declanșatorului de eveniment al bazei de date
Când un declanșator de eveniment al bazei de date apelează funcția dvs. logică, aceasta primește un `DatabaseEventPayload` pentru fiecare înregistrare modificată. Payload-ul combină metadatele despre spațiul de lucru și obiectul sursă cu evenimentul la nivel de înregistrare.
```ts
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';
type Person = {
id: string;
emails?: { primaryEmail?: string };
};
```
Payload-ul include:
| Proprietate | Descriere |
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| `name` | Numele evenimentului, cum ar fi `person.updated`. |
| `workspaceId` | Spațiul de lucru în care a avut loc evenimentul. |
| `objectMetadata` | Metadate pentru obiectul care s-a modificat. |
| `recordId` | ID-ul înregistrării modificate. |
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Câmpurile actorului atunci când evenimentul a fost cauzat de un utilizator al spațiului de lucru. |
| `properties` | Datele înregistrării pentru eveniment, cu `before`, `after`, `diff` și `updatedFields` în funcție de operație. |
| Eveniment | Datele înregistrării |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `person.created` | `event.properties.after` |
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
| `person.destroyed` | `event.properties.before` |
Pentru ștergeri logice (soft delete), `.deleted` urmează structura de tip update deoarece câmpul `deletedAt` al înregistrării se modifică.
Pentru ștergeri permanente, folosiți `.destroyed`.
<Note>
`databaseEventTriggerSettings.updatedFields` filtrează ce evenimente de actualizare declanșează funcția.
`event.properties.updatedFields` vă indică ce câmpuri s-au modificat efectiv în evenimentul curent.
</Note>
Exemplu de eveniment de creare:
```ts
type PersonCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Person>
>;
const handler = async (event: PersonCreatedEvent) => {
const person = event.properties.after;
return {
personId: event.recordId,
email: person.emails?.primaryEmail,
};
};
```
Exemplu de eveniment de actualizare:
```ts
type PersonUpdatedEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<Person>
>;
const handler = async (event: PersonUpdatedEvent) => {
const { before, after, diff, updatedFields } = event.properties;
return {
personId: event.recordId,
updatedFields,
previousEmail: before.emails?.primaryEmail,
currentEmail: after.emails?.primaryEmail,
emailDiff: diff.emails,
};
};
```
Declanșare doar la actualizări ale e-mailului:
```ts
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['emails'],
},
});
```
Exemplu de eveniment de ștergere:
```ts
type PersonDestroyedEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<Person>
>;
const handler = async (event: PersonDestroyedEvent) => {
const personBeforeDestroy = event.properties.before;
return {
personId: event.recordId,
email: personBeforeDestroy.emails?.primaryEmail,
};
};
```
#### Expunerea unei funcții ca instrument AI sau ca acțiune în fluxul de lucru
Funcțiile logice pot fi expuse în două locuri, fiecare cu propriul declanșator:
@@ -13,8 +13,8 @@ icon: bolt
```ts src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
@@ -78,10 +78,10 @@ yarn twenty logs
#### Полезная нагрузка триггера маршрута
Когда триггер маршрута вызывает вашу логическую функцию, она получает объект `RoutePayload`, который соответствует [формату AWS HTTP API v2](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html).
Импортируйте тип `RoutePayload` из `twenty-sdk`:
Импортируйте тип `RoutePayload` из `twenty-sdk/logic-function`:
```ts
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
@@ -140,6 +140,115 @@ const handler = async (event: RoutePayload) => {
Имена заголовков приводятся к нижнему регистру. Обращайтесь к ним, используя ключи в нижнем регистре (например, `event.headers['content-type']`).
</Note>
#### Полезная нагрузка триггера события базы данных
Когда триггер события базы данных вызывает вашу функцию логики, она получает по одному `DatabaseEventPayload` на каждую изменённую запись. Полезная нагрузка объединяет метаданные о рабочем пространстве-источнике и объекте с событием на уровне записи.
```ts
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';
type Person = {
id: string;
emails?: { primaryEmail?: string };
};
```
Полезная нагрузка включает:
| Свойство | Описание |
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| `name` | Имя события, например `person.updated`. |
| `workspaceId` | Рабочее пространство, в котором произошло событие. |
| `objectMetadata` | Метаданные для объекта, который изменился. |
| `recordId` | Идентификатор измененной записи. |
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Поля инициатора, если событие было вызвано пользователем рабочего пространства. |
| `properties` | Данные записи для события с `before`, `after`, `diff` и `updatedFields` в зависимости от операции. |
| Событие | Данные записи |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `person.created` | `event.properties.after` |
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
| `person.destroyed` | `event.properties.before` |
При логическом удалении `.deleted` имеет формат обновления, поскольку изменяется поле `deletedAt` записи.
Для окончательного удаления используйте `.destroyed`.
<Note>
`databaseEventTriggerSettings.updatedFields` фильтрует, какие события обновления запускают функцию.
`event.properties.updatedFields` указывает, какие поля фактически изменились в текущем событии.
</Note>
Пример события создания:
```ts
type PersonCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Person>
>;
const handler = async (event: PersonCreatedEvent) => {
const person = event.properties.after;
return {
personId: event.recordId,
email: person.emails?.primaryEmail,
};
};
```
Пример события обновления:
```ts
type PersonUpdatedEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<Person>
>;
const handler = async (event: PersonUpdatedEvent) => {
const { before, after, diff, updatedFields } = event.properties;
return {
personId: event.recordId,
updatedFields,
previousEmail: before.emails?.primaryEmail,
currentEmail: after.emails?.primaryEmail,
emailDiff: diff.emails,
};
};
```
Триггер только при обновлении email:
```ts
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['emails'],
},
});
```
Пример события уничтожения:
```ts
type PersonDestroyedEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<Person>
>;
const handler = async (event: PersonDestroyedEvent) => {
const personBeforeDestroy = event.properties.before;
return {
personId: event.recordId,
email: personBeforeDestroy.emails?.primaryEmail,
};
};
```
#### Предоставление функции в качестве инструмента ИИ или действия рабочего процесса
Функции логики могут быть представлены в двух интерфейсах, у каждого — свой триггер:
@@ -13,8 +13,8 @@ Her işlev dosyası, bir işleyici ve isteğe bağlı tetikleyiciler içeren bir
```ts src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
@@ -79,10 +79,10 @@ yarn twenty logs
Bir rota tetikleyicisi mantık fonksiyonunuzu çağırdığında,
[AWS HTTP API v2 formatını](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) izleyen bir `RoutePayload` nesnesi alır.
`RoutePayload` türünü `twenty-sdk` içinden içe aktarın:
`RoutePayload` türünü `twenty-sdk/logic-function` içinden içe aktarın:
```ts
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
@@ -141,6 +141,115 @@ const handler = async (event: RoutePayload) => {
Başlık adları küçük harfe normalize edilir. Onlara küçük harfli anahtarlarla erişin (örneğin, `event.headers['content-type']`).
</Note>
#### Veritabanı olay tetikleyicisi yükü
Bir veritabanı olay tetikleyicisi mantık fonksiyonunuzu çağırdığında, değişen her kayıt için bir `DatabaseEventPayload` alır. Yük, kaynak çalışma alanı ve nesne hakkındaki üstveriyi, kayıt düzeyindeki olayla birleştirir.
```ts
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';
type Person = {
id: string;
emails?: { primaryEmail?: string };
};
```
Yük, şunları içerir:
| Özellik | Açıklama |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `name` | `person.updated` gibi bir olay adı. |
| `workspaceId` | Olayın gerçekleştiği çalışma alanı. |
| `objectMetadata` | Değişen nesne için üstveri. |
| `recordId` | Değişen kaydın kimliği. |
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Olay bir çalışma alanı kullanıcısı tarafından tetiklendiğinde aktör alanları. |
| `properties` | İşleme bağlı olarak `before`, `after`, `diff` ve `updatedFields` içeren olay için kayıt verileri. |
| Etkinlik | Kayıt verileri |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `person.created` | `event.properties.after` |
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
| `person.destroyed` | `event.properties.before` |
Yumuşak silmeler için, kayıt `deletedAt` alanı değiştiğinden `.deleted`, güncelleme tarzı yapıyı izler.
Kalıcı silmeler için `.destroyed` kullanın.
<Note>
`databaseEventTriggerSettings.updatedFields`, hangi güncelleme olaylarının fonksiyonu tetikleyeceğini filtreler.
`event.properties.updatedFields`, mevcut olayda hangi alanların gerçekten değiştiğini size bildirir.
</Note>
Oluşturma olayı örneği:
```ts
type PersonCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Person>
>;
const handler = async (event: PersonCreatedEvent) => {
const person = event.properties.after;
return {
personId: event.recordId,
email: person.emails?.primaryEmail,
};
};
```
Güncelleme olayı örneği:
```ts
type PersonUpdatedEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<Person>
>;
const handler = async (event: PersonUpdatedEvent) => {
const { before, after, diff, updatedFields } = event.properties;
return {
personId: event.recordId,
updatedFields,
previousEmail: before.emails?.primaryEmail,
currentEmail: after.emails?.primaryEmail,
emailDiff: diff.emails,
};
};
```
Yalnızca e-posta güncellemelerinde tetikle:
```ts
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['emails'],
},
});
```
Yok etme olayı örneği:
```ts
type PersonDestroyedEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<Person>
>;
const handler = async (event: PersonDestroyedEvent) => {
const personBeforeDestroy = event.properties.before;
return {
personId: event.recordId,
email: personBeforeDestroy.emails?.primaryEmail,
};
};
```
#### Bir işlevi bir yapay zekâ aracı veya iş akışı eylemi olarak kullanıma sunma
Mantık işlevleri, her birinin kendi tetikleyicisi olacak şekilde iki yerde kullanılabilir hâle getirilebilir:
@@ -13,8 +13,8 @@ icon: bolt
```ts src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
@@ -79,10 +79,10 @@ yarn twenty logs
当路由触发器调用你的逻辑函数时,它会接收一个遵循
[AWS HTTP API v2 格式](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html)的 `RoutePayload` 对象。
从 `twenty-sdk` 导入 `RoutePayload` 类型:
从 `twenty-sdk/logic-function` 导入 `RoutePayload` 类型:
```ts
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
@@ -141,6 +141,115 @@ const handler = async (event: RoutePayload) => {
请求头名称会被规范化为小写。 请使用小写键访问它们(例如,`event.headers['content-type']`)。
</Note>
#### 数据库事件触发器有效负载
当数据库事件触发器调用你的逻辑函数时,每条被更改的记录都会对应一个 `DatabaseEventPayload`。 该负载将关于源工作区和对象的元数据与记录级事件组合在一起。
```ts
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';
type Person = {
id: string;
emails?: { primaryEmail?: string };
};
```
有效负载包括:
| 属性 | 描述 |
| ------------------------------------------------ | ------------------------------------------------------------ |
| `name` | 事件名称,例如 `person.updated`。 |
| `workspaceId` | 事件发生的工作区。 |
| `objectMetadata` | 已更改对象的元数据。 |
| `recordId` | 已更改记录的 ID。 |
| `userId`, `userWorkspaceId`, `workspaceMemberId` | 当事件由工作区用户触发时的操作者字段。 |
| `properties` | 事件的记录数据,根据操作不同,包含 `before`、`after`、`diff` 和 `updatedFields`。 |
| 事件 | 记录数据 |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `person.created` | `event.properties.after` |
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
| `person.destroyed` | `event.properties.before` |
对于软删除,`.deleted` 遵循更新样式的结构,因为记录的 `deletedAt` 字段发生了变化。
对于永久删除,请使用 `.destroyed`。
<Note>
`databaseEventTriggerSettings.updatedFields` 会筛选出哪些更新事件会触发该函数。
`event.properties.updatedFields` 告诉你在当前事件中哪些字段实际发生了变化。
</Note>
创建事件示例:
```ts
type PersonCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Person>
>;
const handler = async (event: PersonCreatedEvent) => {
const person = event.properties.after;
return {
personId: event.recordId,
email: person.emails?.primaryEmail,
};
};
```
更新事件示例:
```ts
type PersonUpdatedEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<Person>
>;
const handler = async (event: PersonUpdatedEvent) => {
const { before, after, diff, updatedFields } = event.properties;
return {
personId: event.recordId,
updatedFields,
previousEmail: before.emails?.primaryEmail,
currentEmail: after.emails?.primaryEmail,
emailDiff: diff.emails,
};
};
```
仅在 email 更新时触发:
```ts
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['emails'],
},
});
```
销毁事件示例:
```ts
type PersonDestroyedEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<Person>
>;
const handler = async (event: PersonDestroyedEvent) => {
const personBeforeDestroy = event.properties.before;
return {
personId: event.recordId,
email: personBeforeDestroy.emails?.primaryEmail,
};
};
```
#### 将函数公开为 AI 工具或工作流操作
逻辑函数可以在两个入口对外公开,每个入口都有各自的触发器:
@@ -4087,8 +4087,6 @@ export type Query = {
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
commandMenuItem?: Maybe<CommandMenuItem>;
commandMenuItems: Array<CommandMenuItem>;
connectedAccountById?: Maybe<ConnectedAccountPublicDto>;
connectedAccounts: Array<ConnectedAccountPublicDto>;
currentUser: User;
currentWorkspace: Workspace;
enterpriseCheckoutSession?: Maybe<Scalars['String']>;
@@ -4236,11 +4234,6 @@ export type QueryCommandMenuItemArgs = {
};
export type QueryConnectedAccountByIdArgs = {
id: Scalars['UUID'];
};
export type QueryEnterpriseCheckoutSessionArgs = {
billingInterval?: InputMaybe<Scalars['String']>;
};
@@ -6899,15 +6892,6 @@ export type UpdateMessageFoldersMutationVariables = Exact<{
export type UpdateMessageFoldersMutation = { __typename?: 'Mutation', updateMessageFolders: Array<{ __typename?: 'MessageFolder', id: string, isSynced: boolean }> };
export type PublicConnectionParamsFragment = { __typename?: 'PublicConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null };
export type ConnectedAccountByIdQueryVariables = Exact<{
id: Scalars['UUID'];
}>;
export type ConnectedAccountByIdQuery = { __typename?: 'Query', connectedAccountById?: { __typename?: 'ConnectedAccountPublicDTO', id: string, handle: string, provider: string, scopes?: Array<string> | null, userWorkspaceId: string, connectionParameters?: { __typename?: 'PublicImapSmtpCaldavConnectionParameters', IMAP?: { __typename?: 'PublicConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null } | null, SMTP?: { __typename?: 'PublicConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null } | null, CALDAV?: { __typename?: 'PublicConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null } | null } | null } | null };
export type GetConnectedImapSmtpCaldavAccountQueryVariables = Exact<{
id: Scalars['UUID'];
}>;
@@ -7919,7 +7903,6 @@ export const MarketplaceAppDetailFieldsFragmentDoc = {"kind":"Document","definit
export const MarketplaceAppFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"MarketplaceAppFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"MarketplaceApp"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"author"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}}]}}]} as unknown as DocumentNode<MarketplaceAppFieldsFragment, unknown>;
export const NavigationMenuItemFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NavigationMenuItemFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NavigationMenuItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"targetRecordId"}},{"kind":"Field","name":{"kind":"Name","value":"targetObjectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"folderId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutId"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<NavigationMenuItemFieldsFragment, unknown>;
export const NavigationMenuItemQueryFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NavigationMenuItemQueryFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NavigationMenuItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NavigationMenuItemFields"}},{"kind":"Field","name":{"kind":"Name","value":"targetRecordIdentifier"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"labelIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"imageIdentifier"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NavigationMenuItemFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NavigationMenuItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"targetRecordId"}},{"kind":"Field","name":{"kind":"Name","value":"targetObjectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"folderId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutId"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<NavigationMenuItemQueryFieldsFragment, unknown>;
export const PublicConnectionParamsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PublicConnectionParams"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PublicConnectionParametersOutput"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]} as unknown as DocumentNode<PublicConnectionParamsFragment, unknown>;
export const ApplicationRegistrationFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<ApplicationRegistrationFragmentFragment, unknown>;
export const BillingPriceLicensedFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingPriceLicensedFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPriceLicensed"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"recurringInterval"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageType"}},{"kind":"Field","name":{"kind":"Name","value":"creditAmount"}}]}}]} as unknown as DocumentNode<BillingPriceLicensedFragmentFragment, unknown>;
export const BillingPriceMeteredFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingPriceMeteredFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPriceMetered"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"priceUsageType"}},{"kind":"Field","name":{"kind":"Name","value":"recurringInterval"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"tiers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"flatAmount"}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"upTo"}}]}}]}}]} as unknown as DocumentNode<BillingPriceMeteredFragmentFragment, unknown>;
@@ -8070,7 +8053,6 @@ export const UpdateCalendarChannelDocument = {"kind":"Document","definitions":[{
export const UpdateMessageChannelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMessageChannel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateMessageChannelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMessageChannel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"excludeNonProfessionalEmails"}},{"kind":"Field","name":{"kind":"Name","value":"excludeGroupEmails"}},{"kind":"Field","name":{"kind":"Name","value":"messageFolderImportPolicy"}}]}}]}}]} as unknown as DocumentNode<UpdateMessageChannelMutation, UpdateMessageChannelMutationVariables>;
export const UpdateMessageFolderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMessageFolder"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateMessageFolderInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMessageFolder"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isSynced"}}]}}]}}]} as unknown as DocumentNode<UpdateMessageFolderMutation, UpdateMessageFolderMutationVariables>;
export const UpdateMessageFoldersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMessageFolders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateMessageFoldersInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMessageFolders"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isSynced"}}]}}]}}]} as unknown as DocumentNode<UpdateMessageFoldersMutation, UpdateMessageFoldersMutationVariables>;
export const ConnectedAccountByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectedAccountById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectedAccountById"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PublicConnectionParams"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PublicConnectionParams"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PublicConnectionParams"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PublicConnectionParams"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PublicConnectionParametersOutput"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]} as unknown as DocumentNode<ConnectedAccountByIdQuery, ConnectedAccountByIdQueryVariables>;
export const GetConnectedImapSmtpCaldavAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetConnectedImapSmtpCaldavAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getConnectedImapSmtpCaldavAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>;
export const MyCalendarChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyCalendarChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myCalendarChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyCalendarChannelsQuery, MyCalendarChannelsQueryVariables>;
export const MyConnectedAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"authFailedAt"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"handleAliases"}},{"kind":"Field","name":{"kind":"Name","value":"lastSignedInAt"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionProviderId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"lastCredentialsRefreshedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyConnectedAccountsQuery, MyConnectedAccountsQueryVariables>;
@@ -28,6 +28,13 @@ export type ComputeStepOutputSchemaInput = {
workflowVersionId?: InputMaybe<Scalars['UUID']>;
};
export type ConnectedAccountHandleDto = {
__typename?: 'ConnectedAccountHandleDTO';
handle: Scalars['String'];
id: Scalars['UUID'];
provider: Scalars['String'];
};
export type CreateDraftFromWorkflowVersionInput = {
/** Workflow ID */
workflowId: Scalars['UUID'];
@@ -252,6 +259,7 @@ export type Query = {
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
isMaintenanceModeBannerDismissed: Scalars['Boolean'];
search: SearchResultConnection;
workflowStepConnectedAccountHandle?: Maybe<ConnectedAccountHandleDto>;
};
@@ -306,6 +314,11 @@ export type QuerySearchArgs = {
searchInput: Scalars['String'];
};
export type QueryWorkflowStepConnectedAccountHandleArgs = {
connectedAccountId: Scalars['UUID'];
};
export type RunWorkflowVersion = {
__typename?: 'RunWorkflowVersion';
workflowRunId: Scalars['UUID'];
@@ -745,6 +758,13 @@ export type UpdateWorkflowVersionStepMutationVariables = Exact<{
export type UpdateWorkflowVersionStepMutation = { __typename?: 'Mutation', updateWorkflowVersionStep: { __typename?: 'WorkflowAction', id: any, name: string, type: WorkflowActionType, settings: any, valid: boolean, nextStepIds?: Array<any> | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } };
export type WorkflowStepConnectedAccountHandleQueryVariables = Exact<{
connectedAccountId: Scalars['UUID'];
}>;
export type WorkflowStepConnectedAccountHandleQuery = { __typename?: 'Query', workflowStepConnectedAccountHandle?: { __typename?: 'ConnectedAccountHandleDTO', id: any, handle: string, provider: string } | null };
export type SubmitFormStepMutationVariables = Exact<{
input: SubmitFormStepInput;
}>;
@@ -794,6 +814,7 @@ export const RunWorkflowVersionDocument = {"kind":"Document","definitions":[{"ki
export const StopWorkflowRunDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StopWorkflowRun"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workflowRunId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stopWorkflowRun"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workflowRunId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workflowRunId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"__typename"}}]}}]}}]} as unknown as DocumentNode<StopWorkflowRunMutation, StopWorkflowRunMutationVariables>;
export const UpdateWorkflowRunStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkflowRunStep"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWorkflowRunStepInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkflowRunStep"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"settings"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"nextStepIds"}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"x"}},{"kind":"Field","name":{"kind":"Name","value":"y"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateWorkflowRunStepMutation, UpdateWorkflowRunStepMutationVariables>;
export const UpdateWorkflowVersionStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkflowVersionStep"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWorkflowVersionStepInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkflowVersionStep"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"settings"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"nextStepIds"}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"x"}},{"kind":"Field","name":{"kind":"Name","value":"y"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateWorkflowVersionStepMutation, UpdateWorkflowVersionStepMutationVariables>;
export const WorkflowStepConnectedAccountHandleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"WorkflowStepConnectedAccountHandle"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workflowStepConnectedAccountHandle"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}}]}}]}}]} as unknown as DocumentNode<WorkflowStepConnectedAccountHandleQuery, WorkflowStepConnectedAccountHandleQueryVariables>;
export const SubmitFormStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SubmitFormStep"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SubmitFormStepInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"submitFormStep"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<SubmitFormStepMutation, SubmitFormStepMutationVariables>;
export const TestHttpRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TestHttpRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"TestHttpRequestInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"testHttpRequest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"result"}},{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"statusText"}},{"kind":"Field","name":{"kind":"Name","value":"headers"}}]}}]}}]} as unknown as DocumentNode<TestHttpRequestMutation, TestHttpRequestMutationVariables>;
export const UpdateWorkflowVersionPositionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkflowVersionPositions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWorkflowVersionPositionsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkflowVersionPositions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<UpdateWorkflowVersionPositionsMutation, UpdateWorkflowVersionPositionsMutationVariables>;
@@ -9,7 +9,7 @@ import { contextStoreFilterGroupsComponentState } from '@/context-store/states/c
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
@@ -90,8 +90,8 @@ export const buildHeadlessCommandContextApi = ({
? (currentWorkspaceMember?.timeZone ?? systemTimeZone)
: systemTimeZone;
const fieldMetadataItemByIdMap = store.get(
fieldMetadataItemByIdMapSelector.atom,
const flattenedFieldMetadataItems = store.get(
flattenedFieldMetadataItemsSelector.atom,
);
const graphqlFilter = isDefined(objectMetadataItem)
@@ -100,7 +100,7 @@ export const buildHeadlessCommandContextApi = ({
contextStoreFilters: filters,
contextStoreFilterGroups: filterGroups,
objectMetadataItem,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
fieldMetadataItems: flattenedFieldMetadataItems,
filterValueDependencies: {
currentWorkspaceMemberId: currentWorkspaceMember?.id,
timeZone: userTimezone,
@@ -6,6 +6,7 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/s
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
@@ -54,6 +55,10 @@ export const useFindManyRecordsSelectedInContextStore = ({
fieldMetadataItemByIdMapSelector,
);
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const isSoftDeleteFilterActive = contextStoreFilters.some(
(filter) =>
fieldMetadataItemByIdMap.get(filter.fieldMetadataId)?.name ===
@@ -65,7 +70,7 @@ export const useFindManyRecordsSelectedInContextStore = ({
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
fieldMetadataItems: flattenedFieldMetadataItems,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});
@@ -29,8 +29,7 @@ describe('computeContextStoreFilters', () => {
contextStoreFilters: [],
contextStoreFilterGroups: [],
objectMetadataItem: personObjectMetadataItem,
findFieldMetadataItemById: (id) =>
personObjectMetadataItem.fields.find((field) => field.id === id),
fieldMetadataItems: personObjectMetadataItem.fields,
filterValueDependencies: mockFilterValueDependencies,
contextStoreAnyFieldFilterValue: '',
});
@@ -75,8 +74,7 @@ describe('computeContextStoreFilters', () => {
contextStoreFilters,
contextStoreFilterGroups: [],
objectMetadataItem: personObjectMetadataItem,
findFieldMetadataItemById: (id) =>
personObjectMetadataItem.fields.find((field) => field.id === id),
fieldMetadataItems: personObjectMetadataItem.fields,
filterValueDependencies: mockFilterValueDependencies,
contextStoreAnyFieldFilterValue: '',
});
@@ -7,9 +7,9 @@ import {
type RecordFilterValueDependencies,
type RecordGqlOperationFilter,
} from 'twenty-shared/types';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import {
computeRecordGqlOperationFilter,
type FindFieldMetadataItemById,
turnAnyFieldFilterIntoRecordGqlFilter,
} from 'twenty-shared/utils';
@@ -18,7 +18,7 @@ type ComputeContextStoreFiltersProps = {
contextStoreFilters: RecordFilter[];
contextStoreFilterGroups: RecordFilterGroup[];
objectMetadataItem: EnrichedObjectMetadataItem;
findFieldMetadataItemById: FindFieldMetadataItemById;
fieldMetadataItems: FieldMetadataItem[];
filterValueDependencies: RecordFilterValueDependencies;
contextStoreAnyFieldFilterValue: string;
};
@@ -28,7 +28,7 @@ export const computeContextStoreFilters = ({
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
findFieldMetadataItemById,
fieldMetadataItems,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
}: ComputeContextStoreFiltersProps) => {
@@ -45,7 +45,7 @@ export const computeContextStoreFilters = ({
recordGqlFilterForAnyFieldFilter,
computeRecordGqlOperationFilter({
filterValueDependencies,
findFieldMetadataItemById,
fieldMetadataItems,
recordFilters: contextStoreFilters,
recordFilterGroups: contextStoreFilterGroups,
}),
@@ -74,7 +74,7 @@ export const computeContextStoreFilters = ({
},
computeRecordGqlOperationFilter({
filterValueDependencies,
findFieldMetadataItemById,
fieldMetadataItems,
recordFilters: contextStoreFilters,
recordFilterGroups: contextStoreFilterGroups,
}),
@@ -58,15 +58,8 @@ export const PromiseRejectionEffect = () => {
scope.setExtras({ mechanism: 'onUnhandle' });
const fingerprint = hasErrorCode(error) ? error.code : error.message;
if (isDefined(fingerprint)) {
scope.setFingerprint([fingerprint]);
}
if (error instanceof Error) {
error.name = error.message;
}
scope.setFingerprint([fingerprint]);
error.name = error.message;
return scope;
});
} catch (sentryError) {
@@ -1,5 +1,9 @@
import { useLocation } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/display/components/NavigationMenuItemIcon';
import { getNavigationMenuItemColor } from '@/navigation-menu-item/common/utils/getNavigationMenuItemColor';
import { getPageLayoutNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/page-layout/utils/getPageLayoutNavigationMenuItemComputedLink';
import type { NavigationMenuItemSectionContentProps } from '@/navigation-menu-item/display/sections/types/NavigationMenuItemSectionContentProps';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
@@ -18,8 +22,15 @@ export const NavigationMenuItemPageLayoutDisplay = ({
isLayoutCustomizationModeEnabledState,
);
const { getIcon } = useIcons();
const location = useLocation();
const label = item.name ?? '';
const computedLink = getPageLayoutNavigationMenuItemComputedLink(item);
const pageLayoutColor = getNavigationMenuItemColor(item);
const Icon = isDefined(item.icon) ? getIcon(item.icon) : undefined;
const isActive = computedLink !== '' && location.pathname === computedLink;
return (
<NavigationDrawerItem
@@ -34,8 +45,9 @@ export const NavigationMenuItemPageLayoutDisplay = ({
? editModeProps?.onEditModeClick
: undefined
}
Icon={() => <NavigationMenuItemIcon navigationMenuItem={item} />}
active={false}
Icon={Icon}
iconColor={pageLayoutColor}
active={isActive}
isSelectedInEditMode={editModeProps?.isSelectedInEditMode}
isDragging={isDragging}
triggerEvent="CLICK"
@@ -0,0 +1,65 @@
import { generateDepthRecordGqlFieldsFromFields } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromFields';
import { reportMissingMorphRelationTargetMetadata } from '@/object-record/graphql/record-gql-fields/utils/reportMissingMorphRelationTargetMetadata';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
jest.mock(
'@/object-record/graphql/record-gql-fields/utils/reportMissingMorphRelationTargetMetadata',
() => ({
reportMissingMorphRelationTargetMetadata: jest.fn(),
}),
);
describe('generateDepthRecordGqlFieldsFromFields', () => {
it('should skip morph relations whose target object metadata is missing', () => {
const result = generateDepthRecordGqlFieldsFromFields({
objectMetadataItems: [
{
id: 'company-id',
fields: [],
labelIdentifierFieldMetadataId: null,
imageIdentifierFieldMetadataId: null,
nameSingular: 'company',
namePlural: 'companies',
},
],
fields: [
{
id: 'field-id',
name: 'company',
type: FieldMetadataType.MORPH_RELATION,
settings: { relationType: RelationType.MANY_TO_ONE },
relation: null,
morphRelations: [
{
type: RelationType.MANY_TO_ONE,
targetObjectMetadata: {
id: 'company-id',
nameSingular: 'company',
namePlural: 'companies',
},
},
{
type: RelationType.MANY_TO_ONE,
targetObjectMetadata: {
id: 'oem-plant-id',
nameSingular: 'oemPlant',
namePlural: 'oemPlants',
},
},
],
},
],
depth: 1,
});
expect(result).toEqual({
companyCompany: { id: true },
companyCompanyId: true,
});
expect(reportMissingMorphRelationTargetMetadata).toHaveBeenCalledWith({
fieldMetadataName: 'company',
missingMorphTargetNames: ['oemPlant'],
});
});
});
@@ -10,6 +10,7 @@ import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/
import { buildIdentifierGqlFields } from '@/object-record/graphql/record-gql-fields/utils/buildIdentifierGqlFields';
import { generateActivityTargetGqlFields } from '@/object-record/graphql/record-gql-fields/utils/generateActivityTargetGqlFields';
import { generateJunctionRelationGqlFields } from '@/object-record/graphql/record-gql-fields/utils/generateJunctionRelationGqlFields';
import { reportMissingMorphRelationTargetMetadata } from '@/object-record/graphql/record-gql-fields/utils/reportMissingMorphRelationTargetMetadata';
import { isJunctionRelationField } from '@/object-record/record-field/ui/utils/junction/isJunctionRelationField';
import {
computeMorphRelationGqlFieldName,
@@ -127,7 +128,9 @@ export const generateDepthRecordGqlFieldsFromFields = ({
);
}
const morphGqlFields = fieldMetadata.morphRelations.map(
const missingMorphTargetNames: string[] = [];
const morphGqlFields = fieldMetadata.morphRelations.flatMap(
(morphRelation) => {
const morphTargetObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
@@ -135,28 +138,39 @@ export const generateDepthRecordGqlFieldsFromFields = ({
);
if (!morphTargetObjectMetadataItem) {
throw new Error(
`Target object metadata item not found for ${fieldMetadata.name} (morph target ${morphRelation.targetObjectMetadata.nameSingular})`,
missingMorphTargetNames.push(
morphRelation.targetObjectMetadata.nameSingular,
);
return [];
}
return {
gqlField: computeMorphRelationGqlFieldName({
fieldName: fieldMetadata.name,
relationType: morphRelation.type,
targetObjectMetadataNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural:
morphRelation.targetObjectMetadata.namePlural,
}),
fieldMetadata,
relationIdentifierSubGqlFields: buildIdentifierGqlFields(
morphTargetObjectMetadataItem,
),
};
return [
{
gqlField: computeMorphRelationGqlFieldName({
fieldName: fieldMetadata.name,
relationType: morphRelation.type,
targetObjectMetadataNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural:
morphRelation.targetObjectMetadata.namePlural,
}),
fieldMetadata,
relationIdentifierSubGqlFields: buildIdentifierGqlFields(
morphTargetObjectMetadataItem,
),
},
];
},
);
if (missingMorphTargetNames.length > 0) {
reportMissingMorphRelationTargetMetadata({
fieldMetadataName: fieldMetadata.name,
missingMorphTargetNames,
});
}
return {
...recordGqlFields,
...morphGqlFields.reduce(
@@ -0,0 +1,51 @@
type ReportMissingMorphRelationTargetMetadataArgs = {
fieldMetadataName: string;
missingMorphTargetNames: string[];
};
const MORPH_RELATION_TARGET_METADATA_MISMATCH_MESSAGE =
'Missing morph target object metadata while generating record GraphQL fields';
export const reportMissingMorphRelationTargetMetadata = ({
fieldMetadataName,
missingMorphTargetNames,
}: ReportMissingMorphRelationTargetMetadataArgs) => {
const pathname =
typeof window === 'undefined' ? undefined : window.location.pathname;
const uniqueMissingMorphTargetNames = [...new Set(missingMorphTargetNames)];
// oxlint-disable-next-line no-console
console.warn(MORPH_RELATION_TARGET_METADATA_MISMATCH_MESSAGE, {
fieldMetadataName,
missingMorphTargetNames: uniqueMissingMorphTargetNames,
pathname,
});
import('@sentry/react')
.then(({ captureMessage, withScope }) => {
withScope((scope) => {
scope.setLevel('warning');
scope.setTag('error_type', 'morph_target_metadata_mismatch');
scope.setTag('field_name', fieldMetadataName);
scope.setExtra(
'missingMorphTargetNames',
uniqueMissingMorphTargetNames,
);
scope.setExtra('pathname', pathname);
scope.setFingerprint([
'morph_target_metadata_mismatch',
fieldMetadataName,
...[...uniqueMissingMorphTargetNames].sort(),
]);
captureMessage(MORPH_RELATION_TARGET_METADATA_MISMATCH_MESSAGE);
});
})
.catch((sentryError) => {
// oxlint-disable-next-line no-console
console.warn(
'Failed to capture morph target metadata mismatch warning with Sentry:',
sentryError,
);
});
};
@@ -1,4 +1,4 @@
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { useRecordCalendarMonthDaysRange } from '@/object-record/record-calendar/month/hooks/useRecordCalendarMonthDaysRange';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
@@ -48,8 +48,8 @@ export const useRecordCalendarQueryDateRangeFilter = (
const { filterValueDependencies } = useFilterValueDependencies();
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const anyFieldFilterValue = useAtomComponentStateValue(
@@ -110,7 +110,7 @@ export const useRecordCalendarQueryDateRangeFilter = (
filterValueDependencies,
recordFilters: calendarRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
fieldMetadataItems: flattenedFieldMetadataItems,
});
const { recordGqlOperationFilter: anyFieldFilter } =
@@ -22,14 +22,11 @@ const petMockObjectMetadataItem = getMockObjectMetadataItemOrThrow('pet');
const personMockObjectMetadataItem = getMockObjectMetadataItemOrThrow('person');
const findCompanyFieldById = (id: string) =>
companyMockObjectMetadataItem.fields.find((field) => field.id === id);
const companyFields = companyMockObjectMetadataItem.fields;
const findPersonFieldById = (id: string) =>
personMockObjectMetadataItem.fields.find((field) => field.id === id);
const personFields = personMockObjectMetadataItem.fields;
const findPetFieldById = (id: string) =>
petMockObjectMetadataItem.fields.find((field) => field.id === id);
const petFields = petMockObjectMetadataItem.fields;
const mockFilterValueDependencies: RecordFilterValueDependencies = {
currentWorkspaceMemberId: '32219445-f587-4c40-b2b1-6d3205ed96da',
@@ -63,7 +60,7 @@ describe('computeViewRecordGqlOperationFilter', () => {
filterValueDependencies: mockFilterValueDependencies,
recordFilters: [nameFilter],
recordFilterGroups: [],
findFieldMetadataItemById: findCompanyFieldById,
fieldMetadataItems: companyFields,
});
expect(result).toEqual({
@@ -116,7 +113,7 @@ describe('computeViewRecordGqlOperationFilter', () => {
filterValueDependencies: mockFilterValueDependencies,
recordFilters: [nameFilter, employeesFilter],
recordFilterGroups: [],
findFieldMetadataItemById: findCompanyFieldById,
fieldMetadataItems: companyFields,
});
expect(result).toEqual({
@@ -196,7 +193,7 @@ describe('should work as expected for the different field types', () => {
addressFilterIsNotEmpty,
],
recordFilterGroups: [],
findFieldMetadataItemById: findCompanyFieldById,
fieldMetadataItems: companyFields,
});
expect(result).toEqual({
@@ -660,7 +657,7 @@ describe('should work as expected for the different field types', () => {
phonesFilterIsNotEmpty,
],
recordFilterGroups: [],
findFieldMetadataItemById: findPersonFieldById,
fieldMetadataItems: personFields,
});
expect(result).toEqual({
@@ -857,7 +854,7 @@ describe('should work as expected for the different field types', () => {
emailsFilterIsNotEmpty,
],
recordFilterGroups: [],
findFieldMetadataItemById: findPersonFieldById,
fieldMetadataItems: personFields,
});
expect(result).toEqual({
@@ -1069,7 +1066,7 @@ describe('should work as expected for the different field types', () => {
dateFilterIsNotEmpty,
],
recordFilterGroups: [],
findFieldMetadataItemById: findCompanyFieldById,
fieldMetadataItems: companyFields,
});
expect(result).toEqual({
@@ -1171,7 +1168,7 @@ describe('should work as expected for the different field types', () => {
employeesFilterIsNotEmpty,
],
recordFilterGroups: [],
findFieldMetadataItemById: findCompanyFieldById,
fieldMetadataItems: companyFields,
});
expect(result).toEqual({
@@ -1273,7 +1270,7 @@ describe('should work as expected for the different field types', () => {
ARRFilterIsNot,
],
recordFilterGroups: [],
findFieldMetadataItemById: findCompanyFieldById,
fieldMetadataItems: companyFields,
});
expect(result).toEqual({
@@ -1350,7 +1347,7 @@ describe('should work as expected for the different field types', () => {
filterValueDependencies: mockFilterValueDependencies,
recordFilters: [ARRFilterIn, ARRFilterNotIn],
recordFilterGroups: [],
findFieldMetadataItemById: findCompanyFieldById,
fieldMetadataItems: companyFields,
});
expect(result).toEqual({
@@ -1410,7 +1407,7 @@ describe('should work as expected for the different field types', () => {
filterValueDependencies: mockFilterValueDependencies,
recordFilters: [selectFilterIs, selectFilterIsNot],
recordFilterGroups: [],
findFieldMetadataItemById: findPetFieldById,
fieldMetadataItems: petFields,
});
expect(result).toEqual({
@@ -1483,7 +1480,7 @@ describe('should work as expected for the different field types', () => {
multiSelectFilterDoesNotContain,
],
recordFilterGroups: [],
findFieldMetadataItemById: findCompanyFieldById,
fieldMetadataItems: companyFields,
});
expect(result).toEqual({
@@ -6,7 +6,7 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/s
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
@@ -54,8 +54,8 @@ export const RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect =
const { filterValueDependencies } = useFilterValueDependencies();
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const computedFilter = computeContextStoreFilters({
@@ -63,7 +63,7 @@ export const RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect =
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
fieldMetadataItems: flattenedFieldMetadataItems,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});
@@ -7,7 +7,7 @@ import { contextStoreFilterGroupsComponentState } from '@/context-store/states/c
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/object-options-dropdown/constants/ExportTableDataDefaultPageSize';
@@ -89,8 +89,8 @@ export const useRecordIndexLazyFetchRecords = ({
const { filterValueDependencies } = useFilterValueDependencies();
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const findManyRecordsParams = useFindManyRecordIndexTableParams(
@@ -107,7 +107,7 @@ export const useRecordIndexLazyFetchRecords = ({
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
fieldMetadataItems: flattenedFieldMetadataItems,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});
@@ -1,6 +1,6 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
@@ -49,12 +49,12 @@ export const useFindManyRecordIndexTableParams = (
const { filterValueDependencies } = useFilterValueDependencies();
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const currentFilters = computeRecordGqlOperationFilter({
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
fieldMetadataItems: flattenedFieldMetadataItems,
recordFilterGroups: currentRecordFilterGroups,
recordFilters: currentRecordFilters,
filterValueDependencies,
@@ -1,5 +1,5 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { useRelevantRecordsGqlFields } from '@/object-record/record-field/hooks/useRelevantRecordsGqlFields';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
@@ -38,15 +38,15 @@ export const useRecordIndexGroupCommonQueryVariables = () => {
const { filterValueDependencies } = useFilterValueDependencies();
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const requestFilters = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
fieldMetadataItems: flattenedFieldMetadataItems,
});
const anyFieldFilterValue = useAtomComponentStateValue(
@@ -1,5 +1,5 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery';
@@ -48,15 +48,15 @@ export const useRecordIndexGroupsAggregatesGroupBy = ({
const { filterValueDependencies } = useFilterValueDependencies();
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const requestFilters = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
fieldMetadataItems: flattenedFieldMetadataItems,
});
const { recordAggregateGqlField } =
@@ -1,6 +1,6 @@
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
@@ -40,8 +40,8 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
const { filterValueDependencies } = useFilterValueDependencies();
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const currentRecordFilters = useAtomComponentStateValue(
@@ -63,7 +63,7 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
objectNameSingular: objectMetadataItem.nameSingular,
variables: {
filter: computeRecordGqlOperationFilter({
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
fieldMetadataItems: flattenedFieldMetadataItems,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
filterValueDependencies,
@@ -77,7 +77,7 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
currentRecordFilterGroups,
filterValueDependencies,
currentRecordSorts,
fieldMetadataItemByIdMap,
flattenedFieldMetadataItems,
],
);
@@ -1,4 +1,4 @@
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
@@ -46,14 +46,14 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
const dateLocale = useAtomStateValue(dateLocaleState);
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const { filterValueDependencies } = useFilterValueDependencies();
const requestFilters = computeRecordGqlOperationFilter({
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
fieldMetadataItems: flattenedFieldMetadataItems,
filterValueDependencies,
recordFilterGroups: currentRecordFilterGroups,
recordFilters: currentRecordFilters,
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
@@ -16,8 +16,8 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const { filterValueDependencies } = useFilterValueDependencies();
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const currentRecordFilters = useAtomComponentStateValue(
@@ -39,7 +39,7 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
objectNameSingular: objectMetadataItem.nameSingular,
variables: {
filter: computeRecordGqlOperationFilter({
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
fieldMetadataItems: flattenedFieldMetadataItems,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
filterValueDependencies,
@@ -53,7 +53,7 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
currentRecordFilterGroups,
filterValueDependencies,
currentRecordSorts,
fieldMetadataItemByIdMap,
flattenedFieldMetadataItems,
],
);
@@ -4,7 +4,7 @@ import { contextStoreFilterGroupsComponentState } from '@/context-store/states/c
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useIncrementalUpdateManyRecords } from '@/object-record/hooks/useIncrementalUpdateManyRecords';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
@@ -45,8 +45,8 @@ export const useUpdateMultipleRecordsActions = ({
const { filterValueDependencies } = useFilterValueDependencies();
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const graphqlFilter = computeContextStoreFilters({
@@ -54,7 +54,7 @@ export const useUpdateMultipleRecordsActions = ({
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
fieldMetadataItems: flattenedFieldMetadataItems,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});

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