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
3782 changed files with 48916 additions and 96635 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
+1 -1
View File
@@ -50,4 +50,4 @@ runs:
- name: Deploy
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty app:publish --private --remote target
run: yarn twenty deploy --remote target
@@ -50,4 +50,4 @@ runs:
- name: Install
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty app:install --remote target
run: yarn twenty install --remote target
+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
#
+5 -91
View File
@@ -144,9 +144,6 @@ jobs:
npx nx run twenty-server:database:init:prod
- name: Flush cache before seeding current branch
run: npx nx command-no-deps twenty-server -- cache:flush
- name: Seed current branch database with test data
run: |
npx nx command-no-deps twenty-server -- workspace:seed:dev
@@ -163,7 +160,7 @@ jobs:
- name: Wait for current branch server to be ready
run: |
echo "Waiting for current branch server to start..."
timeout=60
timeout=300
interval=5
elapsed=0
@@ -188,10 +185,9 @@ jobs:
done
if [ $elapsed -ge $timeout ]; then
echo "Timed out waiting for current branch server to serve a valid schema."
echo "::warning::Timed out waiting for current branch server to serve a valid schema. Validation will skip the API diff."
echo "Current server log:"
cat /tmp/current-server.log || echo "No current server log found"
exit 1
fi
- name: Download GraphQL and REST responses from current branch
@@ -315,9 +311,6 @@ jobs:
npx nx run twenty-server:database:init:prod
- name: Flush cache before seeding main branch
run: npx nx command-no-deps twenty-server -- cache:flush
- name: Seed main branch database with test data
run: |
npx nx command-no-deps twenty-server -- workspace:seed:dev
@@ -359,10 +352,9 @@ jobs:
done
if [ $elapsed -ge $timeout ]; then
echo "Timed out waiting for main branch server to serve a valid schema."
echo "::warning::Timed out waiting for main branch server to serve a valid schema. Validation will skip the API diff."
echo "Main server log:"
cat /tmp/main-server.log || echo "No main server log found"
exit 1
fi
- name: Download GraphQL and REST responses from main branch
@@ -458,7 +450,6 @@ jobs:
echo "Using OpenAPITools/openapi-diff via Docker"
- name: Generate GraphQL Schema Diff Reports
id: graphql-diff
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
@@ -472,7 +463,6 @@ jobs:
echo "✅ No changes in GraphQL schema"
else
echo "⚠️ Changes detected in GraphQL schema, generating report..."
echo "core_breaking=true" >> $GITHUB_OUTPUT
echo "# GraphQL Schema Changes" > graphql-schema-diff.md
echo "" >> graphql-schema-diff.md
graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >> graphql-schema-diff.md 2>&1 || {
@@ -490,7 +480,6 @@ jobs:
echo "✅ No changes in GraphQL metadata schema"
else
echo "⚠️ Changes detected in GraphQL metadata schema, generating report..."
echo "metadata_breaking=true" >> $GITHUB_OUTPUT
echo "# GraphQL Metadata Schema Changes" > graphql-metadata-diff.md
echo "" >> graphql-metadata-diff.md
graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >> graphql-metadata-diff.md 2>&1 || {
@@ -507,7 +496,6 @@ jobs:
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
- name: Check REST API Breaking Changes
id: rest-diff
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
@@ -530,7 +518,6 @@ jobs:
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST API"
echo "breaking=true" >> $GITHUB_OUTPUT
# Generate breaking changes report
echo "# REST API Breaking Changes" > rest-api-diff.md
@@ -578,7 +565,6 @@ jobs:
fi
- name: Check REST Metadata API Breaking Changes
id: rest-metadata-diff
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
@@ -601,7 +587,6 @@ jobs:
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST Metadata API"
echo "breaking=true" >> $GITHUB_OUTPUT
# Generate breaking changes report (only for breaking changes)
echo "# REST Metadata API Breaking Changes" > rest-metadata-api-diff.md
@@ -647,79 +632,6 @@ jobs:
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
fi
- name: Fail on breaking changes
if: steps.validate-schemas.outputs.valid == 'true'
run: |
breaking=false
if [ "${{ steps.graphql-diff.outputs.core_breaking }}" = "true" ]; then
echo "❌ GraphQL core schema has breaking changes"
breaking=true
if [ -f graphql-schema-diff.md ]; then
echo ""
cat graphql-schema-diff.md
echo ""
fi
fi
if [ "${{ steps.graphql-diff.outputs.metadata_breaking }}" = "true" ]; then
echo "❌ GraphQL metadata schema has breaking changes"
breaking=true
if [ -f graphql-metadata-diff.md ]; then
echo ""
cat graphql-metadata-diff.md
echo ""
fi
fi
if [ "${{ steps.rest-diff.outputs.breaking }}" = "true" ]; then
echo "❌ REST core API has breaking changes"
breaking=true
if [ -f rest-api-diff.json ]; then
echo ""
jq -r '
(if (.missingEndpoints | length) > 0 then
" Removed endpoints:\n" +
(.missingEndpoints | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end),
(if (.changedOperations | length) > 0 then
" Changed operations:\n" +
(.changedOperations | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end)
' rest-api-diff.json | sed '/^$/d'
echo ""
fi
fi
if [ "${{ steps.rest-metadata-diff.outputs.breaking }}" = "true" ]; then
echo "❌ REST metadata API has breaking changes"
breaking=true
if [ -f rest-metadata-api-diff.json ]; then
echo ""
jq -r '
(if (.missingEndpoints | length) > 0 then
" Removed endpoints:\n" +
(.missingEndpoints | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end),
(if (.changedOperations | length) > 0 then
" Changed operations:\n" +
(.changedOperations | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end)
' rest-metadata-api-diff.json | sed '/^$/d'
echo ""
fi
fi
if [ "$breaking" = "true" ]; then
echo ""
echo "This PR introduces breaking changes to the public API."
echo "If intentional, deprecate the old endpoint and introduce a new one."
echo "See the breaking changes report artifact and PR comment for details."
exit 1
fi
echo "✅ No breaking API changes detected"
- name: Upload breaking changes report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -740,3 +652,5 @@ jobs:
if [ -f /tmp/main-server.pid ]; then
kill $(cat /tmp/main-server.pid) || true
fi
@@ -105,7 +105,7 @@ jobs:
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --url http://localhost:3000
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --workspace-url http://localhost:3000
- name: Install scaffolded app dependencies
run: |
@@ -153,7 +153,7 @@ jobs:
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote:add --api-key ${{ env.TWENTY_API_KEY }} --url ${{ env.TWENTY_API_URL }}
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
- name: Run scaffolded app integration test (deploys, installs, and verifies the app)
run: |
@@ -80,20 +80,10 @@ jobs:
with:
name: storybook-twenty-front-component-renderer
path: packages/twenty-front-component-renderer/storybook-static
- name: Resolve Playwright version
id: playwright-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: v4-playwright-browsers-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: |
cd packages/twenty-front-component-renderer
npx playwright install chromium
npx playwright install
- name: Serve storybook & run tests
run: |
npx http-server packages/twenty-front-component-renderer/storybook-static --port 6008 --silent &
+1 -11
View File
@@ -96,20 +96,10 @@ jobs:
with:
name: storybook-static
path: packages/twenty-front/storybook-static
- name: Resolve Playwright version
id: playwright-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: v4-playwright-browsers-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: |
cd packages/twenty-front
npx playwright install chromium
npx playwright install
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Serve storybook & run tests
+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
+19 -4
View File
@@ -1,6 +1,7 @@
name: 'Preview Environment Dispatch'
permissions: {}
permissions:
contents: write
on:
pull_request_target:
@@ -10,6 +11,7 @@ on:
- packages/twenty-server/**
- packages/twenty-front/**
- .github/workflows/preview-env-dispatch.yaml
- .github/workflows/preview-env-keepalive.yaml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -33,15 +35,28 @@ jobs:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Dispatch preview-env to ci-privileged
- 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 }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
gh api repos/"$REPOSITORY"/dispatches \
-f event_type=preview-environment \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
-f "client_payload[repo_full_name]=$REPOSITORY"
- name: Dispatch to ci-privileged for PR comment
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
KEEPALIVE_DISPATCH_TIME: ${{ github.event.pull_request.updated_at }}
REPOSITORY: ${{ github.repository }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=preview-env-url \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[keepalive_dispatch_time]=$KEEPALIVE_DISPATCH_TIME" \
-f "client_payload[repo]=$REPOSITORY"
@@ -0,0 +1,186 @@
name: 'Preview Environment Keep Alive'
permissions:
contents: read
on:
repository_dispatch:
types: [preview-environment]
jobs:
preview-environment:
timeout-minutes: 310
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ github.event.client_payload.pr_head_sha }}
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Run compose setup
run: |
echo "Patching docker-compose.yml..."
# change image to localbuild using yq
yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
echo "Adding SIGN_IN_PREFILLED environment variable to server service..."
yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml
echo "Setting up .env file..."
cp packages/twenty-docker/.env.example packages/twenty-docker/.env
echo "Generating secrets..."
echo "" >> packages/twenty-docker/.env
echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env
echo "APP_SECRET=$(openssl rand -base64 32)" >> packages/twenty-docker/.env
echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 16)" >> packages/twenty-docker/.env
echo "SIGN_IN_PREFILLED=true" >> packages/twenty-docker/.env
echo "Docker compose build..."
cd packages/twenty-docker/
docker compose build
working-directory: ./
- name: Create Tunnel
id: expose-tunnel
env:
CLOUDFLARED_VERSION: '2026.3.0'
run: |
set -euo pipefail
# Install cloudflared (pinned for reproducibility)
sudo curl -fsSL -o /usr/local/bin/cloudflared \
"https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64"
sudo chmod +x /usr/local/bin/cloudflared
cloudflared --version
# Start an account-less "quick tunnel" pointing at the server container.
# Cloudflare prints the assigned https://*.trycloudflare.com URL into the log.
log_file="$RUNNER_TEMP/cloudflared.log"
: > "$log_file"
cloudflared tunnel \
--url http://localhost:3000 \
--no-autoupdate \
--logfile "$log_file" \
--loglevel info \
> "$RUNNER_TEMP/cloudflared.stdout" 2>&1 &
pid=$!
echo "$pid" > "$RUNNER_TEMP/cloudflared.pid"
echo "cloudflared PID: $pid"
# Wait up to 2 minutes for the URL to appear; fail fast if cloudflared exits.
url=''
for _ in $(seq 1 60); do
url=$(grep -oE 'https://[a-zA-Z0-9-]+\.trycloudflare\.com' "$log_file" 2>/dev/null | head -n1 || true)
[ -n "$url" ] && break
if ! kill -0 "$pid" 2>/dev/null; then
echo "cloudflared exited before producing a URL"
cat "$log_file" || true
exit 1
fi
sleep 2
done
if [ -z "$url" ]; then
echo "Timed out waiting for tunnel URL"
cat "$log_file" || true
exit 1
fi
echo "Tunnel URL: $url"
echo "tunnel-url=$url" >> "$GITHUB_OUTPUT"
- name: Start services with correct SERVER_URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
run: |
cd packages/twenty-docker/
echo "Setting SERVER_URL to $TUNNEL_URL"
sed -i '/SERVER_URL=/d' .env
echo "" >> .env
echo "SERVER_URL=$TUNNEL_URL" >> .env
# Start the services
echo "Docker compose up..."
docker compose up -d || {
echo "Docker compose failed to start"
docker compose logs
exit 1
}
echo "Waiting for services to be ready..."
count=0
while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ] || [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do
sleep 5
count=$((count+1))
if [ $count -gt 60 ]; then
echo "Timeout waiting for services to be ready"
docker compose logs
exit 1
fi
echo "Still waiting for services... ($count/60)"
done
echo "All services are up and running!"
working-directory: ./
- name: Seed Dev Workspace
run: |
cd packages/twenty-docker/
echo "Seeding light dev workspace (Apple only)..."
if ! docker compose exec -T server yarn command:prod workspace:seed:dev --light; then
echo "❌ Seeding light dev workspace failed. Dumping server logs..."
docker compose logs server
exit 1
fi
working-directory: ./
- name: Output tunnel URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
run: |
echo "✅ Preview Environment Ready!"
echo "🔗 Preview URL: $TUNNEL_URL"
echo "⏱️ This environment will be available for 5 hours"
echo "## 🚀 Preview Environment Ready!" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Preview URL: $TUNNEL_URL" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "This environment will automatically shut down after 5 hours." >> "$GITHUB_STEP_SUMMARY"
echo "$TUNNEL_URL" > tunnel-url.txt
- name: Upload tunnel URL artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: tunnel-url
path: tunnel-url.txt
retention-days: 1
- name: Keep tunnel alive for 5 hours
run: timeout 300m sleep 18000 # Stop on whichever we reach first (300m or 5hour sleep)
- name: Cleanup
if: always()
run: |
if [ -f "$RUNNER_TEMP/cloudflared.pid" ]; then
kill "$(cat "$RUNNER_TEMP/cloudflared.pid")" 2>/dev/null || true
fi
cd packages/twenty-docker/
docker compose down -v
working-directory: ./
+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"
-27
View File
@@ -1,27 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf",
"printWidth": 80,
"sortPackageJson": false,
"ignorePatterns": [
"**/dist/**",
"**/build/**",
"**/lib/**",
"**/.next/**",
"**/coverage/**",
"**/generated/**",
"**/generated-admin/**",
"**/generated-metadata/**",
"**/.cache/**",
"**/node_modules/**",
"**/*.min.js",
"**/*.snap",
"**/*.md",
"**/*.mdx",
"**/seed-project/**/*.mjs",
"packages/twenty-zapier/build/**",
"**/upgrade-version-command/**"
]
}
+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": {
-4
View File
@@ -10,8 +10,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
npmPreapprovedPackages:
- twenty-sdk
- twenty-client-sdk
yarnPath: .yarn/releases/yarn-4.13.0.cjs
+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
+44 -44
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:
@@ -63,12 +63,12 @@ export default defineObject({
Then ship it to your workspace:
```bash
npx twenty app:publish --private
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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

+13 -9
View File
@@ -44,12 +44,12 @@
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "npx oxlint -c .oxlintrc.json . && (npx oxfmt --check . || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
"command": "npx oxlint -c .oxlintrc.json . && (prettier . --check --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
},
"configurations": {
"ci": {},
"fix": {
"command": "npx oxlint --fix -c .oxlintrc.json . && npx oxfmt ."
"command": "npx oxlint --fix -c .oxlintrc.json . && prettier . --write --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata"
}
},
"dependsOn": ["^build", "twenty-oxlint-rules:build"]
@@ -59,12 +59,12 @@
"cache": false,
"dependsOn": ["twenty-oxlint-rules:build"],
"options": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (npx oxfmt --check $FILES || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (prettier --check $FILES || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
"pattern": "\\.(ts|tsx|js|jsx)$"
},
"configurations": {
"fix": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && npx oxfmt $FILES)"
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && prettier --write $FILES)"
}
}
},
@@ -73,14 +73,18 @@
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "npx oxfmt --check {args.files} {args.write}",
"files": ".",
"write": ""
"command": "prettier {args.files} --check --cache {args.cache} --cache-location {args.cacheLocation} --write {args.write} --cache-strategy {args.cacheStrategy}",
"cache": true,
"cacheLocation": "../../.cache/prettier/{projectRoot}",
"cacheStrategy": "metadata",
"write": false
},
"configurations": {
"ci": {},
"ci": {
"cacheStrategy": "content"
},
"fix": {
"command": "npx oxfmt {args.files}"
"write": true
}
},
"dependsOn": ["^build"]
+2 -3
View File
@@ -13,7 +13,6 @@
"concurrently": "^8.2.2",
"http-server": "^14.1.1",
"nx": "22.5.4",
"oxfmt": "0.50.0",
"tsx": "^4.17.0",
"verdaccio": "^6.3.1"
},
@@ -28,7 +27,7 @@
"resolutions": {
"graphql": "16.8.1",
"type-fest": "4.10.1",
"typescript": "5.9.3",
"typescript": "5.9.2",
"nodemailer": "8.0.4",
"graphql-redis-subscriptions/ioredis": "^5.6.0",
"@lingui/core": "5.1.2",
@@ -54,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",
+4 -4
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>
@@ -35,7 +35,7 @@ The scaffolder will:
| `--name <name>` | Set the app name |
| `--display-name <displayName>` | Set the display name |
| `--description <description>` | Set the description |
| `--url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
| `--workspace-url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
| `--authentication-method <method>` | `oauth` or `apiKey` (default: `apiKey` for local, `oauth` for remote) |
## Documentation
@@ -48,8 +48,8 @@ Full documentation is available at **[docs.twenty.com/developers/extend/apps](ht
## Troubleshooting
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty docker:logs`.
- Auth not working: run `yarn twenty remote:add --local` to re-authenticate.
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
- Auth not working: run `yarn twenty remote add --local` to re-authenticate.
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
## Contributing
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.9.0",
"version": "2.5.1",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -50,7 +50,7 @@
"jest": "29.7.0",
"jest-environment-node": "^29.4.1",
"twenty-shared": "workspace:*",
"typescript": "^5.9.3",
"typescript": "^5.9.2",
"vite": "^7.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^4.2.1"
+10 -7
View File
@@ -18,8 +18,11 @@ const program = new Command(packageJson.name)
.option('-n, --name <name>', 'Application name')
.option('-d, --display-name <displayName>', 'Application display name')
.option('--description <description>', 'Application description')
.option('--url <url>', 'Twenty server URL (default: http://localhost:2020)')
.option('--api-url <apiUrl>', '[deprecated: use --url]')
.option(
'--workspace-url <workspaceUrl>',
'Twenty workspace URL (default: http://localhost:2020)',
)
.option('--api-url <apiUrl>', '[deprecated: use --workspace-url]')
.option(
'--authentication-method <method>',
'Authentication method: oauth or apiKey (default: apiKey for local, oauth for remote)',
@@ -32,7 +35,7 @@ const program = new Command(packageJson.name)
name?: string;
displayName?: string;
description?: string;
url?: string;
workspaceUrl?: string;
apiUrl?: string;
authenticationMethod?: AuthenticationMethod;
},
@@ -65,18 +68,18 @@ const program = new Command(packageJson.name)
if (options?.apiUrl) {
console.warn(
chalk.yellow('Warning: --api-url is deprecated. Use --url instead.'),
chalk.yellow(
'Warning: --api-url is deprecated. Use --workspace-url instead.',
),
);
}
const serverUrl = (options?.url ?? options?.apiUrl)?.replace(/\/+$/, '');
await new CreateAppCommand().execute({
directory,
name: options?.name,
displayName: options?.displayName,
description: options?.description,
serverUrl,
workspaceUrl: options?.workspaceUrl ?? options?.apiUrl,
authenticationMethod: options?.authenticationMethod,
});
},
@@ -49,19 +49,19 @@
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
It's highly recommended to create new app entities using `yarn twenty add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
| Entity type | Command | Generated file |
| -------------------- | ------------------------------------ | ------------------------------------- |
| Object | `yarn twenty add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty add pageLayout` | `src/page-layouts/<name>.ts` |
This helps automatically generate required IDs etc.
@@ -11,8 +11,8 @@ Run `yarn twenty help` to list all available commands.
## Useful Commands
- `yarn twenty dev` - Start the development server and sync your app
- `yarn twenty docker:status` - Check the local Twenty server status
- `yarn twenty docker:start` - Start the local Twenty server
- `yarn twenty server status` - Check the local Twenty server status
- `yarn twenty server start` - Start the local Twenty server
- `yarn test` - Run integration tests
## Learn More
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -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',
@@ -1 +0,0 @@
nodeLinker: node-modules
@@ -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,
@@ -33,7 +33,7 @@ type CreateAppOptions = {
name?: string;
displayName?: string;
description?: string;
serverUrl?: string;
workspaceUrl?: string;
authenticationMethod?: AuthenticationMethod;
};
@@ -45,9 +45,9 @@ export class CreateAppCommand {
const { appName, appDisplayName, appDirectory, appDescription } =
this.getAppInfos(options);
const serverUrl = options.serverUrl ?? DEV_API_URL;
const workspaceUrl = options.workspaceUrl ?? DEV_API_URL;
const skipLocalInstance = serverUrl !== DEV_API_URL;
const skipLocalInstance = workspaceUrl !== DEV_API_URL;
if (!skipLocalInstance && !isDockerInstalled()) {
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
@@ -118,7 +118,7 @@ export class CreateAppCommand {
console.log('');
let authSucceeded = false;
let resolvedServerUrl = serverUrl;
let resolvedWorkspaceUrl = workspaceUrl;
let serverReady = skipLocalInstance;
if (!skipLocalInstance) {
@@ -126,7 +126,7 @@ export class CreateAppCommand {
const serverResult = await this.ensureDockerServer(dockerPullPromise);
if (isDefined(serverResult.url)) {
resolvedServerUrl = serverResult.url;
resolvedWorkspaceUrl = serverResult.url;
serverReady = true;
}
}
@@ -134,16 +134,18 @@ export class CreateAppCommand {
if (serverReady) {
this.logNextStep('Authenticating');
authSucceeded = await this.tryExistingAuth(resolvedServerUrl);
authSucceeded = await this.tryExistingAuth(resolvedWorkspaceUrl);
if (authSucceeded) {
this.logDetail('Reusing existing credentials');
} else if (authenticationMethod === 'oauth') {
this.logDetail('Starting OAuth flow');
authSucceeded = await this.authenticateWithOAuth(resolvedServerUrl);
authSucceeded =
await this.authenticateWithOAuth(resolvedWorkspaceUrl);
} else {
this.logDetail('Using development API key');
authSucceeded = await this.authenticateWithDevKey(resolvedServerUrl);
authSucceeded =
await this.authenticateWithDevKey(resolvedWorkspaceUrl);
}
}
@@ -163,10 +165,10 @@ export class CreateAppCommand {
}
if (syncSucceeded) {
await this.openMainPage(appDirectory, resolvedServerUrl);
await this.openMainPage(appDirectory, resolvedWorkspaceUrl);
}
this.logSuccess(appDirectory, resolvedServerUrl, authSucceeded);
this.logSuccess(appDirectory, resolvedWorkspaceUrl, authSucceeded);
} catch (error) {
console.error(
chalk.red('\nCreate application failed:'),
@@ -313,7 +315,7 @@ export class CreateAppCommand {
private async openMainPage(
appDirectory: string,
serverUrl: string,
workspaceUrl: string,
): Promise<void> {
try {
const configService = new ConfigService();
@@ -326,7 +328,7 @@ export class CreateAppCommand {
const [universalIdentifier, frontUrl] = await Promise.all([
this.readMainPageLayoutUniversalIdentifier(appDirectory),
this.resolveWorkspaceFrontUrl(serverUrl, token),
this.resolveWorkspaceFrontUrl(workspaceUrl, token),
]);
if (!universalIdentifier || !frontUrl) {
@@ -334,7 +336,7 @@ export class CreateAppCommand {
}
const pageLayoutId = await this.resolvePageLayoutId(
serverUrl,
workspaceUrl,
universalIdentifier,
token,
);
@@ -353,12 +355,12 @@ export class CreateAppCommand {
}
private async resolveWorkspaceFrontUrl(
serverUrl: string,
workspaceUrl: string,
token: string,
): Promise<string | null> {
const query = `{ currentWorkspace { workspaceUrls { subdomainUrl customUrl } } }`;
const response = await fetch(`${serverUrl}/metadata`, {
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -387,7 +389,7 @@ export class CreateAppCommand {
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
return frontUrl ? normalizeUrl(frontUrl) : null;
return frontUrl?.replace(/\/+$/, '') ?? null;
}
private async readMainPageLayoutUniversalIdentifier(
@@ -408,13 +410,13 @@ export class CreateAppCommand {
}
private async resolvePageLayoutId(
serverUrl: string,
workspaceUrl: string,
universalIdentifier: string,
token: string,
): Promise<string | null> {
const query = `{ getPageLayouts { id universalIdentifier } }`;
const response = await fetch(`${serverUrl}/metadata`, {
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -501,7 +503,7 @@ export class CreateAppCommand {
});
}
private async tryExistingAuth(serverUrl: string): Promise<boolean> {
private async tryExistingAuth(workspaceUrl: string): Promise<boolean> {
try {
const configService = new ConfigService();
const remoteNames = await configService.getRemotes();
@@ -509,7 +511,7 @@ export class CreateAppCommand {
for (const remoteName of remoteNames) {
const remoteConfig = await configService.getConfigForRemote(remoteName);
if (remoteConfig.apiUrl !== serverUrl) {
if (remoteConfig.apiUrl !== workspaceUrl) {
continue;
}
@@ -519,7 +521,7 @@ export class CreateAppCommand {
continue;
}
const response = await fetch(`${serverUrl}/metadata`, {
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -553,11 +555,11 @@ export class CreateAppCommand {
}
}
private async authenticateWithDevKey(serverUrl: string): Promise<boolean> {
private async authenticateWithDevKey(workspaceUrl: string): Promise<boolean> {
try {
const result = await authLogin({
apiKey: DEV_API_KEY,
apiUrl: serverUrl,
apiUrl: workspaceUrl,
remote: 'local',
});
@@ -572,7 +574,7 @@ export class CreateAppCommand {
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
' Authentication failed. Run `yarn twenty remote add --local` manually.',
),
);
@@ -580,7 +582,7 @@ export class CreateAppCommand {
} catch {
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
' Authentication failed. Run `yarn twenty remote add --local` manually.',
),
);
@@ -596,21 +598,21 @@ export class CreateAppCommand {
}
}
private async authenticateWithOAuth(serverUrl: string): Promise<boolean> {
private async authenticateWithOAuth(workspaceUrl: string): Promise<boolean> {
try {
const remoteName = this.deriveRemoteName(serverUrl);
const remoteName = this.deriveRemoteName(workspaceUrl);
ConfigService.setActiveRemote(remoteName);
this.logDetail('Opening browser for OAuth...');
const result = await authLoginOAuth({ apiUrl: serverUrl });
const result = await authLoginOAuth({ apiUrl: workspaceUrl });
if (result.success) {
const configService = new ConfigService();
await configService.setDefaultRemote(remoteName);
this.logDetail(`Authenticated via OAuth to ${serverUrl}`);
this.logDetail(`Authenticated via OAuth to ${workspaceUrl}`);
return true;
}
@@ -618,7 +620,7 @@ export class CreateAppCommand {
console.log(
chalk.yellow(
` OAuth failed: ${result.error.message}\n` +
` Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
` Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
),
);
@@ -626,7 +628,7 @@ export class CreateAppCommand {
} catch {
console.log(
chalk.yellow(
` Authentication failed. Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
` Authentication failed. Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
),
);
@@ -636,7 +638,7 @@ export class CreateAppCommand {
private logSuccess(
appDirectory: string,
serverUrl: string,
workspaceUrl: string,
authSucceeded: boolean,
): void {
const dirName = basename(appDirectory);
@@ -654,7 +656,9 @@ export class CreateAppCommand {
if (!authSucceeded) {
console.log(chalk.white(` ${stepNumber}. Connect to a Twenty instance`));
console.log(
chalk.cyan(' yarn twenty remote:add --url <your-instance-url>\n'),
chalk.cyan(
' yarn twenty remote add --api-url <your-instance-url>\n',
),
);
stepNumber++;
}
@@ -664,7 +668,7 @@ export class CreateAppCommand {
stepNumber++;
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
console.log(chalk.cyan(` ${serverUrl}\n`));
console.log(chalk.cyan(` ${workspaceUrl}\n`));
console.log(
chalk.gray(
@@ -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) {
@@ -28,6 +28,6 @@ export const getDockerInstallInstructions = (): string => {
' Then run this command again.',
'',
' Alternatively, connect to an existing Twenty instance:',
' npx create-twenty-app@latest my-twenty-app --url <your-instance-url>',
' npx create-twenty-app@latest my-twenty-app --workspace-url <your-instance-url>',
].join('\n');
};
@@ -93,7 +93,7 @@ yarn install
# Register your local Twenty server as a remote (interactive prompt).
# When asked for the URL use http://localhost:2021 and paste an API key
# from Settings -> Developers in the Twenty UI.
yarn twenty remote:add
yarn twenty remote add
# Build, install, and watch for changes.
yarn twenty dev
@@ -107,8 +107,8 @@ watching `src/`. Edit any file and the change is re-synced within seconds.
```bash
cd packages/twenty-apps/community/github-connector
yarn install
yarn twenty remote:add # same prompts as above
yarn twenty app:install # builds and installs once
yarn twenty remote add # same prompts as above
yarn twenty install # builds and installs once
```
## Configure authentication
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote:add --api-url http://localhost:2020 --as local
yarn twenty remote add --api-url http://localhost:2020 --as local
```
Then, start development mode to sync your app and watch for changes:
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote:status # Check auth status
yarn twenty remote:use # Set default remote
yarn twenty remote:list # List all configured remotes
yarn twenty remote:remove <name> # Remove a remote
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Application
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty dev:function:logs # Stream function logs
yarn twenty dev:function:exec # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
```
## Integration Tests
@@ -13,7 +13,7 @@ beforeAll(async () => {
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -80,8 +80,8 @@ export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
throw new Error(
`App uninstall failed during teardown: ${JSON.stringify(uninstallResult.error, null, 2)}`,
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}
@@ -1,4 +1,4 @@
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
import { PermissionFlag, defineRole } from 'twenty-sdk/define';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
@@ -36,5 +36,5 @@ export default defineRole({
canUpdateFieldValue: true,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
permissionFlags: [PermissionFlag.APPLICATIONS],
});
@@ -10,8 +10,8 @@
"packageManager": "yarn@4.9.2",
"scripts": {
"dev": "twenty dev",
"exec": "twenty dev:fn-exec",
"uninstall": "twenty app:uninstall",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
@@ -1,17 +0,0 @@
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
@@ -1,4 +1,4 @@
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
import { PermissionFlag, defineRole } from 'twenty-sdk/define';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
@@ -36,5 +36,5 @@ export default defineRole({
canUpdateFieldValue: false,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
permissionFlags: [PermissionFlag.APPLICATIONS],
});
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote:add --api-url http://localhost:2020 --as local
yarn twenty remote add --api-url http://localhost:2020 --as local
```
Then, start development mode to sync your app and watch for changes:
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote:status # Check auth status
yarn twenty remote:use # Set default remote
yarn twenty remote:list # List all configured remotes
yarn twenty remote:remove <name> # Remove a remote
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Application
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty dev:function:logs # Stream function logs
yarn twenty dev:function:exec # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
```
## LLMs instructions
@@ -13,7 +13,7 @@ beforeAll(async () => {
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -1,67 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import {
FieldPermissionLevel,
getPermissionContext,
ObjectPermissionLevel,
type RoutePayload,
} from 'twenty-sdk/logic-function';
type PermissionContextDemoBody = {
objectUniversalIdentifier?: string;
fieldUniversalIdentifier?: string;
permissionFlagUniversalIdentifier?: string;
};
export const main = async (
params: RoutePayload<PermissionContextDemoBody>,
): Promise<object> => {
const {
objectUniversalIdentifier,
fieldUniversalIdentifier,
permissionFlagUniversalIdentifier,
} = params.body || {};
const permissions = getPermissionContext();
return {
canReadAllObjectRecords: permissions.canReadAllObjectRecords,
canUpdateAllObjectRecords: permissions.canUpdateAllObjectRecords,
canAccessAllTools: permissions.canAccessAllTools,
canUpdateAllSettings: permissions.canUpdateAllSettings,
canReadObject: objectUniversalIdentifier
? permissions.hasObjectPermission(
objectUniversalIdentifier,
ObjectPermissionLevel.READ,
)
: null,
canUpdateObject: objectUniversalIdentifier
? permissions.hasObjectPermission(
objectUniversalIdentifier,
ObjectPermissionLevel.UPDATE,
)
: null,
canReadField:
objectUniversalIdentifier && fieldUniversalIdentifier
? permissions.hasFieldPermission(
objectUniversalIdentifier,
fieldUniversalIdentifier,
FieldPermissionLevel.READ,
)
: null,
hasPermissionFlag: permissionFlagUniversalIdentifier
? permissions.hasPermissionFlag(permissionFlagUniversalIdentifier)
: null,
};
};
export default defineLogicFunction({
universalIdentifier: '0ef6d1e4-1a89-4b30-9c43-703fec036c89',
name: 'permission-context-demo',
timeoutSeconds: 10,
handler: main,
httpRouteTriggerSettings: {
path: '/webhook/permission-context-demo',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -64,15 +64,15 @@ cd packages/twenty-apps/internal/twenty-linear
# For day-to-day development (publish + install + watch in one):
yarn twenty dev
# Manual publish flow (publish registers the app, install activates it):
yarn twenty app:publish --private
yarn twenty app:install
# Manual publish flow (deploy registers the app, install activates it):
yarn twenty deploy
yarn twenty install
```
`twenty dev` is recommended for iteration — it publishes, installs, and
watches for changes in one command. Use `twenty app:publish --private` +
`twenty app:install` when you want to control each step separately (e.g.
deploying to a production server without auto-installing).
watches for changes in one command. Use `twenty deploy` + `twenty install`
when you want to control each step separately (e.g. deploying to a
production server without auto-installing).
This serves as the reference implementation for Twenty's
`defineConnectionProvider({ type: 'oauth' })` flow — useful as a template
@@ -1,6 +0,0 @@
# Credentials for integration tests (vitest.config.ts) and seed scripts
# (vitest.seed.config.ts). Copy this file to .env.local and fill in the key.
# Get an API key from the Twenty UI: Settings -> APIs & Webhooks.
# .env.local is gitignored; never commit a real key.
TWENTY_API_URL=http://localhost:2020
TWENTY_API_KEY=
@@ -1,42 +0,0 @@
name: CD
on:
push:
branches:
- main
pull_request:
types: [labeled]
permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:2020
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-and-install:
if: >-
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
- name: Install
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
@@ -1,48 +0,0 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty test instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
@@ -1,43 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
# internal planning docs
docs/superpowers/
**/docs/superpowers/
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# typescript
*.tsbuildinfo
*.d.ts
@@ -1 +0,0 @@
24.5.0
@@ -1,37 +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"
},
"overrides": [
{
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["twenty-shared", "twenty-shared/*"],
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
}
]
}
]
}
}
]
}
@@ -1,101 +0,0 @@
# twenty-partners
A Twenty app that turns the CRM into the operating system for the Twenty partner program:
intake partner-eligible deals, match them to vetted marketplace partners, and track the
matching pipeline end-to-end.
Built on [Twenty](https://twenty.com) with [`twenty-sdk`](https://www.npmjs.com/package/twenty-sdk) v2.5.
## What's inside
- **Custom object: `Partner`** — slug, status, availability, served geos, languages spoken,
deployment expertise, Calendly link, last-match timestamp. See `src/objects/partner.object.ts`.
- **Opportunity extensions**`matchStatus`, `designDocStatus`,
`introSentAt`, `lastRelanceSentAt`, `tftId`, plus a `partner` relation.
- **Logic functions**
- `on-opportunity-auto-match` — fires when `matchStatus` is set to `AUTO_MATCH`. Assigns the longest-idle available partner and flips status to `MATCHED`. If no partner is available, hands off to `MANUAL_MATCH` with an audit Note explaining why.
- `list-available-partners` — surfaces matchable partners for a given opportunity.
- `post-install` — first-run setup.
- **Roles** (`src/roles/`)
- **Twenty Partner Ops** — internal team role, full CRUD on Partner/Company/Person/Opportunity.
- **Partner** — placeholder external-partner role. *Do not assign until Twenty ships
row-level permissions* — it currently grants access to every record.
- **Views** (`src/views/`)
- `Waiting for match` — opportunities awaiting human action (`matchStatus` is `TO_BE_MATCHED` or `MANUAL_MATCH`).
- `Matches overview` — full matching funnel grouped by `matchStatus` (configure Kanban
grouping manually in the UI).
- `Opportunities` — replacement of the native opportunities view with the partner columns.
- `Partners` and `All matched deals` — partner-side index and deal log.
- **Sidebar nav** — surfaced in workflow order: `Waiting for match`, `All partner deals`,
`Matches overview`, `Partners`, `Opportunities`.
- **Seed scripts** (`src/scripts/`) — populate a fresh workspace with realistic demo data.
## Match status pipeline
`matchStatus` is a non-nullable SELECT field with a default of `TO_BE_MATCHED`. The 10 states follow the deal lifecycle:
| Status | Meaning |
| --- | --- |
| `TO_BE_MATCHED` | Default — deal entered, awaiting assignment |
| `MANUAL_MATCH` | Needs a human to pick a partner |
| `AUTO_MATCH` | Triggers automatic partner assignment |
| `MATCHED` | Partner assigned |
| `INTRODUCED_TO_A_PARTNER` | Customer intro sent |
| `WORKING_WITH_A_PARTNER` | Engagement underway |
| `IMPLEMENTING` | Active implementation |
| `WON` | Deal closed won |
| `RECONNECT_LATER` | Paused — reconnect in future |
| `LOST` | Deal closed lost |
## Getting started
Requires a local Twenty server at `http://localhost:2020` and Node `^24.5`.
```bash
yarn install
yarn twenty dev
```
Default dev credentials: `tim@apple.dev` / `tim@apple.dev`.
Run `yarn twenty help` for the full CLI reference.
## Common commands
| Command | What it does |
| --- | --- |
| `yarn twenty dev` | Start the dev server and sync the app on file changes |
| `yarn twenty server status` | Check the local Twenty server |
| `yarn lint` / `yarn lint:fix` | Run oxlint |
| `yarn test` | Run integration tests (`vitest.config.ts`) |
## Seeding demo data
Two idempotent seed scripts. Both run via the `vitest.seed.config.ts` config that skips
the global app uninstall/reinstall.
```bash
# 1. Marketplace partners (run first — pipeline seed wires opportunities to these by slug)
yarn vitest run --config vitest.seed.config.ts src/scripts/seed-marketplace-partners.ts
# 2. Pipeline demo: 3 companies, 3 people, 15 opportunities spread across matchStatus values
yarn vitest run --config vitest.seed.config.ts src/scripts/seed-pipeline-demo.ts
```
Both scripts skip records that already exist (by `slug`, `name`, or `firstName+lastName`),
so they are safe to re-run.
## Known limitations
Current SDK gaps blocking further polish:
- Custom Partner record page layout (RECORD_TABLE has no relation scoping).
- Native Opportunities view column-order override.
- Kanban view configuration from app code (`ViewType.KANBAN` is currently ignored).
- App and field descriptions.
## Learn more
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)
- [`twenty-sdk` on npm](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)
@@ -1,43 +0,0 @@
{
"name": "twenty-partners",
"version": "0.3.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest",
"seed": "tsx src/scripts/seed.ts",
"seed:prod": "ENV_FILE=.env.prod tsx src/scripts/seed.ts",
"purge": "tsx src/scripts/purge-soft-deleted.ts",
"purge:prod": "ENV_FILE=.env.prod tsx src/scripts/purge-soft-deleted.ts",
"import:dryrun": "tsx src/scripts/import-from-tft.ts",
"import:dryrun:prod": "ENV_FILE=.env.prod tsx src/scripts/import-from-tft.ts",
"import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts",
"import:apply:prod": "ENV_FILE=.env.prod IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts"
},
"dependencies": {
"twenty-client-sdk": "2.4.0",
"twenty-sdk": "2.4.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"dotenv": "^16.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsx": "^4.0.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
}
}
@@ -1,74 +0,0 @@
// Seed file: ensures at least one ACTIVE + AVAILABLE partner exists before
// the matching integration tests run. Idempotent — skips if already seeded.
import { CoreApiClient } from 'twenty-client-sdk/core';
import { beforeAll, describe, it } from 'vitest';
describe('seed: marketplace partners', () => {
let client: CoreApiClient;
beforeAll(() => {
client = new CoreApiClient();
});
it('ensures at least one ACTIVE AVAILABLE partner exists', async () => {
const existingResult = await client.query({
partners: {
__args: {
filter: { validationStage: { eq: 'VALIDATED' }, availability: { eq: 'AVAILABLE' } },
first: 1,
},
edges: { node: { id: true } },
},
} as any);
const existing = (existingResult as any).partners.edges;
if (existing.length > 0) {
console.log('[seed] partner already exists, skipping');
return;
}
const PARTNERS = [
{
slug: 'nine-dots-ventures',
name: 'Nine Dots Ventures',
introduction: 'Boutique CRM implementer specialising in real-estate workflows.',
calendlyLink: 'https://calendly.com/placeholder',
deploymentExpertise: ['CLOUD', 'SELF_HOST'],
servedGeos: ['EUROPE', 'MENA'],
languagesSpoken: ['ENGLISH', 'FRENCH'],
},
{
slug: 'elevate-consulting',
name: 'Elevate Consulting',
introduction: 'Revenue-operations partner for B2B SaaS teams.',
calendlyLink: 'https://calendly.com/placeholder',
deploymentExpertise: ['CLOUD'],
servedGeos: ['US', 'LATAM'],
languagesSpoken: ['ENGLISH', 'SPANISH'],
},
];
for (const p of PARTNERS) {
const r = await client.mutation({
createPartner: {
__args: {
data: {
name: p.name,
slug: p.slug,
introduction: p.introduction,
calendarLink: { primaryLinkUrl: p.calendlyLink },
deploymentExpertise: p.deploymentExpertise,
region: p.servedGeos,
languagesSpoken: p.languagesSpoken,
validationStage: 'VALIDATED',
availability: 'AVAILABLE',
},
},
id: true,
name: true,
},
} as any);
console.log('[seed] created', (r as any).createPartner.name);
}
});
});
@@ -1,87 +0,0 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}
@@ -1,46 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CoreApiClient', () => {
it('should support CRUD on standard objects', async () => {
const client = new CoreApiClient();
const created = await client.mutation({
createNote: {
__args: { data: { title: 'Integration test note' } },
id: true,
},
});
expect(created.createNote.id).toBeDefined();
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
id: true,
},
});
});
});
@@ -1,13 +0,0 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
});
@@ -1,41 +0,0 @@
export const APP_DISPLAY_NAME = 'Twenty partners';
export const APP_DESCRIPTION = '';
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'e662fc1f-02c1-41ff-b8ba-c95a447b3965';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'ee18c3f3-ebe7-4c56-ad6d-aad555cc32db';
export const PARTNER_OBJECT_UNIVERSAL_IDENTIFIER = '39101b39-1c16-4148-9e82-45dc271bb90d';
export const PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd';
export const ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '379b11d5-44d5-476b-ba7d-31d5f515c9b4';
export const ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER = '7a34da39-a8e1-44c7-88b4-91ceaa064920';
export const PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '3fe15ab5-e38b-4914-af17-2270b210aeb2';
export const PARTNER_DEALS_NAV_UNIVERSAL_IDENTIFIER = 'c5e4ac36-bede-4f4b-bfe8-bbd09518abae';
export const ON_OPP_AUTO_MATCH_FN_UNIVERSAL_IDENTIFIER = 'eb8d4d26-8103-4b66-9026-6a86556f7ca5';
export const POST_INSTALL_FN_UNIVERSAL_IDENTIFIER = 'f92bad2e-5905-4757-96ee-af9869d4ca0c';
export const MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER = 'd8dd0623-3a4c-4ab3-a1e0-4ece7df24fb2';
export const INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER = 'fcf39b0c-0547-415e-806d-b238131ad7cc';
// Roles (Task 2)
export const TWENTY_PARTNER_OPS_ROLE_UNIVERSAL_IDENTIFIER = '3340ca65-863d-4cdc-95c9-8abdec13d0f6';
export const PARTNER_ROLE_UNIVERSAL_IDENTIFIER = 'c3c1dc2e-1a08-4de5-abb7-2139b3d99343';
// Views (Task 3)
export const WAITING_FOR_MATCH_VIEW_UNIVERSAL_IDENTIFIER = 'fe11e738-6bf3-4714-929c-51c76a3fd050';
export const MATCHES_OVERVIEW_VIEW_UNIVERSAL_IDENTIFIER = '5a8fd51a-cf9e-4a6a-b1b4-b833b215fc1c';
// Nav items (Task 5)
export const WAITING_FOR_MATCH_NAV_UNIVERSAL_IDENTIFIER = '00be7449-8927-47c8-a6a1-212d9106587f';
export const MATCHES_OVERVIEW_NAV_UNIVERSAL_IDENTIFIER = '0cf349c9-fcbf-40f8-8e91-142c02bbde9c';
// Page layout (Task 6)
export const PARTNER_RECORD_PAGE_UNIVERSAL_IDENTIFIER = 'a888b39e-d64a-48ba-a044-d8cb685fad74';
// All Opportunities view + nav
export const ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER = '6ce1300b-6e91-4c28-83bb-6f692dbc7a98';
export const ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER = '37944f52-cbe5-4814-a1e6-be5b21425870';
// Partner views + nav (harmonization)
export const PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER = 'b57a84ed-d7c1-420d-b0eb-348db0dac612';
export const VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '13cca6a7-b9f1-4103-b011-ea2e39430899';
export const PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b891-6201149510b3';
export const PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER = '13e2334a-6b1e-4080-8c74-d11109990cc1';
export const VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '6aed30c6-d80f-4ac6-aab0-db5bc59e5c4b';
export const PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
@@ -1,16 +0,0 @@
import { defineApplicationRole } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default function role`,
description: `${APP_DISPLAY_NAME} default function role`,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { OPPORTUNITIES_ON_PARTNER_FIELD_ID, PARTNER_ON_OPPORTUNITY_FIELD_ID } from './partner-on-opportunity.field';
export default defineField({
universalIdentifier: OPPORTUNITIES_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'opportunities',
label: 'Opportunities',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_ON_OPPORTUNITY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,15 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'cc6b8a59-f860-493f-8b9a-f138c078fbf1',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'designDocStatus',
label: 'Design Doc Status',
defaultValue: "'DRAFT'",
options: [
{ id: '1901c790-22af-4149-a792-09374d67acfd', value: 'DRAFT', label: 'Draft', position: 0, color: 'gray' },
{ id: '02cbe191-cc96-4b42-9d8e-f85cd47bed24', value: 'DONE', label: 'Done', position: 1, color: 'green' },
{ id: '943e1389-12cd-4605-8066-db27ba68a50a', value: 'SHARED_WITH_PARTNER', label: 'Shared with Partner', position: 2, color: 'blue' },
],
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '37e5428c-6c8c-4616-b626-f0ea1caa443d',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.LINKS,
name: 'designDocUrl',
label: 'Design Doc URL',
isNullable: true,
});
@@ -1,14 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '7ac7517f-bbca-4b4c-8996-6f864f71219b',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'hostingType',
label: 'Hosting Type',
isNullable: true,
options: [
{ id: '42c108d7-a874-4d1f-be4c-e87edd08f3c7', value: 'CLOUD', label: 'Cloud', position: 0, color: 'sky' },
{ id: '0fe995f4-42de-4160-96af-b3e7d542dfdd', value: 'SELF_HOSTING', label: 'Self-hosting', position: 1, color: 'purple' },
],
});
@@ -1,11 +0,0 @@
import { INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.DATE_TIME,
name: 'introSentAt',
label: 'Intro Sent At',
isNullable: true,
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '834e233d-b171-409e-825f-77ac49b0f19d',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'lostReason',
label: 'Lost Reason',
isNullable: true,
});
@@ -1,26 +0,0 @@
import { MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'matchStatus',
label: 'Match Status',
isNullable: false,
defaultValue: "'TO_BE_MATCHED'",
options: [
// Pre-match (new). NEW UUIDs generated with `uuidgen` (v4).
{ id: '8b3a1c0e-2f64-4a87-9d2b-1e3c4f5a6b78', value: 'TO_BE_MATCHED', label: 'To Be Matched', position: 0, color: 'grey' },
{ id: '4c5d6e7f-8a9b-4c0d-9e1f-2a3b4c5d6e7f', value: 'MANUAL_MATCH', label: 'Manual Match', position: 1, color: 'grey' },
{ id: '7e8f9a0b-1c2d-4e3f-8a4b-5c6d7e8f9a0b', value: 'AUTO_MATCH', label: 'Auto Match', position: 2, color: 'yellow' },
// Post-match. Reuse DELIVERED's UUID for MATCHED so existing rows auto-relabel.
{ id: '095428d8-4680-4a2c-af83-7809dcb3f194', value: 'MATCHED', label: 'Matched', position: 3, color: 'blue' },
{ id: '2f1c79a1-ca91-4937-a4c0-6422f6534d34', value: 'INTRODUCED_TO_A_PARTNER', label: 'Introduced to a partner', position: 4, color: 'sky' },
{ id: '45cdf6ef-8672-40d5-b71f-1e5687ba5776', value: 'WORKING_WITH_A_PARTNER', label: 'Working with a partner', position: 5, color: 'turquoise' },
{ id: '7189b18d-b0f7-435a-9272-f812cba5d13d', value: 'IMPLEMENTING', label: 'Implementing', position: 6, color: 'green' },
{ id: '54cd33bc-11ea-42f1-87c8-cd9d32d2c266', value: 'WON', label: 'Won', position: 7, color: 'purple' },
{ id: '505433e8-5367-4dfb-a89a-708d5182165b', value: 'RECONNECT_LATER', label: 'Reconnect later', position: 8, color: 'orange' },
{ id: '572a9ad2-e0a6-49f2-b1f5-e36c75cc5176', value: 'LOST', label: 'Lost', position: 9, color: 'red' },
],
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '90c683ec-2365-4533-a187-7b9ae162b753',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.NUMBER,
name: 'numberOfSeats',
label: 'Number of Seats',
isNullable: true,
});
@@ -1,14 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '59d5de53-202f-4913-a417-8a08970d87cc',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'subscriptionFrequency',
label: 'Subscription Frequency',
isNullable: true,
options: [
{ id: 'e53a9ebb-11d6-40de-93f2-6bcb6ab7141c', value: 'MONTHLY', label: 'Monthly', position: 0, color: 'blue' },
{ id: '6eca9f01-f891-4c9d-bef6-9238c8e67392', value: 'ANNUAL', label: 'Annual', position: 1, color: 'green' },
],
});
@@ -1,15 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'a58214e9-38f9-4faf-8927-09b3980fd8c3',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'subscriptionType',
label: 'Subscription Type',
isNullable: true,
options: [
{ id: '6d91b477-5bf1-4f5c-8aef-577b6c21fe45', value: 'PRO', label: 'Pro', position: 0, color: 'blue' },
{ id: '7b64f281-3445-4429-a5f0-af9484dff8b4', value: 'ORG', label: 'Org', position: 1, color: 'green' },
{ id: 'bf202f8f-caf9-45bf-a80c-65b344b2798d', value: 'ENT', label: 'Enterprise', position: 2, color: 'purple' },
],
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '2e3e1d04-2719-4e0d-9a6b-ec73acf896c5',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'tftOpportunityId',
label: 'TfT Opportunity ID',
isNullable: true,
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '1bc57f52-a621-4243-ae3e-05c3f504b90c',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'useCase',
label: 'Use Case',
isNullable: true,
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_COMPANY_FIELD_ID = '2779015b-28fa-4117-8ce1-b0c98cf16de2';
export const PARTNERS_ON_COMPANY_FIELD_ID = '2896d888-a4ab-4c29-bf63-e8bfdbd1924f';
export default defineField({
universalIdentifier: PARTNER_COMPANY_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'company',
label: 'Company',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNERS_ON_COMPANY_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'companyId',
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID = 'd448f6be-533f-4c43-a70f-55dc361dcdd7';
export const PARTNER_CONTENTS_ON_COMPANY_FIELD_ID = '941e7707-1b3d-47d4-b13a-45c6628c1b3d';
export default defineField({
universalIdentifier: PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID,
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'customerCompany',
label: 'Customer Company',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_COMPANY_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'customerCompanyId',
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID = 'f17547be-76aa-4231-bd1a-3f57bb1ae323';
export const PARTNER_CONTENTS_ON_PERSON_FIELD_ID = 'fae42f4e-b054-4267-a761-81a6792f7c12';
export default defineField({
universalIdentifier: PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID,
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'customerPerson',
label: 'Customer Person',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PERSON_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'customerPersonId',
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_CONTENT_PARTNER_FIELD_ID = '1774738e-96a1-43e2-aca4-d3c7cc794c50';
export const PARTNER_CONTENTS_ON_PARTNER_FIELD_ID = 'ac7995f1-7ccf-4607-827b-0788eeacaee0';
export default defineField({
universalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENTS_ON_COMPANY_FIELD_ID, PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID } from './partner-content-customer-company.field';
export default defineField({
universalIdentifier: PARTNER_CONTENTS_ON_COMPANY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerContents',
label: 'Partner Content',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENTS_ON_PARTNER_FIELD_ID, PARTNER_CONTENT_PARTNER_FIELD_ID } from './partner-content-partner.field';
export default defineField({
universalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partnerContents',
label: 'Partner Content',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENTS_ON_PERSON_FIELD_ID, PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID } from './partner-content-customer-person.field';
export default defineField({
universalIdentifier: PARTNER_CONTENTS_ON_PERSON_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerContents',
label: 'Partner Content',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_ON_OPPORTUNITY_FIELD_ID = 'd9eeacaa-2f9e-44cc-b5f6-5e1526256d49';
export const OPPORTUNITIES_ON_PARTNER_FIELD_ID = '8c04a5d4-c423-487e-bd78-7142a75b2896';
export default defineField({
universalIdentifier: PARTNER_ON_OPPORTUNITY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: OPPORTUNITIES_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_ON_PERSON_FIELD_ID = 'b49eeaa3-c7ef-4a5c-8c47-d2c234b5122f';
export const PERSONS_ON_PARTNER_FIELD_ID = '2c0e2035-db52-434b-9706-cd2210009a86';
export default defineField({
universalIdentifier: PARTNER_ON_PERSON_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PERSONS_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNERS_ON_COMPANY_FIELD_ID, PARTNER_COMPANY_FIELD_ID } from './partner-company.field';
export default defineField({
universalIdentifier: PARTNERS_ON_COMPANY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.RELATION,
name: 'partners',
label: 'Partners',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_COMPANY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'c6862035-bf2e-42ea-86c4-bf46636f7859',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.TEXT,
name: 'discord',
label: 'Discord',
isNullable: true,
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_ON_PERSON_FIELD_ID, PERSONS_ON_PARTNER_FIELD_ID } from './partner-on-person.field';
export default defineField({
universalIdentifier: PERSONS_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'persons',
label: 'Persons',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_ON_PERSON_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,220 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// Helpers
async function createOpp(client: CoreApiClient, name: string) {
const r = await client.mutation({
createOpportunity: {
__args: { data: { name } },
id: true,
matchStatus: true,
},
} as any);
return (r as any).createOpportunity as { id: string; matchStatus: string };
}
async function destroyOpp(client: CoreApiClient, id: string) {
await client
.mutation({ destroyOpportunity: { __args: { id }, id: true } } as any)
.catch(() => {});
}
async function getOpp(client: CoreApiClient, id: string) {
const r = await client.query({
opportunity: {
__args: { filter: { id: { eq: id } } },
id: true,
matchStatus: true,
partner: { id: true },
},
} as any);
return (r as any).opportunity as {
id: string;
matchStatus: string;
partner: { id: string } | null;
};
}
async function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
describe('on-opportunity-auto-match (success path)', () => {
let client: CoreApiClient;
const created: string[] = [];
let seededPartnerId: string | null = null;
beforeAll(async () => {
client = new CoreApiClient();
// Ensure at least one ACTIVE+AVAILABLE partner exists for the function to pick.
const existing = await client.query({
partners: {
__args: {
filter: { validationStage: { eq: 'VALIDATED' }, availability: { eq: 'AVAILABLE' } },
first: 1,
},
edges: { node: { id: true } },
},
} as any);
if ((existing as any).partners.edges.length === 0) {
const r = await client.mutation({
createPartner: {
__args: {
data: {
name: '[test] auto-match seed partner',
slug: 'test-auto-match-seed',
validationStage: 'VALIDATED',
availability: 'AVAILABLE',
},
},
id: true,
},
} as any);
seededPartnerId = (r as any).createPartner.id;
}
});
afterAll(async () => {
if (seededPartnerId) {
await client
.mutation({ destroyPartner: { __args: { id: seededPartnerId }, id: true } } as any)
.catch(() => {});
}
});
beforeEach(() => {
client = new CoreApiClient();
});
afterEach(async () => {
for (const id of created) await destroyOpp(client, id);
created.length = 0;
});
it('defaults a new opportunity to TO_BE_MATCHED', async () => {
const opp = await createOpp(client, `[test] default status ${Date.now()}`);
created.push(opp.id);
expect(opp.matchStatus).toBe('TO_BE_MATCHED');
});
it('assigns a partner and flips to MATCHED when set to AUTO_MATCH', async () => {
const opp = await createOpp(client, `[test] auto match ${Date.now()}`);
created.push(opp.id);
await client.mutation({
updateOpportunity: {
__args: { id: opp.id, data: { matchStatus: 'AUTO_MATCH' } },
id: true,
},
} as any);
// Logic function runs async (~2s per spec). Poll up to 10s.
let final = await getOpp(client, opp.id);
for (let i = 0; i < 20 && final.matchStatus === 'AUTO_MATCH'; i++) {
await sleep(500);
final = await getOpp(client, opp.id);
}
expect(final.matchStatus).toBe('MATCHED');
expect(final.partner?.id).toBeDefined();
});
});
describe('on-opportunity-auto-match (failure path)', () => {
let client: CoreApiClient;
const createdOpps: string[] = [];
const flippedPartners: Array<{ id: string; prevAvailability: string }> = [];
beforeEach(() => {
client = new CoreApiClient();
});
afterEach(async () => {
// Restore partner availabilities first so other tests find a partner.
for (const p of flippedPartners) {
await client
.mutation({
updatePartner: {
__args: { id: p.id, data: { availability: p.prevAvailability } },
id: true,
},
} as any)
.catch(() => {});
}
flippedPartners.length = 0;
for (const id of createdOpps) await destroyOpp(client, id);
createdOpps.length = 0;
});
it('hands off to MANUAL_MATCH with a Note when no partner is available', async () => {
// Make every AVAILABLE partner UNAVAILABLE for the duration of this test.
const all = await client.query({
partners: {
__args: {
filter: { availability: { eq: 'AVAILABLE' } },
first: 100,
},
edges: { node: { id: true, availability: true } },
},
} as any);
const edges = ((all as any)?.partners?.edges ?? []) as Array<{
node: { id: string; availability: string };
}>;
for (const e of edges) {
flippedPartners.push({ id: e.node.id, prevAvailability: e.node.availability });
await client.mutation({
updatePartner: {
__args: { id: e.node.id, data: { availability: 'UNAVAILABLE' } },
id: true,
},
} as any);
}
const opp = await createOpp(client, `[test] no-partner ${Date.now()}`);
createdOpps.push(opp.id);
await client.mutation({
updateOpportunity: {
__args: { id: opp.id, data: { matchStatus: 'AUTO_MATCH' } },
id: true,
},
} as any);
let final = await getOpp(client, opp.id);
for (let i = 0; i < 20 && final.matchStatus === 'AUTO_MATCH'; i++) {
await sleep(500);
final = await getOpp(client, opp.id);
}
expect(final.matchStatus).toBe('MANUAL_MATCH');
expect(final.partner).toBeNull();
// Confirm a Note was attached.
const notes = await client.query({
noteTargets: {
__args: {
filter: { targetOpportunityId: { eq: opp.id } },
first: 10,
},
edges: {
node: {
id: true,
note: { id: true, title: true, bodyV2: { markdown: true } },
},
},
},
} as any);
const noteEdges = ((notes as any)?.noteTargets?.edges ?? []) as Array<{
node: { note: { title: string; bodyV2: { markdown: string } } };
}>;
const autoMatchNote = noteEdges.find((e) =>
e.node.note.title.toLowerCase().includes('auto-match'),
);
expect(autoMatchNote).toBeDefined();
expect(autoMatchNote!.node.note.bodyV2.markdown).toContain('No partners');
});
});
@@ -1,75 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
export const LIST_AVAILABLE_PARTNERS_LOGIC_FUNCTION_ID =
'0f91164f-f492-41e8-9bb0-481be5a3d5b9';
type Partner = {
id: string;
name: string | null;
slug: string | null;
introduction: string | null;
languagesSpoken: string[] | null;
deploymentExpertise: string[] | null;
region: string[] | null;
calendarLink: { primaryLinkUrl: string | null } | null;
};
type ListAvailablePartnersResult =
| { ok: true; count: number; partners: Partner[] }
| { ok: false; reason: string };
const handler = async (): Promise<ListAvailablePartnersResult> => {
try {
const client = new CoreApiClient();
const result = await client.query({
partners: {
__args: {
filter: {
validationStage: { eq: 'VALIDATED' },
availability: { eq: 'AVAILABLE' },
},
orderBy: [{ name: 'AscNullsLast' }],
first: 100,
},
edges: {
node: {
id: true,
name: true,
slug: true,
introduction: true,
languagesSpoken: true,
deploymentExpertise: true,
region: true,
calendarLink: { primaryLinkUrl: true },
},
},
},
} as any);
const partners = (
(result?.partners?.edges ?? []) as Array<{ node: Partner }>
).map((edge) => edge.node);
return { ok: true, count: partners.length, partners };
} catch (err) {
return {
ok: false,
reason: err instanceof Error ? err.message : String(err),
};
}
};
export default defineLogicFunction({
universalIdentifier: LIST_AVAILABLE_PARTNERS_LOGIC_FUNCTION_ID,
name: 'list-available-partners',
description: 'Returns all partners with validationStage=VALIDATED and availability=AVAILABLE.',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: '/partners',
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -1,105 +0,0 @@
import { DatabaseEventPayload, defineLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { ON_OPP_AUTO_MATCH_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
// Fires when an opportunity's matchStatus is set to 'AUTO_MATCH'.
// Happy path: picks the longest-idle ACTIVE+AVAILABLE partner, assigns it, flips to 'MATCHED'.
// Failure path: no partner available → flips to 'MANUAL_MATCH' and creates an audit Note.
const handler = async (payload: DatabaseEventPayload) => {
const props = payload.properties as {
after?: { id: string; matchStatus?: string; partnerId?: string | null };
before?: { matchStatus?: string };
updatedFields?: string[];
};
if (!props.updatedFields?.includes('matchStatus')) return {};
if (props.after?.matchStatus !== 'AUTO_MATCH') return {};
if (props.before?.matchStatus === 'AUTO_MATCH') return {};
if (props.after.partnerId) return {};
const client = new CoreApiClient();
const partnersResult = await client.query({
partners: {
__args: {
filter: {
validationStage: { eq: 'VALIDATED' },
availability: { eq: 'AVAILABLE' },
},
orderBy: [{ lastMatchAt: 'AscNullsFirst' }],
first: 1,
},
edges: { node: { id: true, lastMatchAt: true } },
},
} as any);
const topPartner = (partnersResult.partners as any).edges[0]?.node;
if (!topPartner) {
const noteBody =
`Auto-match attempted ${new Date().toISOString()}.\n` +
`No partners matched (status=ACTIVE, availability=AVAILABLE).\n` +
`Status moved to Manual Match — pick a partner manually or ` +
`update partner availability and retry by setting status back to Auto Match.`;
try {
const noteResult = await client.mutation({
createNote: {
__args: { data: { title: 'Auto-match failed', bodyV2: { markdown: noteBody } } },
id: true,
},
} as any);
const noteId = (noteResult as any).createNote.id as string;
await client.mutation({
createNoteTarget: {
__args: { data: { noteId, targetOpportunityId: props.after.id } },
id: true,
},
} as any);
} catch {
// Note creation is best-effort; status flip is the critical action.
}
await client.mutation({
updateOpportunity: {
__args: { id: props.after.id, data: { matchStatus: 'MANUAL_MATCH' } },
id: true,
},
} as any);
return { matched: false, reason: 'no_partner_available' };
}
await client.mutation({
updateOpportunity: {
__args: {
id: props.after.id,
data: { partnerId: topPartner.id, matchStatus: 'MATCHED' },
},
id: true,
},
} as any);
await client.mutation({
updatePartner: {
__args: {
id: topPartner.id,
data: { lastMatchAt: new Date().toISOString() },
},
id: true,
},
} as any);
return { matched: true, partnerId: topPartner.id };
};
export default defineLogicFunction({
universalIdentifier: ON_OPP_AUTO_MATCH_FN_UNIVERSAL_IDENTIFIER,
name: 'on-opportunity-auto-match',
timeoutSeconds: 10,
handler,
databaseEventTriggerSettings: {
eventName: 'opportunity.updated',
},
});
@@ -1,48 +0,0 @@
import { InstallPayload, definePostInstallLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (_payload: InstallPayload) => {
const client = new CoreApiClient();
const partnerResult = await client.mutation({
createPartner: {
__args: {
data: {
name: 'Test Partner Alpha',
validationStage: 'VALIDATED',
availability: 'AVAILABLE',
languagesSpoken: ['ENGLISH', 'FRENCH'],
deploymentExpertise: ['CLOUD', 'SELF_HOST'],
region: 'EUROPE',
},
},
id: true,
},
} as any);
const partnerId = (partnerResult.createPartner as any).id;
await client.mutation({
createPerson: {
__args: {
data: {
name: {
firstName: 'Test Partner',
lastName: 'Contact',
},
partnerId,
},
},
id: true,
},
} as any);
return { seeded: true, partnerId };
};
export default definePostInstallLogicFunction({
universalIdentifier: 'f92bad2e-5905-4757-96ee-af9869d4ca0c',
name: 'post-install',
handler,
shouldRunSynchronously: true,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER,
ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconTargetArrow',
position: 3,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
MATCHES_OVERVIEW_NAV_UNIVERSAL_IDENTIFIER,
MATCHES_OVERVIEW_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: MATCHES_OVERVIEW_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconLayoutKanban',
position: 1,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: MATCHES_OVERVIEW_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER,
PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconUserPlus',
position: 1,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER,
PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconQuote',
position: 3,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_DEALS_NAV_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: PARTNER_DEALS_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconHandshake',
position: 2,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,10 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
type: NavigationMenuItemType.FOLDER,
name: 'Partners',
icon: 'IconBuildingStore',
color: 'purple',
position: -1,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
PARTNERS_NAV_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: PARTNERS_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconBuildingStore',
position: 0,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,9 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
type: NavigationMenuItemType.FOLDER,
name: 'Pipeline',
icon: 'IconTargetArrow',
position: -2,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER,
VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconCircleCheck',
position: 2,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
});

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