Compare commits

..
Author SHA1 Message Date
sonarly-bot 6ad5616c76 fix(workflow): guard iterator variable schema access
https://sonarly.com/issue/38763?type=bug

Opening a specific workflow record crashes the page with a frontend TypeError when rendering workflow variable labels. The failure is caused by unsafe access to iterator schema fields during variable-chip rendering.

Fix: Implemented a defensive fix in iterator variable search so malformed iterator schemas no longer crash rendering.

What changed:
- In `searchVariableThroughIteratorOutputSchema`, I now guard `iteratorOutputSchema.currentItem` before any property access.
- If `currentItem` is missing, the function safely returns `{ variableLabel: undefined, variablePathLabel: undefined }` (same non-crashing fallback used elsewhere).
- Subsequent label/type reads now use a local `currentItem` variable, avoiding repeated unsafe property access.

Why this fixes the incident:
- The production crash was caused by direct dereference of `currentItem.value` when `currentItem` was undefined for a specific workflow schema payload.
- With this guard, malformed/incomplete iterator output schema data degrades gracefully instead of throwing in render.

I also added a regression test that explicitly covers “currentItem missing from iterator output schema” and asserts the safe undefined-label fallback.

Authored by Sonarly by autonomous analysis (run 44106).
2026-05-19 19:19:08 +00:00
1406 changed files with 1157 additions and 11468 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/src/content/releases/{VERSION}.mdx`
- Example: `packages/twenty-website/src/content/releases/1.9.0.mdx`
- Path: `packages/twenty-website-new/src/content/releases/{VERSION}.mdx`
- Example: `packages/twenty-website-new/src/content/releases/1.9.0.mdx`
**Create image folder:**
- 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/`
- 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/`
```bash
# Create the image folder
mkdir -p packages/twenty-website/public/images/releases/{MINOR_VERSION}
mkdir -p packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
```
### 3. Move Illustration Files
**Source:** `/Users/thomascolasdesfrancs/Downloads/🆕`
**Destination:** `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
**Destination:** `packages/twenty-website-new/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/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
cd packages/twenty-website && node scripts/convert-png-to-webp.mjs
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
```
### 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/src/content/releases/` for examples
- Check `packages/twenty-website-new/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/src/content/releases/{VERSION}.mdx
cursor packages/twenty-website-new/src/content/releases/{VERSION}.mdx
# Open image folder to verify illustrations
open packages/twenty-website/public/images/releases/{MINOR_VERSION}
open packages/twenty-website-new/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/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
- 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
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/src/content/releases/{VERSION}.mdx
git add packages/twenty-website/public/images/releases/{MINOR_VERSION}/
git add packages/twenty-website-new/src/content/releases/{VERSION}.mdx
git add packages/twenty-website-new/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/src/content/releases/{VERSION}.mdx\`
Changelog file: \`packages/twenty-website-new/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/src/content/releases/`
- **Location**: `packages/twenty-website-new/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/public/images/releases/`
- **Location**: `packages/twenty-website-new/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/src/content/releases/{VERSION}.mdx
Images path: packages/twenty-website/public/images/releases/{MINOR_VERSION}/
Changelog path: packages/twenty-website-new/src/content/releases/{VERSION}.mdx
Images path: packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
```
## Tips
+2 -2
View File
@@ -1,5 +1,5 @@
#
# Crowdin CLI configuration for Website translations (twenty-website)
# Crowdin CLI configuration for Website translations (twenty-website-new)
# 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/src/locales/en.po
- source: packages/twenty-website-new/src/locales/en.po
#
# Translation files path
#
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
files: |
package.json
yarn.lock
packages/twenty-website/**
packages/twenty-website-new/**
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.CI_PRIVILEGED_DISPATCH_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_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:lingui:compile --strict
run: npx nx run twenty-website-new: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/src/locales/en.po'
translation: 'packages/twenty-website/src/locales/%locale%.po'
source: 'packages/twenty-website-new/src/locales/en.po'
translation: 'packages/twenty-website-new/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:lingui:compile
npx nx run twenty-website-new:lingui:compile
git status
git add packages/twenty-website/src/locales
git add packages/twenty-website-new/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/**'
- 'packages/twenty-website-new/**'
- '.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:lingui:extract
run: npx nx run twenty-website-new: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/src/locales
git add packages/twenty-website-new/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:lingui:compile
run: npx nx run twenty-website-new: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/src/locales/generated
git add packages/twenty-website-new/src/locales/generated
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
@@ -1,69 +0,0 @@
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",
"path": "../packages/twenty-website"
"name": "packages/twenty-website-new",
"path": "../packages/twenty-website-new"
}
],
"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/ # Next.js marketing website
├── twenty-website-new/ # 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/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
<img src="./packages/twenty-website-new/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/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://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://www.twenty.com">
<picture>
<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" />
<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" />
</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/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-new/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/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
### <img src="./packages/twenty-website-new/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/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
### <img src="./packages/twenty-website-new/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/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
### <img src="./packages/twenty-website-new/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/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.
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.
<table align="center">
<tr>
<td width="50%">
<picture>
<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" />
<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" />
</picture>
<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>
<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>
</td>
<td width="50%">
<picture>
<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" />
<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" />
</picture>
<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>
<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>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<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" />
<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" />
</picture>
<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>
<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>
</td>
<td width="50%">
<picture>
<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" />
<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" />
</picture>
<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>
<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>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<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" />
<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" />
</picture>
<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>
<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>
</td>
<td width="50%">
<picture>
<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" />
<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" />
</picture>
<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>
<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>
</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/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>
- <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>
# Thanks
<p align="center">
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website-new/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
<a href="https://greptile.com"><img src="./packages/twenty-website-new/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
<a href="https://sentry.io/"><img src="./packages/twenty-website-new/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
<a href="https://crowdin.com/"><img src="./packages/twenty-website-new/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/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>
<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>
+1 -1
View File
@@ -53,7 +53,7 @@
"packages/twenty-ui",
"packages/twenty-utils",
"packages/twenty-zapier",
"packages/twenty-website",
"packages/twenty-website-new",
"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/public/images/core/logo.svg" height="128">
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website-new/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.6.0",
"version": "2.5.1",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
+1 -6
View File
@@ -74,17 +74,12 @@ 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,
workspaceUrl: options?.workspaceUrl ?? options?.apiUrl,
authenticationMethod: options?.authenticationMethod,
});
},
@@ -1,11 +1,4 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
Avatar,
IconBox,
IconHierarchy,
IconLayout,
IconSettingsAutomation,
} from 'twenty-sdk/ui';
import { useState } from 'react';
import {
@@ -37,7 +30,7 @@ const CATEGORIES = [
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'LOGIC FUNCTION',
label: 'SERVERLESS FUNCT.',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
@@ -66,6 +59,15 @@ 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
@@ -91,18 +93,6 @@ 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={{
@@ -121,12 +111,8 @@ const CategoryCard = ({
style={{
padding: '16px 20px',
background: `${color}22`,
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<CategoryIcon />
<span
style={{
fontSize: '16px',
@@ -168,7 +154,7 @@ const CategoryCard = ({
transition: 'background 0.15s',
}}
>
<IconBox color={color} size={'20px'} />
<ItemIcon color={color} />
<span
style={{
fontSize: '13px',
@@ -204,11 +190,13 @@ const MainPage = () => {
padding: '40px',
}}
>
<Avatar
{/*<Avatar
avatarUrl={getPublicAssetUrl('logo.svg')}
placeholder={APP_DISPLAY_NAME}
placeholderColorSeed={APP_DISPLAY_NAME}
type="squared"
size="xl"
/>
/>*/}
<span
style={{
fontSize: '24px',
@@ -232,7 +220,7 @@ const MainPage = () => {
You can now add content to your app.
</span>
<a
href="/settings/applications#installed"
href="/settings/applications"
style={{
display: 'inline-flex',
alignItems: 'center',
@@ -17,7 +17,7 @@ import {
DEV_API_URL,
serverStart,
} from 'twenty-sdk/cli';
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import {
getDockerInstallInstructions,
isDockerInstalled,
@@ -389,7 +389,7 @@ export class CreateAppCommand {
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
return frontUrl ? normalizeUrl(frontUrl) : null;
return frontUrl?.replace(/\/+$/, '') ?? null;
}
private async readMainPageLayoutUniversalIdentifier(
@@ -17,7 +17,6 @@ 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';
@@ -174,27 +173,6 @@ 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, .yarnrc.yml)');
onProgress?.('Configuring dotfiles (.gitignore, .github)');
await renameDotfiles({ appDirectory });
onProgress?.('Mirroring AGENTS.md to CLAUDE.md');
@@ -47,7 +47,6 @@ 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) {
@@ -1,18 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -1,126 +0,0 @@
# 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).
@@ -1,35 +0,0 @@
{
"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"
}
}
@@ -1,26 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,37 +0,0 @@
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,
},
},
});
@@ -1,17 +0,0 @@
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,
});
@@ -1,673 +0,0 @@
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,
});
@@ -1,26 +0,0 @@
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,
},
});
@@ -1,38 +0,0 @@
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';
@@ -1,37 +0,0 @@
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);
}
};
@@ -1,36 +0,0 @@
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);
}
};
@@ -1,104 +0,0 @@
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',
};
}
};
@@ -1,44 +0,0 @@
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);
}
};
@@ -1,52 +0,0 @@
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);
}
};
@@ -1,45 +0,0 @@
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);
}
};
@@ -1,27 +0,0 @@
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,
};
@@ -1,21 +0,0 @@
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,
};
@@ -1,29 +0,0 @@
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,
};
@@ -1,35 +0,0 @@
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,
};
@@ -1,35 +0,0 @@
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,
};
@@ -1,35 +0,0 @@
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,
};
@@ -1,35 +0,0 @@
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,
});
@@ -1,35 +0,0 @@
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,
});
@@ -1,45 +0,0 @@
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,
},
});
@@ -1,49 +0,0 @@
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,
});
@@ -1,34 +0,0 @@
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,
});
@@ -1,39 +0,0 @@
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,
},
});
@@ -1,35 +0,0 @@
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,
});
@@ -1,35 +0,0 @@
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,
});
@@ -1,5 +0,0 @@
export type SlackAddReactionInput = {
slackChannelId: string;
messageTimestamp: string;
emojiName: string;
};
@@ -1,4 +0,0 @@
export type SlackDeleteMessageInput = {
slackChannelId: string;
messageTimestamp: string;
};
@@ -1,7 +0,0 @@
export type SlackChannelType = 'Public' | 'Private' | 'All';
export type SlackListChannelsInput = {
channelType?: SlackChannelType;
excludeArchived?: boolean;
limit?: number;
};
@@ -1,17 +0,0 @@
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;
};
@@ -1 +0,0 @@
export type SlackMessageBodyFormat = 'plain' | 'markdown';
@@ -1,8 +0,0 @@
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
export type SlackPostEphemeralMessageInput = {
slackChannelId: string;
recipientSlackUserId: string;
messageText: string;
messageFormat?: SlackMessageBodyFormat;
};
@@ -1,8 +0,0 @@
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
export type SlackPostMessageInput = {
slackChannelId: string;
messageText: string;
parentMessageTimestamp?: string;
messageFormat?: SlackMessageBodyFormat;
};
@@ -1,7 +0,0 @@
export type SlackToolResult = {
success: boolean;
message: string;
error?: string;
slackTs?: string;
channel?: string;
};
@@ -1,8 +0,0 @@
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
export type SlackUpdateMessageInput = {
slackChannelId: string;
messageTimestamp: string;
newMessageText: string;
messageFormat?: SlackMessageBodyFormat;
};
@@ -1,19 +0,0 @@
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 };
}
};
@@ -1,18 +0,0 @@
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),
};
};
@@ -1,29 +0,0 @@
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 };
}
};
@@ -1,10 +0,0 @@
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',
});
@@ -1,21 +0,0 @@
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: [],
});
@@ -1,31 +0,0 @@
{
"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"]
}
@@ -1,7 +0,0 @@
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.6.0",
"version": "2.5.1",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
@@ -2998,6 +2998,8 @@ 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,6 +2598,8 @@ export interface Query {
myMessageFolders: MessageFolder[]
myMessageChannels: MessageChannel[]
myConnectedAccounts: ConnectedAccountPublicDTO[]
connectedAccountById?: ConnectedAccountPublicDTO
connectedAccounts: ConnectedAccountPublicDTO[]
myCalendarChannels: CalendarChannel[]
webhooks: Webhook[]
webhook?: Webhook
@@ -5629,6 +5631,8 @@ 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,6 +6176,18 @@ export default {
"myConnectedAccounts": [
269
],
"connectedAccountById": [
269,
{
"id": [
3,
"UUID!"
]
}
],
"connectedAccounts": [
269
],
"myCalendarChannels": [
300,
{
+6
View File
@@ -26,6 +26,12 @@ 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>
@@ -0,0 +1,39 @@
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 { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
import { CoreApiClient, type Person } 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/logic-function`:
Import the `RoutePayload` type from `twenty-sdk`:
```ts
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
@@ -141,115 +141,6 @@ 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 { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
import { CoreApiClient, type Person } 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/logic-function`:
Importieren Sie den Typ `RoutePayload` aus `twenty-sdk`:
```ts
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
@@ -140,115 +140,6 @@ 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 { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
import { CoreApiClient, type Person } 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/logic-function`:
Importe o tipo `RoutePayload` de `twenty-sdk`:
```ts
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
@@ -140,115 +140,6 @@ 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 { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
import { CoreApiClient, type Person } 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/logic-function`:
Importați tipul `RoutePayload` din `twenty-sdk`:
```ts
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
@@ -141,115 +141,6 @@ 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 { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
import { CoreApiClient, type Person } 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/logic-function`:
Импортируйте тип `RoutePayload` из `twenty-sdk`:
```ts
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
@@ -140,115 +140,6 @@ 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 { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
import { CoreApiClient, type Person } 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/logic-function` içinden içe aktarın:
`RoutePayload` türünü `twenty-sdk` içinden içe aktarın:
```ts
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
@@ -141,115 +141,6 @@ 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 { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
import { CoreApiClient, type Person } 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/logic-function` 导入 `RoutePayload` 类型:
从 `twenty-sdk` 导入 `RoutePayload` 类型:
```ts
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
@@ -141,115 +141,6 @@ 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,6 +4087,8 @@ 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']>;
@@ -4234,6 +4236,11 @@ export type QueryCommandMenuItemArgs = {
};
export type QueryConnectedAccountByIdArgs = {
id: Scalars['UUID'];
};
export type QueryEnterpriseCheckoutSessionArgs = {
billingInterval?: InputMaybe<Scalars['String']>;
};
@@ -6892,6 +6899,15 @@ 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'];
}>;
@@ -7903,6 +7919,7 @@ 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>;
@@ -8053,6 +8070,7 @@ 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,13 +28,6 @@ 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'];
@@ -259,7 +252,6 @@ export type Query = {
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
isMaintenanceModeBannerDismissed: Scalars['Boolean'];
search: SearchResultConnection;
workflowStepConnectedAccountHandle?: Maybe<ConnectedAccountHandleDto>;
};
@@ -314,11 +306,6 @@ export type QuerySearchArgs = {
searchInput: Scalars['String'];
};
export type QueryWorkflowStepConnectedAccountHandleArgs = {
connectedAccountId: Scalars['UUID'];
};
export type RunWorkflowVersion = {
__typename?: 'RunWorkflowVersion';
workflowRunId: Scalars['UUID'];
@@ -758,13 +745,6 @@ 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;
}>;
@@ -814,7 +794,6 @@ 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 { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
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 flattenedFieldMetadataItems = store.get(
flattenedFieldMetadataItemsSelector.atom,
const fieldMetadataItemByIdMap = store.get(
fieldMetadataItemByIdMapSelector.atom,
);
const graphqlFilter = isDefined(objectMetadataItem)
@@ -100,7 +100,7 @@ export const buildHeadlessCommandContextApi = ({
contextStoreFilters: filters,
contextStoreFilterGroups: filterGroups,
objectMetadataItem,
fieldMetadataItems: flattenedFieldMetadataItems,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
filterValueDependencies: {
currentWorkspaceMemberId: currentWorkspaceMember?.id,
timeZone: userTimezone,
@@ -6,7 +6,6 @@ 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';
@@ -55,10 +54,6 @@ export const useFindManyRecordsSelectedInContextStore = ({
fieldMetadataItemByIdMapSelector,
);
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const isSoftDeleteFilterActive = contextStoreFilters.some(
(filter) =>
fieldMetadataItemByIdMap.get(filter.fieldMetadataId)?.name ===
@@ -70,7 +65,7 @@ export const useFindManyRecordsSelectedInContextStore = ({
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
fieldMetadataItems: flattenedFieldMetadataItems,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});
@@ -29,7 +29,8 @@ describe('computeContextStoreFilters', () => {
contextStoreFilters: [],
contextStoreFilterGroups: [],
objectMetadataItem: personObjectMetadataItem,
fieldMetadataItems: personObjectMetadataItem.fields,
findFieldMetadataItemById: (id) =>
personObjectMetadataItem.fields.find((field) => field.id === id),
filterValueDependencies: mockFilterValueDependencies,
contextStoreAnyFieldFilterValue: '',
});
@@ -74,7 +75,8 @@ describe('computeContextStoreFilters', () => {
contextStoreFilters,
contextStoreFilterGroups: [],
objectMetadataItem: personObjectMetadataItem,
fieldMetadataItems: personObjectMetadataItem.fields,
findFieldMetadataItemById: (id) =>
personObjectMetadataItem.fields.find((field) => field.id === id),
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;
fieldMetadataItems: FieldMetadataItem[];
findFieldMetadataItemById: FindFieldMetadataItemById;
filterValueDependencies: RecordFilterValueDependencies;
contextStoreAnyFieldFilterValue: string;
};
@@ -28,7 +28,7 @@ export const computeContextStoreFilters = ({
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
fieldMetadataItems,
findFieldMetadataItemById,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
}: ComputeContextStoreFiltersProps) => {
@@ -45,7 +45,7 @@ export const computeContextStoreFilters = ({
recordGqlFilterForAnyFieldFilter,
computeRecordGqlOperationFilter({
filterValueDependencies,
fieldMetadataItems,
findFieldMetadataItemById,
recordFilters: contextStoreFilters,
recordFilterGroups: contextStoreFilterGroups,
}),
@@ -74,7 +74,7 @@ export const computeContextStoreFilters = ({
},
computeRecordGqlOperationFilter({
filterValueDependencies,
fieldMetadataItems,
findFieldMetadataItemById,
recordFilters: contextStoreFilters,
recordFilterGroups: contextStoreFilterGroups,
}),
@@ -58,8 +58,15 @@ export const PromiseRejectionEffect = () => {
scope.setExtras({ mechanism: 'onUnhandle' });
const fingerprint = hasErrorCode(error) ? error.code : error.message;
scope.setFingerprint([fingerprint]);
error.name = error.message;
if (isDefined(fingerprint)) {
scope.setFingerprint([fingerprint]);
}
if (error instanceof Error) {
error.name = error.message;
}
return scope;
});
} catch (sentryError) {
@@ -1,9 +1,5 @@
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 { getNavigationMenuItemColor } from '@/navigation-menu-item/common/utils/getNavigationMenuItemColor';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/display/components/NavigationMenuItemIcon';
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';
@@ -22,15 +18,8 @@ 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
@@ -45,9 +34,8 @@ export const NavigationMenuItemPageLayoutDisplay = ({
? editModeProps?.onEditModeClick
: undefined
}
Icon={Icon}
iconColor={pageLayoutColor}
active={isActive}
Icon={() => <NavigationMenuItemIcon navigationMenuItem={item} />}
active={false}
isSelectedInEditMode={editModeProps?.isSelectedInEditMode}
isDragging={isDragging}
triggerEvent="CLICK"
@@ -1,65 +0,0 @@
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,7 +10,6 @@ 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,
@@ -128,9 +127,7 @@ export const generateDepthRecordGqlFieldsFromFields = ({
);
}
const missingMorphTargetNames: string[] = [];
const morphGqlFields = fieldMetadata.morphRelations.flatMap(
const morphGqlFields = fieldMetadata.morphRelations.map(
(morphRelation) => {
const morphTargetObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
@@ -138,39 +135,28 @@ export const generateDepthRecordGqlFieldsFromFields = ({
);
if (!morphTargetObjectMetadataItem) {
missingMorphTargetNames.push(
morphRelation.targetObjectMetadata.nameSingular,
throw new Error(
`Target object metadata item not found for ${fieldMetadata.name} (morph target ${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(
@@ -1,51 +0,0 @@
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 { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
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 flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
);
const anyFieldFilterValue = useAtomComponentStateValue(
@@ -110,7 +110,7 @@ export const useRecordCalendarQueryDateRangeFilter = (
filterValueDependencies,
recordFilters: calendarRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
fieldMetadataItems: flattenedFieldMetadataItems,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
});
const { recordGqlOperationFilter: anyFieldFilter } =
@@ -22,11 +22,14 @@ const petMockObjectMetadataItem = getMockObjectMetadataItemOrThrow('pet');
const personMockObjectMetadataItem = getMockObjectMetadataItemOrThrow('person');
const companyFields = companyMockObjectMetadataItem.fields;
const findCompanyFieldById = (id: string) =>
companyMockObjectMetadataItem.fields.find((field) => field.id === id);
const personFields = personMockObjectMetadataItem.fields;
const findPersonFieldById = (id: string) =>
personMockObjectMetadataItem.fields.find((field) => field.id === id);
const petFields = petMockObjectMetadataItem.fields;
const findPetFieldById = (id: string) =>
petMockObjectMetadataItem.fields.find((field) => field.id === id);
const mockFilterValueDependencies: RecordFilterValueDependencies = {
currentWorkspaceMemberId: '32219445-f587-4c40-b2b1-6d3205ed96da',
@@ -60,7 +63,7 @@ describe('computeViewRecordGqlOperationFilter', () => {
filterValueDependencies: mockFilterValueDependencies,
recordFilters: [nameFilter],
recordFilterGroups: [],
fieldMetadataItems: companyFields,
findFieldMetadataItemById: findCompanyFieldById,
});
expect(result).toEqual({
@@ -113,7 +116,7 @@ describe('computeViewRecordGqlOperationFilter', () => {
filterValueDependencies: mockFilterValueDependencies,
recordFilters: [nameFilter, employeesFilter],
recordFilterGroups: [],
fieldMetadataItems: companyFields,
findFieldMetadataItemById: findCompanyFieldById,
});
expect(result).toEqual({
@@ -193,7 +196,7 @@ describe('should work as expected for the different field types', () => {
addressFilterIsNotEmpty,
],
recordFilterGroups: [],
fieldMetadataItems: companyFields,
findFieldMetadataItemById: findCompanyFieldById,
});
expect(result).toEqual({
@@ -657,7 +660,7 @@ describe('should work as expected for the different field types', () => {
phonesFilterIsNotEmpty,
],
recordFilterGroups: [],
fieldMetadataItems: personFields,
findFieldMetadataItemById: findPersonFieldById,
});
expect(result).toEqual({
@@ -854,7 +857,7 @@ describe('should work as expected for the different field types', () => {
emailsFilterIsNotEmpty,
],
recordFilterGroups: [],
fieldMetadataItems: personFields,
findFieldMetadataItemById: findPersonFieldById,
});
expect(result).toEqual({
@@ -1066,7 +1069,7 @@ describe('should work as expected for the different field types', () => {
dateFilterIsNotEmpty,
],
recordFilterGroups: [],
fieldMetadataItems: companyFields,
findFieldMetadataItemById: findCompanyFieldById,
});
expect(result).toEqual({
@@ -1168,7 +1171,7 @@ describe('should work as expected for the different field types', () => {
employeesFilterIsNotEmpty,
],
recordFilterGroups: [],
fieldMetadataItems: companyFields,
findFieldMetadataItemById: findCompanyFieldById,
});
expect(result).toEqual({
@@ -1270,7 +1273,7 @@ describe('should work as expected for the different field types', () => {
ARRFilterIsNot,
],
recordFilterGroups: [],
fieldMetadataItems: companyFields,
findFieldMetadataItemById: findCompanyFieldById,
});
expect(result).toEqual({
@@ -1347,7 +1350,7 @@ describe('should work as expected for the different field types', () => {
filterValueDependencies: mockFilterValueDependencies,
recordFilters: [ARRFilterIn, ARRFilterNotIn],
recordFilterGroups: [],
fieldMetadataItems: companyFields,
findFieldMetadataItemById: findCompanyFieldById,
});
expect(result).toEqual({
@@ -1407,7 +1410,7 @@ describe('should work as expected for the different field types', () => {
filterValueDependencies: mockFilterValueDependencies,
recordFilters: [selectFilterIs, selectFilterIsNot],
recordFilterGroups: [],
fieldMetadataItems: petFields,
findFieldMetadataItemById: findPetFieldById,
});
expect(result).toEqual({
@@ -1480,7 +1483,7 @@ describe('should work as expected for the different field types', () => {
multiSelectFilterDoesNotContain,
],
recordFilterGroups: [],
fieldMetadataItems: companyFields,
findFieldMetadataItemById: findCompanyFieldById,
});
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 { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
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 flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
);
const computedFilter = computeContextStoreFilters({
@@ -63,7 +63,7 @@ export const RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect =
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
fieldMetadataItems: flattenedFieldMetadataItems,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
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 { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
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 flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
);
const findManyRecordsParams = useFindManyRecordIndexTableParams(
@@ -107,7 +107,7 @@ export const useRecordIndexLazyFetchRecords = ({
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
fieldMetadataItems: flattenedFieldMetadataItems,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});
@@ -1,6 +1,6 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
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 flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
);
const currentFilters = computeRecordGqlOperationFilter({
fieldMetadataItems: flattenedFieldMetadataItems,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
recordFilterGroups: currentRecordFilterGroups,
recordFilters: currentRecordFilters,
filterValueDependencies,
@@ -1,5 +1,5 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
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 flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
);
const requestFilters = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
fieldMetadataItems: flattenedFieldMetadataItems,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
});
const anyFieldFilterValue = useAtomComponentStateValue(
@@ -1,5 +1,5 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
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 flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
);
const requestFilters = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
fieldMetadataItems: flattenedFieldMetadataItems,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
});
const { recordAggregateGqlField } =
@@ -1,6 +1,6 @@
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
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 flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
);
const currentRecordFilters = useAtomComponentStateValue(
@@ -63,7 +63,7 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
objectNameSingular: objectMetadataItem.nameSingular,
variables: {
filter: computeRecordGqlOperationFilter({
fieldMetadataItems: flattenedFieldMetadataItems,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
filterValueDependencies,
@@ -77,7 +77,7 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
currentRecordFilterGroups,
filterValueDependencies,
currentRecordSorts,
flattenedFieldMetadataItems,
fieldMetadataItemByIdMap,
],
);
@@ -1,4 +1,4 @@
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
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 flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
);
const { filterValueDependencies } = useFilterValueDependencies();
const requestFilters = computeRecordGqlOperationFilter({
fieldMetadataItems: flattenedFieldMetadataItems,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
filterValueDependencies,
recordFilterGroups: currentRecordFilterGroups,
recordFilters: currentRecordFilters,
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
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 flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
);
const currentRecordFilters = useAtomComponentStateValue(
@@ -39,7 +39,7 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
objectNameSingular: objectMetadataItem.nameSingular,
variables: {
filter: computeRecordGqlOperationFilter({
fieldMetadataItems: flattenedFieldMetadataItems,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
filterValueDependencies,
@@ -53,7 +53,7 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
currentRecordFilterGroups,
filterValueDependencies,
currentRecordSorts,
flattenedFieldMetadataItems,
fieldMetadataItemByIdMap,
],
);
@@ -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 { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
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 flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
const fieldMetadataItemByIdMap = useAtomStateValue(
fieldMetadataItemByIdMapSelector,
);
const graphqlFilter = computeContextStoreFilters({
@@ -54,7 +54,7 @@ export const useUpdateMultipleRecordsActions = ({
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
fieldMetadataItems: flattenedFieldMetadataItems,
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});

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