Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6b5bf8bd6 | ||
|
|
34db7ac8b4 | ||
|
|
4a82cddad6 | ||
|
|
9d6c5b7d58 | ||
|
|
a2db0b6932 | ||
|
|
5eb79e7797 | ||
|
|
f19647617a | ||
|
|
424c6737a1 | ||
|
|
be8c25dfc2 | ||
|
|
b8de469f37 | ||
|
|
c74337978b | ||
|
|
059e75e532 | ||
|
|
bb5c2bd00c | ||
|
|
a2673da164 | ||
|
|
08682fb3f5 | ||
|
|
53d4e92dda | ||
|
|
53392f9a16 | ||
|
|
7e7bb81586 | ||
|
|
3703b3e2f6 | ||
|
|
e72d10f550 | ||
|
|
7ca9081efa | ||
|
|
015dca95fc | ||
|
|
076c05cbd0 | ||
|
|
82c565f7dc | ||
|
|
44bae39538 | ||
|
|
1188ea9cd5 | ||
|
|
f721420705 | ||
|
|
96d247e94d | ||
|
|
90f711361c | ||
|
|
98d47d0dd0 | ||
|
|
f613886511 | ||
|
|
a3e41ec267 | ||
|
|
d602f35cbd | ||
|
|
69d89f8cfc | ||
|
|
be39702fd2 | ||
|
|
50c8834e21 | ||
|
|
d54caf10c1 | ||
|
|
0b38b4ffc4 | ||
|
|
b8b115f4e3 | ||
|
|
85d649e831 | ||
|
|
2f4ebf8160 | ||
|
|
77603a4102 | ||
|
|
91ce59d8e2 | ||
|
|
056e3a4cd8 | ||
|
|
9876af5587 | ||
|
|
563acc3f57 | ||
|
|
452433a9ae | ||
|
|
3bda05ea57 | ||
|
|
eda41b4eba | ||
|
|
de044f4b45 | ||
|
|
e3c79c803c | ||
|
|
59d69e2f5c | ||
|
|
4554dbe3c9 | ||
|
|
efba71a938 | ||
|
|
e6cc783a23 | ||
|
|
76e144e85a | ||
|
|
8f24cda586 | ||
|
|
c2a48a1cc2 | ||
|
|
37a1d3980f | ||
|
|
05f58b2dba | ||
|
|
4e0b69eb8d | ||
|
|
99799073e9 | ||
|
|
084fa8eaba | ||
|
|
788d120b71 | ||
|
|
3128157988 | ||
|
|
068d365731 | ||
|
|
9b05e7fff4 | ||
|
|
323e66433e | ||
|
|
e2ee4ffdff | ||
|
|
9a7f50fcb7 | ||
|
|
c721fa8502 | ||
|
|
b0a1002838 | ||
|
|
1e2ae5342b | ||
|
|
11b9f708d6 | ||
|
|
07a20cba5e | ||
|
|
138eb5a74a | ||
|
|
a66fae79ee | ||
|
|
edf2bfbf76 | ||
|
|
64241ed8ae | ||
|
|
0edd8d400c | ||
|
|
3c91f3f276 | ||
|
|
0bfcd9a701 | ||
|
|
869f8af4f0 | ||
|
|
2fcf3e3c2b | ||
|
|
8826d12a18 | ||
|
|
abf5902ab5 | ||
|
|
a78c4c9fe8 | ||
|
|
a9ff1a9d3c |
@@ -144,6 +144,9 @@ 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
|
||||
@@ -160,7 +163,7 @@ jobs:
|
||||
- name: Wait for current branch server to be ready
|
||||
run: |
|
||||
echo "Waiting for current branch server to start..."
|
||||
timeout=300
|
||||
timeout=60
|
||||
interval=5
|
||||
elapsed=0
|
||||
|
||||
@@ -185,9 +188,10 @@ jobs:
|
||||
done
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "::warning::Timed out waiting for current branch server to serve a valid schema. Validation will skip the API diff."
|
||||
echo "❌ Timed out waiting for current branch server to serve a valid schema."
|
||||
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
|
||||
@@ -311,6 +315,9 @@ 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
|
||||
@@ -352,9 +359,10 @@ jobs:
|
||||
done
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "::warning::Timed out waiting for main branch server to serve a valid schema. Validation will skip the API diff."
|
||||
echo "❌ Timed out waiting for main branch server to serve a valid schema."
|
||||
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
|
||||
@@ -450,6 +458,7 @@ 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 ==="
|
||||
@@ -463,6 +472,7 @@ 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 || {
|
||||
@@ -480,6 +490,7 @@ 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 || {
|
||||
@@ -496,6 +507,7 @@ 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 ==="
|
||||
@@ -518,6 +530,7 @@ 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
|
||||
@@ -565,6 +578,7 @@ 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 ==="
|
||||
@@ -587,6 +601,7 @@ 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
|
||||
@@ -632,6 +647,79 @@ 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
|
||||
@@ -652,5 +740,3 @@ jobs:
|
||||
if [ -f /tmp/main-server.pid ]; then
|
||||
kill $(cat /tmp/main-server.pid) || true
|
||||
fi
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
name: 'Preview Environment Dispatch'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
permissions: {}
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
@@ -11,7 +10,6 @@ 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 }}
|
||||
@@ -35,28 +33,15 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger preview environment workflow
|
||||
- name: Dispatch preview-env 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 }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
gh api repos/"$REPOSITORY"/dispatches \
|
||||
gh api repos/twentyhq/ci-privileged/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"
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
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: ./
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$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/**"
|
||||
]
|
||||
}
|
||||
@@ -44,12 +44,12 @@
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "npx oxlint -c .oxlintrc.json . && (prettier . --check --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
|
||||
"command": "npx oxlint -c .oxlintrc.json . && (npx oxfmt --check . || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {},
|
||||
"fix": {
|
||||
"command": "npx oxlint --fix -c .oxlintrc.json . && prettier . --write --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata"
|
||||
"command": "npx oxlint --fix -c .oxlintrc.json . && npx oxfmt ."
|
||||
}
|
||||
},
|
||||
"dependsOn": ["^build", "twenty-oxlint-rules:build"]
|
||||
@@ -59,12 +59,12 @@
|
||||
"cache": false,
|
||||
"dependsOn": ["twenty-oxlint-rules:build"],
|
||||
"options": {
|
||||
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (prettier --check $FILES || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
|
||||
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (npx oxfmt --check $FILES || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
|
||||
"pattern": "\\.(ts|tsx|js|jsx)$"
|
||||
},
|
||||
"configurations": {
|
||||
"fix": {
|
||||
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && prettier --write $FILES)"
|
||||
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && npx oxfmt $FILES)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -73,18 +73,14 @@
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "prettier {args.files} --check --cache {args.cache} --cache-location {args.cacheLocation} --write {args.write} --cache-strategy {args.cacheStrategy}",
|
||||
"cache": true,
|
||||
"cacheLocation": "../../.cache/prettier/{projectRoot}",
|
||||
"cacheStrategy": "metadata",
|
||||
"write": false
|
||||
"command": "npx oxfmt --check {args.files} {args.write}",
|
||||
"files": ".",
|
||||
"write": ""
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"cacheStrategy": "content"
|
||||
},
|
||||
"ci": {},
|
||||
"fix": {
|
||||
"write": true
|
||||
"command": "npx oxfmt {args.files}"
|
||||
}
|
||||
},
|
||||
"dependsOn": ["^build"]
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"concurrently": "^8.2.2",
|
||||
"http-server": "^14.1.1",
|
||||
"nx": "22.5.4",
|
||||
"oxfmt": "0.50.0",
|
||||
"tsx": "^4.17.0",
|
||||
"verdaccio": "^6.3.1"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.7.0",
|
||||
"version": "2.9.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -80,8 +80,8 @@ export async function teardown() {
|
||||
const uninstallResult = await appUninstall({ appPath: APP_PATH });
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
console.warn(
|
||||
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
|
||||
throw new Error(
|
||||
`App uninstall failed during teardown: ${JSON.stringify(uninstallResult.error, null, 2)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PermissionFlag, defineRole } from 'twenty-sdk/define';
|
||||
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
|
||||
import {
|
||||
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
@@ -36,5 +36,5 @@ export default defineRole({
|
||||
canUpdateFieldValue: true,
|
||||
},
|
||||
],
|
||||
permissionFlags: [PermissionFlag.APPLICATIONS],
|
||||
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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 { PermissionFlag, defineRole } from 'twenty-sdk/define';
|
||||
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
|
||||
import {
|
||||
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
@@ -36,5 +36,5 @@ export default defineRole({
|
||||
canUpdateFieldValue: false,
|
||||
},
|
||||
],
|
||||
permissionFlags: [PermissionFlag.APPLICATIONS],
|
||||
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
|
||||
});
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
# 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=
|
||||
@@ -0,0 +1,42 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,48 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
TWENTY_VERSION: latest
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Spawn Twenty test instance
|
||||
id: twenty
|
||||
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
|
||||
with:
|
||||
twenty-version: ${{ env.TWENTY_VERSION }}
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Run integration tests
|
||||
run: yarn test
|
||||
env:
|
||||
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
|
||||
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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
|
||||
@@ -0,0 +1 @@
|
||||
24.5.0
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -0,0 +1,101 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
|
||||
|
||||
const APP_PATH = process.cwd();
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
|
||||
|
||||
function validateEnv(): { apiUrl: string; apiKey: string } {
|
||||
const apiUrl = process.env.TWENTY_API_URL;
|
||||
const apiKey = process.env.TWENTY_API_KEY;
|
||||
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
return { apiUrl, apiKey };
|
||||
}
|
||||
|
||||
async function checkServer(apiUrl: string) {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${apiUrl}/healthz`);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Twenty server is not reachable at ${apiUrl}. ` +
|
||||
'Make sure the server is running before executing integration tests.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfig(apiUrl: string, apiKey: string) {
|
||||
const payload = JSON.stringify(
|
||||
{
|
||||
remotes: {
|
||||
local: { apiUrl, apiKey, accessToken: apiKey },
|
||||
},
|
||||
defaultRemote: 'local',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
|
||||
}
|
||||
|
||||
export async function setup() {
|
||||
const { apiUrl, apiKey } = validateEnv();
|
||||
|
||||
await checkServer(apiUrl);
|
||||
|
||||
writeConfig(apiUrl, apiKey);
|
||||
|
||||
await appUninstall({ appPath: APP_PATH }).catch(() => {});
|
||||
|
||||
const result = await appDevOnce({
|
||||
appPath: APP_PATH,
|
||||
onProgress: (message: string) => console.log(`[dev] ${message}`),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
const uninstallResult = await appUninstall({ appPath: APP_PATH });
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
console.warn(
|
||||
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('App installation', () => {
|
||||
it('should find the installed app in the applications list', async () => {
|
||||
const client = new MetadataApiClient();
|
||||
|
||||
const result = await client.query({
|
||||
findManyApplications: {
|
||||
id: true,
|
||||
name: true,
|
||||
universalIdentifier: true,
|
||||
},
|
||||
});
|
||||
|
||||
const app = result.findManyApplications.find(
|
||||
(a: { universalIdentifier: string }) =>
|
||||
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
);
|
||||
|
||||
expect(app).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CoreApiClient', () => {
|
||||
it('should support CRUD on standard objects', async () => {
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const created = await client.mutation({
|
||||
createNote: {
|
||||
__args: { data: { title: 'Integration test note' } },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
expect(created.createNote.id).toBeDefined();
|
||||
|
||||
await client.mutation({
|
||||
destroyNote: {
|
||||
__args: { id: created.createNote.id },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
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,
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
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';
|
||||
@@ -0,0 +1,16 @@
|
||||
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,
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
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' },
|
||||
],
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
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,
|
||||
});
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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' },
|
||||
],
|
||||
});
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
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,
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
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,
|
||||
});
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
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' },
|
||||
],
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
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,
|
||||
});
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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' },
|
||||
],
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
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' },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
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,
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
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,
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
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,
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
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,
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
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,
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
});
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
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,
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
WAITING_FOR_MATCH_NAV_UNIVERSAL_IDENTIFIER,
|
||||
WAITING_FOR_MATCH_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: WAITING_FOR_MATCH_NAV_UNIVERSAL_IDENTIFIER,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
icon: 'IconClockHour4',
|
||||
position: 0,
|
||||
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
|
||||
viewUniversalIdentifier: WAITING_FOR_MATCH_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { FieldType, defineObject } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'partnerContent',
|
||||
namePlural: 'partnerContents',
|
||||
labelSingular: 'Partner Content',
|
||||
labelPlural: 'Partner Content',
|
||||
description: 'Marketing content involving a partner: quotes, case studies, logos',
|
||||
icon: 'IconQuote',
|
||||
isSearchable: true,
|
||||
labelIdentifierFieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6',
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6',
|
||||
type: FieldType.TEXT,
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
icon: 'IconTag',
|
||||
defaultValue: "''",
|
||||
},
|
||||
{
|
||||
universalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692',
|
||||
type: FieldType.MULTI_SELECT,
|
||||
name: 'contentType',
|
||||
label: 'Content Type',
|
||||
icon: 'IconCategory',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ id: '07f85e5b-0d70-416b-9884-256e469ed532', value: 'CUSTOMER_QUOTE', label: 'Customer quote', position: 0, color: 'blue' },
|
||||
{ id: '108b2358-d04d-4fdc-83df-a5978d39f66f', value: 'CASE_STUDY', label: 'Case study', position: 1, color: 'green' },
|
||||
{ id: 'eb45f371-f93c-4c45-9c8a-f29e1a58b7e4', value: 'PARTNER_QUOTE', label: 'Partner quote', position: 2, color: 'orange' },
|
||||
{ id: '3356c8a0-41cd-47c3-a293-5862138abc1a', value: 'LOGO', label: 'Logo', position: 3, color: 'purple' },
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a',
|
||||
type: FieldType.SELECT,
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
icon: 'IconProgressCheck',
|
||||
defaultValue: "'WIP'",
|
||||
options: [
|
||||
{ id: '5d41450b-8efb-41e8-81b7-b534429ec1b4', value: 'WIP', label: 'WIP', position: 0, color: 'gray' },
|
||||
{ id: '6611ae9a-4253-4638-9c47-d8bf2ec54368', value: 'INTERVIEW_SCHEDULED', label: 'Interview scheduled', position: 1, color: 'blue' },
|
||||
{ id: 'ae3d91e4-96ea-4687-b71f-d2d80088f28a', value: 'UNDER_CUSTOMER_PARTNER_REVIEW', label: 'Under review', position: 2, color: 'orange' },
|
||||
{ id: '91fe48a3-7950-4cdd-8c52-8d9b2cc03f0b', value: 'APPROVED', label: 'Approved', position: 3, color: 'green' },
|
||||
{ id: 'fa640b71-862e-4c64-80fa-37ab8b50d254', value: 'REJECTED', label: 'Rejected', position: 4, color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'b52d263e-423e-40b0-b82c-29214597c005',
|
||||
type: FieldType.DATE_TIME,
|
||||
name: 'approvalDate',
|
||||
label: 'Approval Date',
|
||||
icon: 'IconCalendarCheck',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'da7e9094-e2c3-47d3-924f-a1d4d3c717ed',
|
||||
type: FieldType.LINKS,
|
||||
name: 'interview',
|
||||
label: 'Interview',
|
||||
icon: 'IconMicrophone',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'f303369e-288c-4a48-9920-c1de0ad9a159',
|
||||
type: FieldType.FILES,
|
||||
name: 'documents',
|
||||
label: 'Documents',
|
||||
icon: 'IconPaperclip',
|
||||
isNullable: true,
|
||||
universalSettings: { maxNumberOfValues: 10 },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
import { FieldType, defineObject } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'partner',
|
||||
namePlural: 'partners',
|
||||
labelSingular: 'Partner',
|
||||
labelPlural: 'Partners',
|
||||
description: 'A certified implementation partner',
|
||||
icon: 'IconBuildingStore',
|
||||
isSearchable: true,
|
||||
labelIdentifierFieldMetadataUniversalIdentifier: 'a0000001-0000-4000-8000-000000000001',
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: 'a0000001-0000-4000-8000-000000000001',
|
||||
type: FieldType.TEXT,
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
icon: 'IconTag',
|
||||
defaultValue: "''",
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'a0000002-0000-4000-8000-000000000002',
|
||||
type: FieldType.TEXT,
|
||||
name: 'slug',
|
||||
label: 'Slug',
|
||||
icon: 'IconLink',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: '2ca9856f-f54a-4326-9ff3-668fd7da0b50',
|
||||
type: FieldType.SELECT,
|
||||
name: 'validationStage',
|
||||
label: 'Validation Stage',
|
||||
icon: 'IconProgressCheck',
|
||||
defaultValue: "'APPLICATION'",
|
||||
options: [
|
||||
{ id: '1b4f8b0b-68ec-4c02-a7e6-fbd0cda2f3b9', value: 'APPLICATION', label: 'Application', position: 0, color: 'gray' },
|
||||
{ id: '901181af-b2cb-4052-9ac8-3c84281671f8', value: 'POTENTIAL', label: 'Potential partner', position: 1, color: 'blue' },
|
||||
{ id: '3ebbc425-e5e3-412d-93df-5f70fefa1798', value: 'VALIDATED', label: 'Validated partner', position: 2, color: 'green' },
|
||||
{ id: '3d0ec027-0d51-4df7-932e-13c60d4b4382', value: 'FORMER', label: 'Former partner', position: 3, color: 'orange' },
|
||||
{ id: '19dc4929-735a-4b64-8f57-f34f3eaa3a73', value: 'REJECTED', label: 'Rejected', position: 4, color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: '5af4e57e-7fa7-4c4f-b40f-37549361459a',
|
||||
type: FieldType.BOOLEAN,
|
||||
name: 'reviewed',
|
||||
label: 'Reviewed',
|
||||
icon: 'IconChecks',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: '5412e4ca-cc96-4be8-8652-b73dace7673b',
|
||||
type: FieldType.RATING,
|
||||
name: 'ranking',
|
||||
label: 'Ranking',
|
||||
icon: 'IconStar',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ id: 'e4c784f0-7e2b-4eca-94dc-fc447266f252', value: 'RATING_1', label: '1', position: 0 },
|
||||
{ id: '79d033bd-4189-4f69-a9d1-a54d7cbbc5bc', value: 'RATING_2', label: '2', position: 1 },
|
||||
{ id: '1206bdcc-a804-4649-a3ce-bd001a3abc2c', value: 'RATING_3', label: '3', position: 2 },
|
||||
{ id: '1c195738-d823-415e-b4db-44a6cdc09ec5', value: 'RATING_4', label: '4', position: 3 },
|
||||
{ id: '92cd3a72-8d5b-4077-a369-ddfe429aa2aa', value: 'RATING_5', label: '5', position: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'd4fa6461-37b6-49ee-9181-dd482e74a70b',
|
||||
type: FieldType.SELECT,
|
||||
name: 'partnerTier',
|
||||
label: 'Partner Tier',
|
||||
icon: 'IconMedal',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ id: '63806cc0-02a8-4fe9-b2ba-0efe43a33109', value: 'NEW', label: 'New', position: 0, color: 'gray' },
|
||||
{ id: '1ea6f5ba-6a4a-4e64-9b56-11e9ae56c97e', value: 'INTERMEDIATE', label: 'Intermediate', position: 1, color: 'blue' },
|
||||
{ id: 'b44de8b4-caea-4ca6-94cd-fb67221b5fdb', value: 'ADVANCED', label: 'Advanced', position: 2, color: 'green' },
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: '500021ad-ca42-4fd3-8727-392dd26b722a',
|
||||
type: FieldType.MULTI_SELECT,
|
||||
name: 'partnerScope',
|
||||
label: 'Partner Scope',
|
||||
icon: 'IconListCheck',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ id: '00f05e1c-0fd9-4214-a461-554b7c9e7eb5', value: 'APPS', label: 'Apps', position: 0, color: 'blue' },
|
||||
{ id: 'e0f789d5-a3bc-4ecb-8bc2-5aa018468d83', value: 'DATA_MODEL', label: 'Data model', position: 1, color: 'green' },
|
||||
{ id: 'c88bf189-8be4-4431-aa03-85928f8b2a52', value: 'DATA_MIGRATION', label: 'Data migration', position: 2, color: 'turquoise' },
|
||||
{ id: 'a7fd9429-c26f-49ab-bf52-4d591f5ca7a0', value: 'HOSTING_ENVIRONMENT', label: 'Hosting environment', position: 3, color: 'purple' },
|
||||
{ id: '9e416a39-05c6-4c80-9f36-99b5b60c26ec', value: 'WORKFLOWS', label: 'Workflows', position: 4, color: 'orange' },
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'a451e557-a488-470a-8b35-6f9b8cfb1a10',
|
||||
type: FieldType.SELECT,
|
||||
name: 'typeOfTeam',
|
||||
label: 'Type of Team',
|
||||
icon: 'IconUsersGroup',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ id: '0f59b781-5a81-4025-92ad-4b93b2c62df0', value: 'SOLO', label: 'Solo', position: 0, color: 'blue' },
|
||||
{ id: 'db47bc44-93db-4cbf-8d0f-3057cf01d11e', value: 'AGENCY', label: 'Agency', position: 1, color: 'green' },
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: '6f260cc0-a860-41e6-ad40-a2ef32ecffbe',
|
||||
type: FieldType.ARRAY,
|
||||
name: 'skills',
|
||||
label: 'Skills',
|
||||
icon: 'IconTags',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'a0000004-0000-4000-8000-000000000004',
|
||||
type: FieldType.SELECT,
|
||||
name: 'availability',
|
||||
label: 'Availability',
|
||||
icon: 'IconCalendarCheck',
|
||||
defaultValue: "'AVAILABLE'",
|
||||
options: [
|
||||
{ id: '639d74c3-5d38-407b-b0e4-81f3930db451', value: 'AVAILABLE', label: 'Available', position: 0, color: 'green' },
|
||||
{ id: '6a921479-559a-4446-892a-d4a1b5a3abb4', value: 'UNAVAILABLE', label: 'Unavailable', position: 1, color: 'gray' },
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'a0000005-0000-4000-8000-000000000005',
|
||||
type: FieldType.MULTI_SELECT,
|
||||
name: 'deploymentExpertise',
|
||||
label: 'Deployment Expertise',
|
||||
icon: 'IconCloud',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ id: 'f2f53365-d909-4a97-bb81-efab43f0a17e', value: 'CLOUD', label: 'Cloud', position: 0, color: 'sky' },
|
||||
{ id: '5e47226b-4e60-413f-80ee-b8f1d9ee6ad6', value: 'SELF_HOST', label: 'Self-host', position: 1, color: 'purple' },
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'a0000007-0000-4000-8000-000000000007',
|
||||
type: FieldType.MULTI_SELECT,
|
||||
name: 'languagesSpoken',
|
||||
label: 'Languages Spoken',
|
||||
icon: 'IconLanguage',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ id: '3fe94a92-5f64-46f4-8697-1a44e06e939f', value: 'ENGLISH', label: 'English', position: 0, color: 'blue' },
|
||||
{ id: 'fdfe8adf-b2f1-4ef9-97c1-449302932698', value: 'FRENCH', label: 'French', position: 1, color: 'red' },
|
||||
{ id: '820c120a-17ba-4e9a-8dd7-9724ac41d7ca', value: 'GERMAN', label: 'German', position: 2, color: 'yellow' },
|
||||
{ id: '28171507-753d-4a8e-b9c2-44c698c2a26b', value: 'CHINESE', label: 'Chinese', position: 3, color: 'orange' },
|
||||
{ id: 'd9111720-26c1-47c3-85bb-a30699737cce', value: 'SPANISH', label: 'Spanish', position: 4, color: 'green' },
|
||||
{ id: '7dbfcb38-4255-4fe8-91dc-ce0686191521', value: 'ARABIC', label: 'Arabic', position: 5, color: 'sky' },
|
||||
{ id: 'd4e0e54b-2c57-498a-9ccc-9cfe64f09a36', value: 'BENGALI', label: 'Bengali', position: 6, color: 'purple' },
|
||||
{ id: '34e765d2-5b03-4a63-a6e6-6c3c75fd8d75', value: 'CATALAN', label: 'Catalan', position: 7, color: 'pink' },
|
||||
{ id: 'b042b1d3-19d6-4094-b53e-85f9727360b1', value: 'CZECH', label: 'Czech', position: 8, color: 'turquoise' },
|
||||
{ id: '0a8ebe74-dcfa-44f5-abde-1af3a8dd8879', value: 'DANISH', label: 'Danish', position: 9, color: 'gray' },
|
||||
{ id: '385e0db5-ab16-4a59-94fd-98921fa367d5', value: 'DUTCH', label: 'Dutch', position: 10, color: 'blue' },
|
||||
{ id: 'f8ec118f-6652-4fe4-a7dc-a77c079f473e', value: 'FARSI', label: 'Farsi', position: 11, color: 'red' },
|
||||
{ id: 'cd58db19-6be6-4deb-8f1b-4cd9bdbcacdf', value: 'FINNISH', label: 'Finnish', position: 12, color: 'yellow' },
|
||||
{ id: 'c20be129-8a93-479c-99f1-89dad2177c0a', value: 'GREEK', label: 'Greek', position: 13, color: 'orange' },
|
||||
{ id: '7faaec05-22d1-4b37-a700-9dc4733effb6', value: 'HINDI', label: 'Hindi', position: 14, color: 'green' },
|
||||
{ id: '3400d7db-312f-4d54-ac19-04a2f6fed6e0', value: 'INDONESIAN', label: 'Indonesian', position: 15, color: 'sky' },
|
||||
{ id: 'd1eb3870-2938-4c87-9596-3980b31df293', value: 'ITALIAN', label: 'Italian', position: 16, color: 'purple' },
|
||||
{ id: '7a854dd9-6c81-4d16-8b76-79b3571638ec', value: 'JAPANESE', label: 'Japanese', position: 17, color: 'pink' },
|
||||
{ id: '51be3b14-195c-45e2-815e-a6591d0946b1', value: 'KOREAN', label: 'Korean', position: 18, color: 'turquoise' },
|
||||
{ id: 'c31168a0-276c-45dc-bd39-a498d39a44d8', value: 'MALAY', label: 'Malay', position: 19, color: 'gray' },
|
||||
{ id: '7719d197-9e91-41d3-a66f-728cfce32c33', value: 'NORWEGIAN', label: 'Norwegian', position: 20, color: 'blue' },
|
||||
{ id: '115c5e11-2462-4371-92ca-c4835649de15', value: 'POLISH', label: 'Polish', position: 21, color: 'red' },
|
||||
{ id: '018e3663-3de8-4110-aceb-48c8083ebb9a', value: 'PORTUGUESE', label: 'Portuguese', position: 22, color: 'yellow' },
|
||||
{ id: 'ca97987f-4f43-43d0-b7a0-490bc566b42f', value: 'PUNJABI', label: 'Punjabi', position: 23, color: 'orange' },
|
||||
{ id: '05215653-4d32-49e2-9ff3-73e66e14d97d', value: 'ROMANIAN', label: 'Romanian', position: 24, color: 'green' },
|
||||
{ id: 'a14e341e-a266-407f-b4c7-936cfa608500', value: 'RUSSIAN', label: 'Russian', position: 25, color: 'sky' },
|
||||
{ id: '1190a9eb-bd06-4d77-ab59-c619f4e2faa3', value: 'SWAHILI', label: 'Swahili', position: 26, color: 'purple' },
|
||||
{ id: '0fedffb0-c0d7-47b7-ac5f-0b166d24d670', value: 'SWEDISH', label: 'Swedish', position: 27, color: 'pink' },
|
||||
{ id: '8205bc44-b2a0-4211-9fe5-1bc64efaa844', value: 'TAGALOG', label: 'Tagalog', position: 28, color: 'turquoise' },
|
||||
{ id: '866b6438-c172-4c72-b69e-b1ca89342526', value: 'TAMIL', label: 'Tamil', position: 29, color: 'gray' },
|
||||
{ id: 'a2c4851b-302b-4420-aff2-6d7dc614fa6c', value: 'THAI', label: 'Thai', position: 30, color: 'blue' },
|
||||
{ id: '0a5672dd-ebe5-48bc-b8ab-7d2bf6a605e2', value: 'TURKISH', label: 'Turkish', position: 31, color: 'red' },
|
||||
{ id: '36324fb0-5ea2-4e9f-845c-ac65b977ab20', value: 'UKRAINIAN', label: 'Ukrainian', position: 32, color: 'yellow' },
|
||||
{ id: 'b1aad243-fc85-4df3-a83e-07796ae01d6c', value: 'URDU', label: 'Urdu', position: 33, color: 'orange' },
|
||||
{ id: '922895f8-3a8a-45b8-a70e-0ae7b0c8ff32', value: 'VIETNAMESE', label: 'Vietnamese', position: 34, color: 'green' },
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: '0a1ce916-4df5-469a-b793-30f19e45b38d',
|
||||
type: FieldType.TEXT,
|
||||
name: 'city',
|
||||
label: 'City',
|
||||
icon: 'IconMapPin',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'a77d7fa6-c398-47db-af0f-036a5c719f20',
|
||||
type: FieldType.SELECT,
|
||||
name: 'country',
|
||||
label: 'Country',
|
||||
icon: 'IconFlag',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ id: '03332728-50b1-49ec-bdec-a938cb97ca74', value: 'AFGHANISTAN', label: 'Afghanistan 🇦🇫', position: 0, color: 'blue' },
|
||||
{ id: 'c39c7baa-6f52-4349-bc28-97b59f2a9a3a', value: 'ALBANIA', label: 'Albania 🇦🇱', position: 1, color: 'turquoise' },
|
||||
{ id: '1bce0427-d693-451a-9a6b-a50c0c502c85', value: 'ALGERIA', label: 'Algeria 🇩🇿', position: 2, color: 'green' },
|
||||
{ id: '634fb580-4f12-4a5b-b43c-5787330d4b60', value: 'ANDORRA', label: 'Andorra 🇦🇩', position: 3, color: 'sky' },
|
||||
{ id: '545797b9-16df-40d5-ab9b-037d3d875263', value: 'ANGOLA', label: 'Angola 🇦🇴', position: 4, color: 'purple' },
|
||||
{ id: '83b58c61-8b0d-42e1-9d4f-9a530ace7899', value: 'ANTIGUA_AND_BARBUDA', label: 'Antigua & Barbuda 🇦🇬', position: 5, color: 'orange' },
|
||||
{ id: '58fd4e0d-b48d-4b22-81d8-de6db6bd22ed', value: 'ARGENTINA', label: 'Argentina 🇦🇷', position: 6, color: 'yellow' },
|
||||
{ id: '7091a2e3-2095-4693-bd53-366df4ba5c75', value: 'ARMENIA', label: 'Armenia 🇦🇲', position: 7, color: 'red' },
|
||||
{ id: '479725e7-f185-46c6-9e8b-605fbbb392f8', value: 'AUSTRALIA', label: 'Australia 🇦🇺', position: 8, color: 'pink' },
|
||||
{ id: 'b7993e1b-5f7d-47b9-a84d-41795160393a', value: 'AUSTRIA', label: 'Austria 🇦🇹', position: 9, color: 'gray' },
|
||||
{ id: 'e2d324df-2baa-49a2-a016-2c7cab0d0e95', value: 'AZERBAIJAN', label: 'Azerbaijan 🇦🇿', position: 10, color: 'blue' },
|
||||
{ id: '87dc4b54-c4f7-4813-a0e4-d2214e659a77', value: 'BAHAMAS', label: 'Bahamas 🇧🇸', position: 11, color: 'turquoise' },
|
||||
{ id: '32149c4f-3319-42a6-a0ca-6afb42bdf3b9', value: 'BAHRAIN', label: 'Bahrain 🇧🇭', position: 12, color: 'green' },
|
||||
{ id: 'c74d1f90-e17f-4e9a-9341-5c6ffda2d3ac', value: 'BANGLADESH', label: 'Bangladesh 🇧🇩', position: 13, color: 'sky' },
|
||||
{ id: '61c2e8c4-06af-4daa-ac52-34f4b47e2aad', value: 'BARBADOS', label: 'Barbados 🇧🇧', position: 14, color: 'purple' },
|
||||
{ id: '1c7dfa46-72e0-4ba3-8af5-3b2d1d46fd4b', value: 'BELARUS', label: 'Belarus 🇧🇾', position: 15, color: 'orange' },
|
||||
{ id: '771258e5-b16f-47d4-aaed-47a4c931b72d', value: 'BELGIUM', label: 'Belgium 🇧🇪', position: 16, color: 'yellow' },
|
||||
{ id: 'f3910b68-3f3f-4afb-9f90-5561376e5098', value: 'BELIZE', label: 'Belize 🇧🇿', position: 17, color: 'red' },
|
||||
{ id: 'b06f1bc7-2058-44fc-b868-631559de7d11', value: 'BENIN', label: 'Benin 🇧🇯', position: 18, color: 'pink' },
|
||||
{ id: 'acff20b8-b865-47de-9049-c660866f5218', value: 'BHUTAN', label: 'Bhutan 🇧🇹', position: 19, color: 'gray' },
|
||||
{ id: 'bee20647-c283-40af-9733-37753e42b464', value: 'BOLIVIA', label: 'Bolivia 🇧🇴', position: 20, color: 'blue' },
|
||||
{ id: '4dd91769-c713-47af-bf48-aed0251115b1', value: 'BOSNIA_AND_HERZEGOVINA', label: 'Bosnia & Herzegovina 🇧🇦', position: 21, color: 'turquoise' },
|
||||
{ id: 'a6997427-007f-42b4-b6ea-892f7ce58dff', value: 'BOTSWANA', label: 'Botswana 🇧🇼', position: 22, color: 'green' },
|
||||
{ id: '3ff1f170-d3f0-4ce6-9490-bfc20aadf470', value: 'BRAZIL', label: 'Brazil 🇧🇷', position: 23, color: 'sky' },
|
||||
{ id: '7b2d6911-4676-433d-93f1-6eca2530d89d', value: 'BRUNEI', label: 'Brunei 🇧🇳', position: 24, color: 'purple' },
|
||||
{ id: '767c72f3-f951-4b00-958d-856d2a8bda7a', value: 'BULGARIA', label: 'Bulgaria 🇧🇬', position: 25, color: 'orange' },
|
||||
{ id: 'e16ce6af-86b0-4448-8008-c5efa2ce3fe9', value: 'BURKINA_FASO', label: 'Burkina Faso 🇧🇫', position: 26, color: 'yellow' },
|
||||
{ id: 'dd7e7b2a-8367-482d-99d4-7877d883c2e3', value: 'BURUNDI', label: 'Burundi 🇧🇮', position: 27, color: 'red' },
|
||||
{ id: 'd9d6eea9-7724-4c65-be48-e861075121f1', value: 'CAMBODIA', label: 'Cambodia 🇰🇭', position: 28, color: 'pink' },
|
||||
{ id: '49178472-0bdd-4797-be6c-e0a1ac6e4147', value: 'CAMEROON', label: 'Cameroon 🇨🇲', position: 29, color: 'gray' },
|
||||
{ id: '849b6361-a83b-48fb-a3e5-2ac6e9089c7f', value: 'CANADA', label: 'Canada 🇨🇦', position: 30, color: 'blue' },
|
||||
{ id: 'ca22a230-01b1-4099-b7c2-29fd3b8d09de', value: 'CAPE_VERDE', label: 'Cape Verde 🇨🇻', position: 31, color: 'turquoise' },
|
||||
{ id: '360bef1e-34a9-4467-9ffe-5ebeb6427711', value: 'CENTRAL_AFRICAN_REPUBLIC', label: 'Central African Republic 🇨🇫', position: 32, color: 'green' },
|
||||
{ id: 'deba0ee2-3027-4646-afc6-24410bf68198', value: 'CHAD', label: 'Chad 🇹🇩', position: 33, color: 'sky' },
|
||||
{ id: 'd0bf04d5-a40c-45f0-8e82-4d5905c00131', value: 'CHILE', label: 'Chile 🇨🇱', position: 34, color: 'purple' },
|
||||
{ id: '60522b37-dded-4f18-a25b-05b51a8c9aa7', value: 'CHINA', label: 'China 🇨🇳', position: 35, color: 'orange' },
|
||||
{ id: '3b2c58f1-4efc-4c0f-9d43-f3a5b5b577cf', value: 'COLOMBIA', label: 'Colombia 🇨🇴', position: 36, color: 'yellow' },
|
||||
{ id: 'cbea73ba-8a9d-47ee-a38b-b7c3d89d3922', value: 'COMOROS', label: 'Comoros 🇰🇲', position: 37, color: 'red' },
|
||||
{ id: '895ed0d4-6a00-482a-840e-44edef034d2b', value: 'CONGO', label: 'Congo 🇨🇬', position: 38, color: 'pink' },
|
||||
{ id: '13db3209-398c-474c-b5f9-269ccf27adc5', value: 'DR_CONGO', label: 'DR Congo 🇨🇩', position: 39, color: 'gray' },
|
||||
{ id: '150c4227-ce67-420b-bb6c-82d5a4fb9c70', value: 'COSTA_RICA', label: 'Costa Rica 🇨🇷', position: 40, color: 'blue' },
|
||||
{ id: 'ad1853e2-753d-4245-b9a7-88a29281ca0a', value: 'CROATIA', label: 'Croatia 🇭🇷', position: 41, color: 'turquoise' },
|
||||
{ id: 'e42cad17-f7ea-450b-8c7f-a0ac0dc31dac', value: 'CUBA', label: 'Cuba 🇨🇺', position: 42, color: 'green' },
|
||||
{ id: '9035a680-5962-48c3-8d79-ee805a2c8b1c', value: 'CYPRUS', label: 'Cyprus 🇨🇾', position: 43, color: 'sky' },
|
||||
{ id: 'facb14c9-40e9-47f6-bc4b-b29178761210', value: 'CZECH_REPUBLIC', label: 'Czech Republic 🇨🇿', position: 44, color: 'purple' },
|
||||
{ id: '47026695-ee4b-47f2-97c1-9d1ea9854cd5', value: 'DENMARK', label: 'Denmark 🇩🇰', position: 45, color: 'orange' },
|
||||
{ id: '335e3877-1f8b-4f2b-a409-ec74596dd779', value: 'DJIBOUTI', label: 'Djibouti 🇩🇯', position: 46, color: 'yellow' },
|
||||
{ id: 'ee17957f-f692-495b-a664-61284fa723f2', value: 'DOMINICA', label: 'Dominica 🇩🇲', position: 47, color: 'red' },
|
||||
{ id: '4114c313-27d7-4f5a-a381-38daca143642', value: 'DOMINICAN_REPUBLIC', label: 'Dominican Republic 🇩🇴', position: 48, color: 'pink' },
|
||||
{ id: 'ffcfae44-e1b7-4739-87dd-0d957062646d', value: 'ECUADOR', label: 'Ecuador 🇪🇨', position: 49, color: 'gray' },
|
||||
{ id: '1f4e8ee4-3be1-42cd-96d4-3e5686dee153', value: 'EGYPT', label: 'Egypt 🇪🇬', position: 50, color: 'blue' },
|
||||
{ id: 'cf98813f-2a7e-4426-b97f-d3f1ac044bb8', value: 'EL_SALVADOR', label: 'El Salvador 🇸🇻', position: 51, color: 'turquoise' },
|
||||
{ id: '0e787d6e-e864-4ee3-b4c7-20fa873e8649', value: 'EQUATORIAL_GUINEA', label: 'Equatorial Guinea 🇬🇶', position: 52, color: 'green' },
|
||||
{ id: 'f68b6554-4ed5-4161-933f-a321b41391ea', value: 'ERITREA', label: 'Eritrea 🇪🇷', position: 53, color: 'sky' },
|
||||
{ id: '7a4387dc-198c-46db-9536-7bc73de4e1b0', value: 'ESTONIA', label: 'Estonia 🇪🇪', position: 54, color: 'purple' },
|
||||
{ id: '7e0fec86-e04f-499c-a814-72ac9b4cc5ce', value: 'ESWATINI', label: 'Eswatini 🇸🇿', position: 55, color: 'orange' },
|
||||
{ id: 'a7768d57-5bf2-401e-a82d-ec36e3f3a419', value: 'ETHIOPIA', label: 'Ethiopia 🇪🇹', position: 56, color: 'yellow' },
|
||||
{ id: '1b9e15c3-4fa5-4cd8-9bfb-8262518665fa', value: 'FIJI', label: 'Fiji 🇫🇯', position: 57, color: 'red' },
|
||||
{ id: '828a6fe2-3c50-4da3-97c9-11f3b80fbb0d', value: 'FINLAND', label: 'Finland 🇫🇮', position: 58, color: 'pink' },
|
||||
{ id: '03a1c597-65cf-440f-a20d-9d6bef0540c2', value: 'FRANCE', label: 'France 🇫🇷', position: 59, color: 'gray' },
|
||||
{ id: 'ab2edd1f-6353-4de8-863f-4b28e774be8e', value: 'GABON', label: 'Gabon 🇬🇦', position: 60, color: 'blue' },
|
||||
{ id: '12a50b82-c2f8-4d0b-b5f4-e52dfe01ba13', value: 'GAMBIA', label: 'Gambia 🇬🇲', position: 61, color: 'turquoise' },
|
||||
{ id: '2adf673e-8367-4eaf-83f0-41199718f0ee', value: 'GEORGIA', label: 'Georgia 🇬🇪', position: 62, color: 'green' },
|
||||
{ id: 'bd61021e-c25b-453e-b0cc-afb3942b063f', value: 'GERMANY', label: 'Germany 🇩🇪', position: 63, color: 'sky' },
|
||||
{ id: '1c45b7ae-9489-4714-b21e-8c717e6599ab', value: 'GHANA', label: 'Ghana 🇬🇭', position: 64, color: 'purple' },
|
||||
{ id: 'cc47d864-cd78-4cdd-a711-d679f0cbc133', value: 'GREECE', label: 'Greece 🇬🇷', position: 65, color: 'orange' },
|
||||
{ id: '2a2c3ca6-611e-49a4-9639-e8f60172c1b3', value: 'GRENADA', label: 'Grenada 🇬🇩', position: 66, color: 'yellow' },
|
||||
{ id: 'da2024e5-a1a0-49ab-8cf0-0d9e3f10ff9d', value: 'GUATEMALA', label: 'Guatemala 🇬🇹', position: 67, color: 'red' },
|
||||
{ id: '086c9969-1911-4aa4-8d67-3abdc12b2ce7', value: 'GUINEA', label: 'Guinea 🇬🇳', position: 68, color: 'pink' },
|
||||
{ id: 'a648d1d3-4d3f-4fc6-a1ea-40f98283df75', value: 'GUINEA_BISSAU', label: 'Guinea-Bissau 🇬🇼', position: 69, color: 'gray' },
|
||||
{ id: 'b567e119-4dcb-4f8a-b012-febe72262d85', value: 'GUYANA', label: 'Guyana 🇬🇾', position: 70, color: 'blue' },
|
||||
{ id: '26801d38-c0e6-468a-8c78-a0aed06c16fd', value: 'HAITI', label: 'Haiti 🇭🇹', position: 71, color: 'turquoise' },
|
||||
{ id: '2e31c5ff-da0a-4c82-b7bc-7a8c18b54f1e', value: 'HONDURAS', label: 'Honduras 🇭🇳', position: 72, color: 'green' },
|
||||
{ id: 'a78bd322-ac40-4ab3-a46f-6204e42b0712', value: 'HUNGARY', label: 'Hungary 🇭🇺', position: 73, color: 'sky' },
|
||||
{ id: '0026e951-225d-4e8c-8e64-289ead339bd3', value: 'ICELAND', label: 'Iceland 🇮🇸', position: 74, color: 'purple' },
|
||||
{ id: 'f5101d7d-9b81-4d2e-a9aa-e20dfab52c58', value: 'INDIA', label: 'India 🇮🇳', position: 75, color: 'orange' },
|
||||
{ id: '11b30046-58f3-42e5-a3e2-5d4761675056', value: 'INDONESIA', label: 'Indonesia 🇮🇩', position: 76, color: 'yellow' },
|
||||
{ id: '7345db23-db33-45cb-b8cc-e4d4a1f1b90e', value: 'IRAN', label: 'Iran 🇮🇷', position: 77, color: 'red' },
|
||||
{ id: '899d0eb1-9f04-4a88-b7f3-351e998b5979', value: 'IRAQ', label: 'Iraq 🇮🇶', position: 78, color: 'pink' },
|
||||
{ id: 'e45e8143-14c1-4184-84db-c11212cb8e25', value: 'IRELAND', label: 'Ireland 🇮🇪', position: 79, color: 'gray' },
|
||||
{ id: 'a208dac3-455e-41ed-9db0-aa7352d420ab', value: 'ISRAEL', label: 'Israel 🇮🇱', position: 80, color: 'blue' },
|
||||
{ id: '4537862d-0565-47a0-8759-ef522996881f', value: 'ITALY', label: 'Italy 🇮🇹', position: 81, color: 'turquoise' },
|
||||
{ id: '988630a1-dd19-476b-a0dc-cf8904ec5f68', value: 'IVORY_COAST', label: 'Ivory Coast 🇨🇮', position: 82, color: 'green' },
|
||||
{ id: '30c755e0-61f2-4590-9969-f3150c3b5aff', value: 'JAMAICA', label: 'Jamaica 🇯🇲', position: 83, color: 'sky' },
|
||||
{ id: '9ca39ab6-e0fb-4bbb-80bc-cbffa48682da', value: 'JAPAN', label: 'Japan 🇯🇵', position: 84, color: 'purple' },
|
||||
{ id: 'f71aefd9-1dc0-4370-8a31-2a329d8ab15d', value: 'JORDAN', label: 'Jordan 🇯🇴', position: 85, color: 'orange' },
|
||||
{ id: '7f230c15-2745-4e78-ab35-8ae5bec7bcc6', value: 'KAZAKHSTAN', label: 'Kazakhstan 🇰🇿', position: 86, color: 'yellow' },
|
||||
{ id: '9e745903-4ac7-432f-bfd2-d0d78e76f3e9', value: 'KENYA', label: 'Kenya 🇰🇪', position: 87, color: 'red' },
|
||||
{ id: 'ee448c0f-8b3b-4666-bbbb-19e8b62ecbcc', value: 'KIRIBATI', label: 'Kiribati 🇰🇮', position: 88, color: 'pink' },
|
||||
{ id: '6de78d0a-7a2b-48bb-97b5-2a86c8bab3b6', value: 'KOSOVO', label: 'Kosovo 🇽🇰', position: 89, color: 'gray' },
|
||||
{ id: '48e160eb-1fdf-4456-99f7-4e97e7d16cfa', value: 'KUWAIT', label: 'Kuwait 🇰🇼', position: 90, color: 'blue' },
|
||||
{ id: '7d318293-6378-4a02-af49-23d48d887a8f', value: 'KYRGYZSTAN', label: 'Kyrgyzstan 🇰🇬', position: 91, color: 'turquoise' },
|
||||
{ id: 'dc540210-83bb-40c5-ab0d-585f4648df1b', value: 'LAOS', label: 'Laos 🇱🇦', position: 92, color: 'green' },
|
||||
{ id: 'fe2c5828-330f-45b8-991c-180723d5bed6', value: 'LATVIA', label: 'Latvia 🇱🇻', position: 93, color: 'sky' },
|
||||
{ id: 'cff1390a-7548-401b-bfa1-5e062e1e2cd3', value: 'LEBANON', label: 'Lebanon 🇱🇧', position: 94, color: 'purple' },
|
||||
{ id: '2a1d40fd-a633-4bb0-890a-1009a2126ff9', value: 'LESOTHO', label: 'Lesotho 🇱🇸', position: 95, color: 'orange' },
|
||||
{ id: 'dee0303d-11c9-4bf9-913b-c5fedfc93766', value: 'LIBERIA', label: 'Liberia 🇱🇷', position: 96, color: 'yellow' },
|
||||
{ id: '57713732-bd4e-4888-a4e3-ad7761e76dd9', value: 'LIBYA', label: 'Libya 🇱🇾', position: 97, color: 'red' },
|
||||
{ id: '5ed4e5fd-31dc-4adc-a875-b5036ef5eac6', value: 'LIECHTENSTEIN', label: 'Liechtenstein 🇱🇮', position: 98, color: 'pink' },
|
||||
{ id: '8facad69-2b22-47a4-a324-75773bd7d4f8', value: 'LITHUANIA', label: 'Lithuania 🇱🇹', position: 99, color: 'gray' },
|
||||
{ id: 'fb1b4543-7f22-4c44-a953-7c68e2790e27', value: 'LUXEMBOURG', label: 'Luxembourg 🇱🇺', position: 100, color: 'blue' },
|
||||
{ id: '80009115-d98c-4e63-8401-285f82d94921', value: 'MADAGASCAR', label: 'Madagascar 🇲🇬', position: 101, color: 'turquoise' },
|
||||
{ id: 'b32ed1f2-3d12-4255-a39a-3a5ab64b425c', value: 'MALAWI', label: 'Malawi 🇲🇼', position: 102, color: 'green' },
|
||||
{ id: 'd7c744b0-23d2-4dd4-a9f9-3298cfce0d1b', value: 'MALAYSIA', label: 'Malaysia 🇲🇾', position: 103, color: 'sky' },
|
||||
{ id: 'a878865c-a73f-47d7-bf52-29c1b7b947d7', value: 'MALDIVES', label: 'Maldives 🇲🇻', position: 104, color: 'purple' },
|
||||
{ id: '2d5e9644-11b6-4b55-8f9a-ed33da2de6ac', value: 'MALI', label: 'Mali 🇲🇱', position: 105, color: 'orange' },
|
||||
{ id: 'e2fa091a-0e78-41da-937b-42b55e501226', value: 'MALTA', label: 'Malta 🇲🇹', position: 106, color: 'yellow' },
|
||||
{ id: 'e36301e0-1d79-4540-87c7-4de2bfeface6', value: 'MARSHALL_ISLANDS', label: 'Marshall Islands 🇲🇭', position: 107, color: 'red' },
|
||||
{ id: '8dc782e6-3578-4d7a-a6ba-a29e48f3cc11', value: 'MAURITANIA', label: 'Mauritania 🇲🇷', position: 108, color: 'pink' },
|
||||
{ id: 'edf63c87-d621-40fe-b653-a0f7764c8961', value: 'MAURITIUS', label: 'Mauritius 🇲🇺', position: 109, color: 'gray' },
|
||||
{ id: '86233df1-6c71-4ffb-9298-c61c65dcc79c', value: 'MEXICO', label: 'Mexico 🇲🇽', position: 110, color: 'blue' },
|
||||
{ id: 'c41c35ff-118b-4119-b12f-93be635fbae3', value: 'MICRONESIA', label: 'Micronesia 🇫🇲', position: 111, color: 'turquoise' },
|
||||
{ id: '8466a06e-a34f-4dd4-b660-8e9ad2aa5998', value: 'MOLDOVA', label: 'Moldova 🇲🇩', position: 112, color: 'green' },
|
||||
{ id: '948e9117-486d-40c1-847c-de6cc64b9730', value: 'MONACO', label: 'Monaco 🇲🇨', position: 113, color: 'sky' },
|
||||
{ id: 'ee33ddb8-3219-4fdb-8a71-8a6f8fdf93d3', value: 'MONGOLIA', label: 'Mongolia 🇲🇳', position: 114, color: 'purple' },
|
||||
{ id: 'b5c1fa55-0b1a-43d8-a871-ebd004952d0b', value: 'MONTENEGRO', label: 'Montenegro 🇲🇪', position: 115, color: 'orange' },
|
||||
{ id: '9e98cb68-666e-4116-b35f-bb9711fd7546', value: 'MOROCCO', label: 'Morocco 🇲🇦', position: 116, color: 'yellow' },
|
||||
{ id: '86c4b5f8-d4bc-4639-a5bf-00a84601b911', value: 'MOZAMBIQUE', label: 'Mozambique 🇲🇿', position: 117, color: 'red' },
|
||||
{ id: '8b3bcfa4-1797-492e-83d7-7d939e479b1d', value: 'MYANMAR', label: 'Myanmar 🇲🇲', position: 118, color: 'pink' },
|
||||
{ id: 'b8d01fef-4061-4e8c-bc38-f8226b4be6be', value: 'NAMIBIA', label: 'Namibia 🇳🇦', position: 119, color: 'gray' },
|
||||
{ id: '71dd1ad1-af94-4cfb-973d-33f87b4ef0cd', value: 'NAURU', label: 'Nauru 🇳🇷', position: 120, color: 'blue' },
|
||||
{ id: 'dc692171-3adc-4a73-8b45-a25cf659c546', value: 'NEPAL', label: 'Nepal 🇳🇵', position: 121, color: 'turquoise' },
|
||||
{ id: '3e161b4b-be95-4a6a-910d-628ad32691e1', value: 'NETHERLANDS', label: 'Netherlands 🇳🇱', position: 122, color: 'green' },
|
||||
{ id: 'b86be59e-a1ae-4df4-937f-ad97c641e0df', value: 'NEW_ZEALAND', label: 'New Zealand 🇳🇿', position: 123, color: 'sky' },
|
||||
{ id: '4674abdd-0548-4317-8af5-865e4d11327d', value: 'NICARAGUA', label: 'Nicaragua 🇳🇮', position: 124, color: 'purple' },
|
||||
{ id: '85ff2ce7-ddd8-4be6-b740-d79e70a3d103', value: 'NIGER', label: 'Niger 🇳🇪', position: 125, color: 'orange' },
|
||||
{ id: 'a69ab8cf-5a59-4879-92da-7cd2dea6b9e6', value: 'NIGERIA', label: 'Nigeria 🇳🇬', position: 126, color: 'yellow' },
|
||||
{ id: '28736977-b21f-4678-8e22-363be28a8e94', value: 'NORTH_KOREA', label: 'North Korea 🇰🇵', position: 127, color: 'red' },
|
||||
{ id: '11f87414-633d-48f8-a99d-0b407c281dbd', value: 'NORTH_MACEDONIA', label: 'North Macedonia 🇲🇰', position: 128, color: 'pink' },
|
||||
{ id: '9637c5aa-d405-4414-a56b-b983ed0bfa51', value: 'NORWAY', label: 'Norway 🇳🇴', position: 129, color: 'gray' },
|
||||
{ id: '70f79652-7f95-4836-ab2d-4373631910ec', value: 'OMAN', label: 'Oman 🇴🇲', position: 130, color: 'blue' },
|
||||
{ id: '50e4ae80-79d8-4cdf-aa0a-a2675e6e26fb', value: 'PAKISTAN', label: 'Pakistan 🇵🇰', position: 131, color: 'turquoise' },
|
||||
{ id: '08d27180-4861-4937-b04e-6cbcd33f507d', value: 'PALAU', label: 'Palau 🇵🇼', position: 132, color: 'green' },
|
||||
{ id: '6aa50196-ea4b-42cd-b890-8d7d56cc9731', value: 'PALESTINE', label: 'Palestine 🇵🇸', position: 133, color: 'sky' },
|
||||
{ id: '5663124d-f2fc-4374-8734-f3c53f9ea4f5', value: 'PANAMA', label: 'Panama 🇵🇦', position: 134, color: 'purple' },
|
||||
{ id: '8bde7cc7-fa6c-4f52-b9df-16c99928f50f', value: 'PAPUA_NEW_GUINEA', label: 'Papua New Guinea 🇵🇬', position: 135, color: 'orange' },
|
||||
{ id: '3eed4b21-1009-4dd2-bdac-53b11df8400c', value: 'PARAGUAY', label: 'Paraguay 🇵🇾', position: 136, color: 'yellow' },
|
||||
{ id: 'fbc734c9-63ae-4d2e-af0f-8e2abd874dda', value: 'PERU', label: 'Peru 🇵🇪', position: 137, color: 'red' },
|
||||
{ id: '211555bc-8f26-4d4d-94f1-c83884c32e20', value: 'PHILIPPINES', label: 'Philippines 🇵🇭', position: 138, color: 'pink' },
|
||||
{ id: '6d69e1a3-270e-4e36-9a55-40daed5b10ef', value: 'POLAND', label: 'Poland 🇵🇱', position: 139, color: 'gray' },
|
||||
{ id: '23b1ae16-f00f-4844-8871-ed2b46c1cb51', value: 'PORTUGAL', label: 'Portugal 🇵🇹', position: 140, color: 'blue' },
|
||||
{ id: '4dc69f6b-b0ee-48ea-95d3-517dc4ec7b91', value: 'QATAR', label: 'Qatar 🇶🇦', position: 141, color: 'turquoise' },
|
||||
{ id: '8dcd82ce-98b2-45cb-a76d-f1503509035f', value: 'ROMANIA', label: 'Romania 🇷🇴', position: 142, color: 'green' },
|
||||
{ id: '7e18e241-4fd6-4303-b390-0a9dea9c16bd', value: 'RUSSIA', label: 'Russia 🇷🇺', position: 143, color: 'sky' },
|
||||
{ id: '966e45fb-7ed8-478e-9c26-25b7d59ae4f4', value: 'RWANDA', label: 'Rwanda 🇷🇼', position: 144, color: 'purple' },
|
||||
{ id: '22ddf2d7-f1f8-46e9-8eb9-248c02652ad0', value: 'SAINT_KITTS_AND_NEVIS', label: 'Saint Kitts & Nevis 🇰🇳', position: 145, color: 'orange' },
|
||||
{ id: '3312359a-b9fd-4a95-9db2-ad3e1f67f2b2', value: 'SAINT_LUCIA', label: 'Saint Lucia 🇱🇨', position: 146, color: 'yellow' },
|
||||
{ id: '159c06c1-14a5-407f-af16-b84ba14f16be', value: 'SAINT_VINCENT', label: 'Saint Vincent 🇻🇨', position: 147, color: 'red' },
|
||||
{ id: '0dd1cf79-89e2-4442-bd83-c65b43a3a544', value: 'SAMOA', label: 'Samoa 🇼🇸', position: 148, color: 'pink' },
|
||||
{ id: 'e16851a7-97de-4e17-aeac-0cb828655406', value: 'SAN_MARINO', label: 'San Marino 🇸🇲', position: 149, color: 'gray' },
|
||||
{ id: '6415861d-5fa9-46b5-a2cd-d58587c9dd03', value: 'SAO_TOME_AND_PRINCIPE', label: 'São Tomé & Príncipe 🇸🇹', position: 150, color: 'blue' },
|
||||
{ id: '5831720a-3ce0-409c-9694-1b1d12394599', value: 'SAUDI_ARABIA', label: 'Saudi Arabia 🇸🇦', position: 151, color: 'turquoise' },
|
||||
{ id: '89155d75-e4d0-4115-916c-8b77b9ddc5f3', value: 'SENEGAL', label: 'Senegal 🇸🇳', position: 152, color: 'green' },
|
||||
{ id: 'f0c3e0e0-8106-4921-a02e-0497c1318efa', value: 'SERBIA', label: 'Serbia 🇷🇸', position: 153, color: 'sky' },
|
||||
{ id: 'd8633c5d-1c65-44c5-bc71-7eb65cb988c3', value: 'SEYCHELLES', label: 'Seychelles 🇸🇨', position: 154, color: 'purple' },
|
||||
{ id: '64b9fd47-a748-4add-bacf-a7dc1f1aa0c7', value: 'SIERRA_LEONE', label: 'Sierra Leone 🇸🇱', position: 155, color: 'orange' },
|
||||
{ id: '5bd24cd5-a233-4d8f-930d-27f15d493c92', value: 'SINGAPORE', label: 'Singapore 🇸🇬', position: 156, color: 'yellow' },
|
||||
{ id: '63c5df12-b203-4627-91b2-0526a56452d4', value: 'SLOVAKIA', label: 'Slovakia 🇸🇰', position: 157, color: 'red' },
|
||||
{ id: 'b0deeef1-767a-4730-8743-588bfeb856e6', value: 'SLOVENIA', label: 'Slovenia 🇸🇮', position: 158, color: 'pink' },
|
||||
{ id: '9274cd87-3400-4ccd-9192-3239b5126691', value: 'SOLOMON_ISLANDS', label: 'Solomon Islands 🇸🇧', position: 159, color: 'gray' },
|
||||
{ id: '67dc7930-eaa4-4ef0-9fc4-af996eaaae09', value: 'SOMALIA', label: 'Somalia 🇸🇴', position: 160, color: 'blue' },
|
||||
{ id: '19d96578-107c-4286-9504-5d59077b8290', value: 'SOUTH_AFRICA', label: 'South Africa 🇿🇦', position: 161, color: 'turquoise' },
|
||||
{ id: '11cb5cb6-d67d-47ae-adf1-ecba3635fe20', value: 'SOUTH_KOREA', label: 'South Korea 🇰🇷', position: 162, color: 'green' },
|
||||
{ id: '8293e893-0d7d-4002-9929-fea536a9a73c', value: 'SOUTH_SUDAN', label: 'South Sudan 🇸🇸', position: 163, color: 'sky' },
|
||||
{ id: 'e0b9ba99-d891-4f6c-95df-1f92a0a7e12e', value: 'SPAIN', label: 'Spain 🇪🇸', position: 164, color: 'purple' },
|
||||
{ id: '18378ce8-e480-4fd9-b90d-379d5daf7ecb', value: 'SRI_LANKA', label: 'Sri Lanka 🇱🇰', position: 165, color: 'orange' },
|
||||
{ id: '10943324-5ae5-47db-bb2f-5d4bdeafd0b0', value: 'SUDAN', label: 'Sudan 🇸🇩', position: 166, color: 'yellow' },
|
||||
{ id: 'bfd244e4-87c0-4dca-89b0-a1b581f1aa65', value: 'SURINAME', label: 'Suriname 🇸🇷', position: 167, color: 'red' },
|
||||
{ id: '6a8f9119-c413-4ece-b432-2ce2e80580a1', value: 'SWEDEN', label: 'Sweden 🇸🇪', position: 168, color: 'pink' },
|
||||
{ id: '866a8e2d-60a7-40a0-8541-35a9cfa20c09', value: 'SWITZERLAND', label: 'Switzerland 🇨🇭', position: 169, color: 'gray' },
|
||||
{ id: '93758334-0ebc-4884-bfac-c0dbeec23a35', value: 'SYRIA', label: 'Syria 🇸🇾', position: 170, color: 'blue' },
|
||||
{ id: 'c785e424-7a6f-4f27-91bc-aadb617525b0', value: 'TAIWAN', label: 'Taiwan 🇹🇼', position: 171, color: 'turquoise' },
|
||||
{ id: '15ab8a5b-5989-4e3b-92cb-00b88cf7b7f6', value: 'TAJIKISTAN', label: 'Tajikistan 🇹🇯', position: 172, color: 'green' },
|
||||
{ id: '476a55dd-8c69-4711-8e40-54cbe41d5c3a', value: 'TANZANIA', label: 'Tanzania 🇹🇿', position: 173, color: 'sky' },
|
||||
{ id: 'bfcbef16-36c4-4350-a614-7ad065276104', value: 'THAILAND', label: 'Thailand 🇹🇭', position: 174, color: 'purple' },
|
||||
{ id: '6872c804-9baa-498f-9dc6-b5894b64dece', value: 'TIMOR_LESTE', label: 'Timor-Leste 🇹🇱', position: 175, color: 'orange' },
|
||||
{ id: 'b6429782-b6e1-4404-a5c4-8a44f3818147', value: 'TOGO', label: 'Togo 🇹🇬', position: 176, color: 'yellow' },
|
||||
{ id: 'f4a7b7f9-aa58-4d23-adac-a96ed2c61ba1', value: 'TONGA', label: 'Tonga 🇹🇴', position: 177, color: 'red' },
|
||||
{ id: 'a1377467-7a21-40af-861f-4b362b30fcb4', value: 'TRINIDAD_AND_TOBAGO', label: 'Trinidad & Tobago 🇹🇹', position: 178, color: 'pink' },
|
||||
{ id: '7779c7d6-c7e1-4d6c-8282-31f659988c22', value: 'TUNISIA', label: 'Tunisia 🇹🇳', position: 179, color: 'gray' },
|
||||
{ id: '5858f81e-629a-4b04-98e8-6441f3984044', value: 'TURKEY', label: 'Turkey 🇹🇷', position: 180, color: 'blue' },
|
||||
{ id: 'cfdae353-6782-4dde-b229-d4e2c577fbed', value: 'TURKMENISTAN', label: 'Turkmenistan 🇹🇲', position: 181, color: 'turquoise' },
|
||||
{ id: '755d859a-a8d4-45e0-aa6e-77afcedb29d0', value: 'TUVALU', label: 'Tuvalu 🇹🇻', position: 182, color: 'green' },
|
||||
{ id: '3b325d46-b8e8-4cb9-9318-53f20baf50bd', value: 'UGANDA', label: 'Uganda 🇺🇬', position: 183, color: 'sky' },
|
||||
{ id: '10f32cd9-0a6b-4829-913d-50f519dcfc3a', value: 'UKRAINE', label: 'Ukraine 🇺🇦', position: 184, color: 'purple' },
|
||||
{ id: '0776427b-0dc1-49ef-b1c8-bb7dbba5a1e9', value: 'UNITED_ARAB_EMIRATES', label: 'UAE 🇦🇪', position: 185, color: 'orange' },
|
||||
{ id: 'edaf21dc-60d4-4550-8b58-2f7a19eedc92', value: 'UNITED_KINGDOM', label: 'UK 🇬🇧', position: 186, color: 'yellow' },
|
||||
{ id: '553004cd-aeed-4025-aa71-574efcade200', value: 'UNITED_STATES', label: 'USA 🇺🇸', position: 187, color: 'red' },
|
||||
{ id: 'f925d857-5cb7-467d-bc65-e5c04ae5a238', value: 'URUGUAY', label: 'Uruguay 🇺🇾', position: 188, color: 'pink' },
|
||||
{ id: '9b50ea41-2b8f-44d1-beeb-f4f4428289c4', value: 'UZBEKISTAN', label: 'Uzbekistan 🇺🇿', position: 189, color: 'gray' },
|
||||
{ id: 'a7a7cbfd-6999-42e8-874e-c2bfbe6ca567', value: 'VANUATU', label: 'Vanuatu 🇻🇺', position: 190, color: 'blue' },
|
||||
{ id: '7de0a670-8f04-444c-86dc-d7be00cc8b4e', value: 'VATICAN', label: 'Vatican 🇻🇦', position: 191, color: 'turquoise' },
|
||||
{ id: '9acc531c-7f17-4b67-804a-035c3c873cce', value: 'VENEZUELA', label: 'Venezuela 🇻🇪', position: 192, color: 'green' },
|
||||
{ id: '540b908e-f6b6-4f70-bb9c-c86dc5becbe7', value: 'VIETNAM', label: 'Vietnam 🇻🇳', position: 193, color: 'sky' },
|
||||
{ id: 'a70a3610-9a9b-4fe0-9e97-10d3e60a3d92', value: 'YEMEN', label: 'Yemen 🇾🇪', position: 194, color: 'purple' },
|
||||
{ id: 'fde30bd3-b2f1-4331-a255-9601259e807e', value: 'ZAMBIA', label: 'Zambia 🇿🇲', position: 195, color: 'orange' },
|
||||
{ id: 'a83060bf-f55b-4efe-9e0c-b856ad5446c4', value: 'ZIMBABWE', label: 'Zimbabwe 🇿🇼', position: 196, color: 'yellow' },
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: '560503de-6330-4c1d-af97-a8dee125f2ad',
|
||||
type: FieldType.MULTI_SELECT,
|
||||
name: 'region',
|
||||
label: 'Region',
|
||||
icon: 'IconWorld',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ id: '735d7a75-c032-48b3-8480-c46e15b7e511', value: 'EUROPE', label: 'Europe', position: 0, color: 'blue' },
|
||||
{ id: '78eb8d0c-841e-48dd-8425-d8546cbcfe25', value: 'US', label: 'US', position: 1, color: 'red' },
|
||||
{ id: 'b0339081-52dd-4101-b7e7-83ed6ad6ca4f', value: 'LATAM', label: 'LATAM', position: 2, color: 'green' },
|
||||
{ id: 'f02bd583-87b8-48d1-97db-9395056c96e2', value: 'MENA', label: 'MENA', position: 3, color: 'orange' },
|
||||
{ id: '5205c25f-a300-4967-b5f3-033c0515a28a', value: 'APAC', label: 'APAC', position: 4, color: 'yellow' },
|
||||
{ id: '7f55cf70-8c81-4f68-83bc-270d215f0a4e', value: 'AFRICA', label: 'Africa', position: 5, color: 'pink' },
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: '243e9808-c070-4163-8603-ded12b03923c',
|
||||
type: FieldType.CURRENCY,
|
||||
name: 'projectBudgetMin',
|
||||
label: 'Project Budget Min',
|
||||
icon: 'IconCoin',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'ced87a97-cb2a-43cb-a6fc-4a1eff2892ba',
|
||||
type: FieldType.CURRENCY,
|
||||
name: 'projectBudgetTypical',
|
||||
label: 'Project Budget Typical',
|
||||
icon: 'IconCoins',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: '6a095709-7620-495f-b6e0-790743e412d5',
|
||||
type: FieldType.CURRENCY,
|
||||
name: 'hourlyRate',
|
||||
label: 'Hourly Rate',
|
||||
icon: 'IconCurrencyDollar',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: '640bbf33-45d7-4174-a862-dbe611ab8d1a',
|
||||
type: FieldType.LINKS,
|
||||
name: 'linkedin',
|
||||
label: 'LinkedIn',
|
||||
icon: 'IconBrandLinkedin',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: '40d730e3-2785-45c8-aa5f-cc724b1b08e0',
|
||||
type: FieldType.LINKS,
|
||||
name: 'profilePicture',
|
||||
label: 'Profile Picture',
|
||||
icon: 'IconPhoto',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'a0000008-0000-4000-8000-000000000008',
|
||||
type: FieldType.LINKS,
|
||||
name: 'calendarLink',
|
||||
label: 'Calendar Link',
|
||||
icon: 'IconCalendar',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'a0000009-0000-4000-8000-000000000009',
|
||||
type: FieldType.TEXT,
|
||||
name: 'introduction',
|
||||
label: 'Introduction',
|
||||
icon: 'IconFileText',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'a0000010-0000-4000-8000-000000000010',
|
||||
type: FieldType.DATE_TIME,
|
||||
name: 'lastMatchAt',
|
||||
label: 'Last Match At',
|
||||
icon: 'IconClock',
|
||||
isNullable: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineRole } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
// PLACEHOLDER role for external partners. Twenty has no row-level filtering yet,
|
||||
// so anyone assigned this role currently sees every record — do not hand out
|
||||
// until RLP ships and we can scope to records owned by the assigned user.
|
||||
export default defineRole({
|
||||
universalIdentifier: PARTNER_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Partner',
|
||||
description:
|
||||
'PLACEHOLDER. External partner self-service role. Sees ALL Partner/Opportunity records today because Twenty does not yet support row-level record filtering. When RLP ships, scope these permissions to records owned by the assigned user. DO NOT assign to real external partners until then.',
|
||||
icon: 'IconBuildingStore',
|
||||
canBeAssignedToUsers: true,
|
||||
canUpdateAllSettings: false,
|
||||
canReadAllObjectRecords: false,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
objectPermissions: [
|
||||
{
|
||||
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineRole } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
TWENTY_PARTNER_OPS_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
// Internal role for the Twenty partner-ops team. Scoped strictly to the CRM
|
||||
// objects needed to run the matching workflow — no settings, no destroy.
|
||||
export default defineRole({
|
||||
universalIdentifier: TWENTY_PARTNER_OPS_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Twenty Partner Ops',
|
||||
description:
|
||||
'Internal Twenty teammate role for managing partners and matched deals. Full read/write on Partner, Company, Person, Opportunity. No access to Tasks/Notes/Workflows. No settings access.',
|
||||
icon: 'IconUsersGroup',
|
||||
canBeAssignedToUsers: true,
|
||||
canUpdateAllSettings: false,
|
||||
canReadAllObjectRecords: false,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
objectPermissions: [
|
||||
{
|
||||
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,518 @@
|
||||
// Import partners, opportunities and partner quotes FROM the TFT workspace INTO
|
||||
// this (local) workspace. Idempotent: re-running upserts by natural key
|
||||
// (partner=slug, opportunity=tftOpportunityId, quote=name).
|
||||
//
|
||||
// TFT (source) is read over RAW GraphQL fetch: its custom fields/filters are not
|
||||
// in the SDK's generated (local) genql schema, so CoreApiClient cannot build
|
||||
// queries for them. The local (target) workspace is written via CoreApiClient,
|
||||
// exactly like seed.ts. Two separate credential sets, no collision:
|
||||
// TFT -> TFT_API_URL / TFT_API_KEY (raw fetch)
|
||||
// local-> TWENTY_PARTNERS_API_URL / TWENTY_PARTNERS_API_KEY (CoreApiClient)
|
||||
//
|
||||
// The local server rate-limits API calls (~100 / 60s), so existence checks are
|
||||
// batched into one `in` query per object (like seed.ts) and writes are paced.
|
||||
//
|
||||
// DRY-RUN BY DEFAULT: reads everything, writes nothing, and reports both what it
|
||||
// WOULD upsert and the distinct TFT SELECT values it saw (flagging any value not
|
||||
// covered by a local field option, which would otherwise fail a real write).
|
||||
// Set IMPORT_APPLY=1 to actually write to the local workspace.
|
||||
//
|
||||
// TFT_API_URL=https://twentyfortwenty.twenty.com TFT_API_KEY=<tft key> \
|
||||
// TWENTY_PARTNERS_API_URL=http://localhost:2020 TWENTY_PARTNERS_API_KEY=<local key> \
|
||||
// [IMPORT_APPLY=1] \
|
||||
// tsx src/scripts/import-from-tft.ts
|
||||
|
||||
import { config } from 'dotenv';
|
||||
config({ path: process.env.ENV_FILE ?? '.env.local' });
|
||||
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const requireEnv = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`Missing ${name} env var`);
|
||||
return value;
|
||||
};
|
||||
|
||||
const APPLY = process.env.IMPORT_APPLY === '1';
|
||||
|
||||
// Raw GraphQL against TFT. The generated CoreApiClient is bound to the LOCAL
|
||||
// schema and cannot serialise TFT's custom fields/filters, so read TFT untyped.
|
||||
const tftQuery = async (query: string): Promise<any> => {
|
||||
const url = `${requireEnv('TFT_API_URL').replace(/\/$/, '')}/graphql`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${requireEnv('TFT_API_KEY')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
const json: any = await response.json();
|
||||
if (json.errors?.length) {
|
||||
throw new Error(`TFT query failed: ${JSON.stringify(json.errors)}`);
|
||||
}
|
||||
return json.data;
|
||||
};
|
||||
|
||||
// TFT opportunity.stage -> our matchStatus (the 4 early stages collapse to TO_BE_MATCHED)
|
||||
const STAGE_TO_MATCH_STATUS: Record<string, string> = {
|
||||
INTRODUCED_TO_A_PARTNER: 'INTRODUCED_TO_A_PARTNER',
|
||||
WORKING_WITH_A_PARTNER: 'WORKING_WITH_A_PARTNER',
|
||||
WON: 'WON',
|
||||
LOST: 'LOST',
|
||||
RECONNECT_LATER: 'RECONNECT_LATER',
|
||||
IDENTIFIED: 'TO_BE_MATCHED',
|
||||
MET: 'TO_BE_MATCHED',
|
||||
SOLUTIONING: 'TO_BE_MATCHED',
|
||||
ADVANCED: 'TO_BE_MATCHED',
|
||||
};
|
||||
|
||||
// TFT person.partnerStage -> our Partner.validationStage
|
||||
const PARTNER_STAGE_TO_VALIDATION: Record<string, string> = {
|
||||
APPLICATION: 'APPLICATION',
|
||||
POTENTIAL_PARTNER: 'POTENTIAL',
|
||||
PARTNER: 'VALIDATED',
|
||||
FORMER_PARTNER: 'FORMER',
|
||||
REJECTED: 'REJECTED',
|
||||
};
|
||||
|
||||
// TFT person.partnerTimezone -> our Partner.region (MULTI_SELECT). TFT's value is a
|
||||
// coarse timezone band, not a geography, so each maps to every region it plausibly
|
||||
// covers. OTHER carries no signal -> no region. Region stays empty if unmapped.
|
||||
const TIMEZONE_TO_REGION: Record<string, string[]> = {
|
||||
AMERICAS: ['US', 'LATAM'],
|
||||
EMEA: ['EUROPE', 'MENA', 'AFRICA'],
|
||||
WEST_ASIA: ['MENA', 'APAC'],
|
||||
EAST_ASIA_OCEANIA: ['APAC'],
|
||||
OTHER: [],
|
||||
};
|
||||
|
||||
// Local SELECT option sets, for preflight coverage checks (a TFT value not in
|
||||
// these would fail a real write). Keep in sync with src/objects + src/fields.
|
||||
const LOCAL_OPTIONS: Record<string, Set<string>> = {
|
||||
partnerTier: new Set(['NEW', 'INTERMEDIATE', 'ADVANCED']),
|
||||
partnerScope: new Set(['APPS', 'DATA_MODEL', 'DATA_MIGRATION', 'HOSTING_ENVIRONMENT', 'WORKFLOWS']),
|
||||
typeOfTeam: new Set(['SOLO', 'AGENCY']),
|
||||
hostingType: new Set(['CLOUD', 'SELF_HOSTING']),
|
||||
subscriptionType: new Set(['PRO', 'ORG', 'ENT']),
|
||||
subscriptionFrequency: new Set(['MONTHLY', 'ANNUAL']),
|
||||
quoteStatus: new Set(['WIP', 'INTERVIEW_SCHEDULED', 'UNDER_CUSTOMER_PARTNER_REVIEW', 'APPROVED', 'REJECTED']),
|
||||
};
|
||||
|
||||
const slugify = (s: string): string =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
const edges = (result: any, key: string): any[] =>
|
||||
(result?.[key]?.edges ?? []).map((e: any) => e.node);
|
||||
|
||||
const uniq = (values: (string | undefined | null)[]): string[] =>
|
||||
[...new Set(values.filter((v): v is string => !!v))];
|
||||
|
||||
// Normalize a domain for dedup. Twenty's company domain is a unique key but is
|
||||
// stored with an https:// prefix, while TFT values vary, so compare on a canonical
|
||||
// form (no protocol, no www, no trailing slash, lowercased).
|
||||
const normDomain = (d?: string | null): string | undefined =>
|
||||
d
|
||||
? d.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '') || undefined
|
||||
: undefined;
|
||||
|
||||
// Distinct non-empty values across rows, for the preflight report. Flattens array
|
||||
// fields (e.g. partnerScope, typeCustom) and stringifies, so scalar and
|
||||
// multi-select fields share one path. Derived from the already-fetched source
|
||||
// rows — no per-loop bookkeeping needed.
|
||||
const distinct = <TRow>(rows: TRow[], pick: (row: TRow) => unknown): string[] =>
|
||||
[
|
||||
...new Set(
|
||||
rows.flatMap((row) => {
|
||||
const value = pick(row);
|
||||
return Array.isArray(value) ? value : value != null ? [value] : [];
|
||||
}),
|
||||
),
|
||||
]
|
||||
.map(String)
|
||||
.sort();
|
||||
|
||||
async function main() {
|
||||
console.log(`[import] mode: ${APPLY ? 'APPLY (writing to local)' : 'DRY-RUN (no writes)'}`);
|
||||
const local = new CoreApiClient({
|
||||
url: `${requireEnv('TWENTY_PARTNERS_API_URL').replace(/\/$/, '')}/graphql`,
|
||||
headers: { Authorization: `Bearer ${requireEnv('TWENTY_PARTNERS_API_KEY')}` },
|
||||
});
|
||||
|
||||
// Pace writes to stay under the local server's ~100 req/60s limit.
|
||||
let writes = 0;
|
||||
const pace = async () => {
|
||||
if (APPLY && writes++ > 0) await new Promise((r) => setTimeout(r, 750));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 1. Read all three TFT datasets (raw fetch; not rate-limited by local).
|
||||
// ---------------------------------------------------------------------
|
||||
console.log('[import] fetching TFT people...');
|
||||
const tftPeople = edges(
|
||||
await tftQuery(`query {
|
||||
people(first: 500, filter: { partnerStage: { in: ["APPLICATION","POTENTIAL_PARTNER","PARTNER","FORMER_PARTNER","REJECTED"] } }) {
|
||||
edges { node {
|
||||
id
|
||||
name { firstName lastName }
|
||||
emails { primaryEmail }
|
||||
city jobTitle
|
||||
linkedinLink { primaryLinkUrl }
|
||||
partnerStage partnerTier partnerScope partnerTypeOfTeam partnerTimezone partnerIsAvailable partnerSkills
|
||||
partnerBudgetMinimum { amountMicros currencyCode }
|
||||
partnerBudgetAverage { amountMicros currencyCode }
|
||||
company { id name domainName { primaryLinkUrl } }
|
||||
} }
|
||||
}
|
||||
}`),
|
||||
'people',
|
||||
);
|
||||
console.log('[import] fetching TFT opportunities...');
|
||||
const tftOppsAll = edges(
|
||||
await tftQuery(`query {
|
||||
opportunities(first: 500) {
|
||||
edges { node {
|
||||
id name numberOfSeats useCase hostingType subscriptionType subscriptionFrequence lostReason stage
|
||||
amount { amountMicros currencyCode }
|
||||
closeDate
|
||||
company { id name domainName { primaryLinkUrl } }
|
||||
partner { id }
|
||||
} }
|
||||
}
|
||||
}`),
|
||||
'opportunities',
|
||||
);
|
||||
// Only import opportunities linked to a partner. The rest is TFT's general sales
|
||||
// pipeline (mostly LOST/IDENTIFIED) — noise for a partners app. Every partner
|
||||
// stage (INTRODUCED/WORKING) only ever appears on partner-linked opps anyway.
|
||||
const tftOpps = tftOppsAll.filter((o: any) => o.partner?.id);
|
||||
console.log(`[import] opportunities: ${tftOpps.length} partner-linked of ${tftOppsAll.length} total (skipping ${tftOppsAll.length - tftOpps.length} unlinked)`);
|
||||
console.log('[import] fetching TFT partner content...');
|
||||
const tftContent = edges(
|
||||
await tftQuery(`query {
|
||||
customerContents(first: 500) {
|
||||
edges { node {
|
||||
id name status approvalDate typeCustom
|
||||
interview { primaryLinkUrl }
|
||||
partnerPerson { id }
|
||||
customerCompany { id name domainName { primaryLinkUrl } }
|
||||
customerPerson { id }
|
||||
} }
|
||||
}
|
||||
}`),
|
||||
'customerContents',
|
||||
);
|
||||
// Import all content TYPES (quotes, case studies, logos) but only records that
|
||||
// involve a partner. Customer-only content (no partnerPerson) is noise for the
|
||||
// partners app; a partner-linked case study/quote should show on the partner.
|
||||
const contentRecords = tftContent.filter((c: any) => c.partnerPerson?.id);
|
||||
console.log(`[import] TFT: ${tftPeople.length} partner-people, ${tftOpps.length} opportunities, ${tftContent.length} content records`);
|
||||
console.log(`[import] partner content: ${contentRecords.length} partner-linked of ${tftContent.length} total (skipping ${tftContent.length - contentRecords.length} customer-only)`);
|
||||
|
||||
const personSlug = (p: any): string =>
|
||||
slugify([p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner') || p.id;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 2. Batched existence lookups against local (one `in` query per object).
|
||||
// ---------------------------------------------------------------------
|
||||
console.log('[import] checking existing records in target workspace...');
|
||||
const partnerSlugs = uniq(tftPeople.map(personSlug));
|
||||
const partnerIdBySlug = new Map<string, string>(
|
||||
partnerSlugs.length
|
||||
? edges(
|
||||
await local.query({
|
||||
partners: { __args: { filter: { slug: { in: partnerSlugs } }, first: 500 }, edges: { node: { id: true, slug: true } } },
|
||||
} as any),
|
||||
'partners',
|
||||
).map((n: any) => [n.slug, n.id])
|
||||
: [],
|
||||
);
|
||||
|
||||
// Fetch existing companies and index by BOTH name and normalized domain. Twenty
|
||||
// enforces uniqueness on domain, so dedup must be domain-aware: the same company
|
||||
// can arrive under different names (e.g. "Acme" vs "acme.com") across TFT people,
|
||||
// opps and content, and creating a second one collides on the domain constraint.
|
||||
// Page through ALL companies (not just the first 500): these dedupe maps must
|
||||
// be complete, or upsertCompany would create domain-colliding duplicates for
|
||||
// companies that live beyond the first page in a larger workspace.
|
||||
const existingCompanies: any[] = [];
|
||||
let companiesCursor: string | undefined;
|
||||
for (;;) {
|
||||
const page: any = await local.query({
|
||||
companies: {
|
||||
__args: { filter: {}, first: 200, ...(companiesCursor ? { after: companiesCursor } : {}) },
|
||||
edges: { node: { id: true, name: true, domainName: { primaryLinkUrl: true } } },
|
||||
pageInfo: { hasNextPage: true, endCursor: true },
|
||||
},
|
||||
} as any);
|
||||
existingCompanies.push(...edges(page, 'companies'));
|
||||
if (!page?.companies?.pageInfo?.hasNextPage) break;
|
||||
companiesCursor = page.companies.pageInfo.endCursor;
|
||||
}
|
||||
const companyIdByName = new Map<string, string>(existingCompanies.map((n: any) => [n.name, n.id]));
|
||||
const companyIdByDomain = new Map<string, string>();
|
||||
for (const c of existingCompanies) {
|
||||
const nd = normDomain(c.domainName?.primaryLinkUrl);
|
||||
if (nd && !companyIdByDomain.has(nd)) companyIdByDomain.set(nd, c.id);
|
||||
}
|
||||
|
||||
const oppTftIds = uniq(tftOpps.filter((o: any) => o.name).map((o: any) => o.id));
|
||||
const oppIdByTftId = new Map<string, string>(
|
||||
oppTftIds.length
|
||||
? edges(
|
||||
await local.query({
|
||||
opportunities: { __args: { filter: { tftOpportunityId: { in: oppTftIds } }, first: 500 }, edges: { node: { id: true, tftOpportunityId: true } } },
|
||||
} as any),
|
||||
'opportunities',
|
||||
).map((n: any) => [n.tftOpportunityId, n.id])
|
||||
: [],
|
||||
);
|
||||
|
||||
// Unnamed content upserts by name, so a constant fallback would make every
|
||||
// unnamed record collide on one key (collapsing them on re-run). Key the
|
||||
// fallback on the TFT id so each unnamed record stays distinct.
|
||||
const contentName = (c: any): string => c.name || `Partner content ${c.id}`;
|
||||
const contentNames = uniq(contentRecords.map(contentName));
|
||||
const contentIdByName = new Map<string, string>(
|
||||
contentNames.length
|
||||
? edges(
|
||||
await local.query({
|
||||
partnerContents: { __args: { filter: { name: { in: contentNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
|
||||
} as any),
|
||||
'partnerContents',
|
||||
).map((n: any) => [n.name, n.id])
|
||||
: [],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 3. Upsert. Writes are APPLY-gated and paced; in dry-run we resolve new
|
||||
// ids to synthetic placeholders so relation mapping still exercises.
|
||||
// ---------------------------------------------------------------------
|
||||
const upsertCompany = async (name?: string, domain?: string): Promise<string | undefined> => {
|
||||
if (!name) return undefined;
|
||||
if (companyIdByName.has(name)) return companyIdByName.get(name);
|
||||
const nd = normDomain(domain);
|
||||
// Same company under a different name but same domain — reuse it.
|
||||
if (nd && companyIdByDomain.has(nd)) {
|
||||
const existingId = companyIdByDomain.get(nd) as string;
|
||||
companyIdByName.set(name, existingId);
|
||||
return existingId;
|
||||
}
|
||||
let id = `dry:company:${name}`;
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
try {
|
||||
const created: any = await local.mutation({
|
||||
createCompany: {
|
||||
__args: { data: { name, ...(domain ? { domainName: { primaryLinkUrl: domain } } : {}) } },
|
||||
id: true,
|
||||
},
|
||||
} as any);
|
||||
id = created.createCompany.id;
|
||||
} catch (err) {
|
||||
// Fallback: createCompany failed, almost certainly because the domain
|
||||
// already exists (Twenty enforces a unique domain) on a company we
|
||||
// didn't index. Re-find it and reuse instead of failing the import.
|
||||
// `ilike` is a substring match — and the stored value carries an
|
||||
// https:// prefix so we can't `eq` the normalized form — so it can
|
||||
// return the wrong company ("acme.com" also matches "notacme.com" or
|
||||
// "acme.com.br"). Confirm an exact normalized-domain match before reuse.
|
||||
if (!nd) throw err;
|
||||
await pace();
|
||||
const candidates = edges(
|
||||
await local.query({
|
||||
companies: { __args: { filter: { domainName: { primaryLinkUrl: { ilike: `%${nd}%` } } }, first: 20 }, edges: { node: { id: true, domainName: { primaryLinkUrl: true } } } },
|
||||
} as any),
|
||||
'companies',
|
||||
);
|
||||
const match = candidates.find((c: any) => normDomain(c.domainName?.primaryLinkUrl) === nd);
|
||||
if (!match?.id) throw err;
|
||||
id = match.id;
|
||||
console.log(`[import] company "${name}" reused existing by domain ${nd}`);
|
||||
}
|
||||
}
|
||||
companyIdByName.set(name, id);
|
||||
if (nd) companyIdByDomain.set(nd, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
const budgetCurrency = (amount: any) =>
|
||||
amount?.amountMicros != null ? { amountMicros: amount.amountMicros, currencyCode: amount.currencyCode ?? 'USD' } : undefined;
|
||||
|
||||
// Generic create/update dispatch shared by partners, opportunities and content.
|
||||
// Owns pacing + APPLY-gating + the create-vs-update branch, so each loop below
|
||||
// only builds its `data`. genql keys a mutation by the object name, so the
|
||||
// create<Object>/update<Object> names are derived from one argument. Companies
|
||||
// keep their own upsert (above) because of the domain-collision fallback.
|
||||
// Returns the row id: the real id on APPLY, a synthetic dry id otherwise so the
|
||||
// relation mapping in dry-run still resolves to a stable placeholder.
|
||||
const upsert = async (
|
||||
object: 'Partner' | 'Opportunity' | 'PartnerContent',
|
||||
existingId: string | undefined,
|
||||
data: Record<string, unknown>,
|
||||
dryKey: string,
|
||||
): Promise<string> => {
|
||||
if (!APPLY) return existingId ?? `dry:${object}:${dryKey}`;
|
||||
await pace();
|
||||
if (existingId) {
|
||||
await local.mutation({ [`update${object}`]: { __args: { id: existingId, data }, id: true } } as any);
|
||||
return existingId;
|
||||
}
|
||||
const created: any = await local.mutation({ [`create${object}`]: { __args: { data }, id: true } } as any);
|
||||
return created[`create${object}`].id;
|
||||
};
|
||||
|
||||
// -- Partners (upsert by slug) --
|
||||
console.log(`[import] upserting ${tftPeople.length} partners...`);
|
||||
const localPartnerIdByTftPersonId = new Map<string, string>();
|
||||
let partnersCreated = 0;
|
||||
let partnersUpdated = 0;
|
||||
let partnersDone = 0;
|
||||
for (const p of tftPeople) {
|
||||
const slug = personSlug(p);
|
||||
const companyId = await upsertCompany(p.company?.name, p.company?.domainName?.primaryLinkUrl);
|
||||
// Timezone band -> geographic region(s). Unmapped/OTHER -> no region.
|
||||
const region = TIMEZONE_TO_REGION[p.partnerTimezone] ?? [];
|
||||
// A partner scoped for hosting is, by definition, a self-host expert.
|
||||
const scope = Array.isArray(p.partnerScope) ? p.partnerScope : [];
|
||||
const deploymentExpertise = scope.includes('HOSTING_ENVIRONMENT') ? ['SELF_HOST'] : [];
|
||||
const data: Record<string, unknown> = {
|
||||
name: [p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner',
|
||||
slug,
|
||||
validationStage: PARTNER_STAGE_TO_VALIDATION[p.partnerStage] ?? 'APPLICATION',
|
||||
availability: p.partnerIsAvailable ? 'AVAILABLE' : 'UNAVAILABLE',
|
||||
// TFT has no language data; default everyone to English.
|
||||
languagesSpoken: ['ENGLISH'],
|
||||
...(p.partnerTier ? { partnerTier: p.partnerTier } : {}),
|
||||
...(scope.length ? { partnerScope: scope } : {}),
|
||||
...(region.length ? { region } : {}),
|
||||
...(deploymentExpertise.length ? { deploymentExpertise } : {}),
|
||||
...(p.partnerTypeOfTeam ? { typeOfTeam: p.partnerTypeOfTeam } : {}),
|
||||
...(Array.isArray(p.partnerSkills) && p.partnerSkills.length ? { skills: p.partnerSkills } : {}),
|
||||
...(p.city ? { city: p.city } : {}),
|
||||
...(budgetCurrency(p.partnerBudgetMinimum) ? { projectBudgetMin: budgetCurrency(p.partnerBudgetMinimum) } : {}),
|
||||
...(budgetCurrency(p.partnerBudgetAverage) ? { projectBudgetTypical: budgetCurrency(p.partnerBudgetAverage) } : {}),
|
||||
...(p.linkedinLink?.primaryLinkUrl ? { linkedin: { primaryLinkUrl: p.linkedinLink.primaryLinkUrl } } : {}),
|
||||
...(companyId && APPLY ? { companyId } : {}),
|
||||
};
|
||||
|
||||
const existingId = partnerIdBySlug.get(slug);
|
||||
const partnerId = await upsert('Partner', existingId, data, slug);
|
||||
if (existingId) {
|
||||
partnersUpdated++;
|
||||
} else {
|
||||
partnerIdBySlug.set(slug, partnerId);
|
||||
partnersCreated++;
|
||||
}
|
||||
localPartnerIdByTftPersonId.set(p.id, partnerId);
|
||||
partnersDone++;
|
||||
if (partnersDone % 10 === 0 || partnersDone === tftPeople.length)
|
||||
console.log(`[import] partners ${partnersDone}/${tftPeople.length} (created=${partnersCreated} updated=${partnersUpdated})`);
|
||||
}
|
||||
console.log(`[import] partners done: created=${partnersCreated} updated=${partnersUpdated}`);
|
||||
|
||||
// -- Opportunities (upsert by tftOpportunityId) --
|
||||
console.log(`[import] upserting ${tftOpps.length} opportunities...`);
|
||||
let oppsCreated = 0;
|
||||
let oppsUpdated = 0;
|
||||
let oppsPartnerLinked = 0;
|
||||
let oppsDone = 0;
|
||||
for (const o of tftOpps) {
|
||||
if (!o.name) continue;
|
||||
const companyId = await upsertCompany(o.company?.name, o.company?.domainName?.primaryLinkUrl);
|
||||
const partnerId = o.partner?.id ? localPartnerIdByTftPersonId.get(o.partner.id) : undefined;
|
||||
if (partnerId) oppsPartnerLinked++;
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
name: o.name,
|
||||
tftOpportunityId: o.id,
|
||||
matchStatus: STAGE_TO_MATCH_STATUS[o.stage] ?? 'TO_BE_MATCHED',
|
||||
...(o.numberOfSeats != null ? { numberOfSeats: o.numberOfSeats } : {}),
|
||||
...(o.useCase ? { useCase: o.useCase } : {}),
|
||||
...(o.hostingType ? { hostingType: o.hostingType } : {}),
|
||||
...(o.subscriptionType ? { subscriptionType: o.subscriptionType } : {}),
|
||||
...(o.subscriptionFrequence ? { subscriptionFrequency: o.subscriptionFrequence } : {}),
|
||||
...(o.lostReason ? { lostReason: o.lostReason } : {}),
|
||||
...(o.amount?.amountMicros != null ? { amount: { amountMicros: o.amount.amountMicros, currencyCode: o.amount.currencyCode ?? 'USD' } } : {}),
|
||||
...(o.closeDate ? { closeDate: o.closeDate } : {}),
|
||||
...(companyId && APPLY ? { companyId } : {}),
|
||||
...(partnerId && APPLY ? { partnerId } : {}),
|
||||
};
|
||||
|
||||
const existingId = oppIdByTftId.get(o.id);
|
||||
await upsert('Opportunity', existingId, data, o.id);
|
||||
if (existingId) oppsUpdated++;
|
||||
else oppsCreated++;
|
||||
oppsDone++;
|
||||
if (oppsDone % 20 === 0 || oppsDone === tftOpps.length)
|
||||
console.log(`[import] opportunities ${oppsDone}/${tftOpps.length} (created=${oppsCreated} updated=${oppsUpdated})`);
|
||||
}
|
||||
console.log(`[import] opportunities done: created=${oppsCreated} updated=${oppsUpdated} (partner-linked=${oppsPartnerLinked})`);
|
||||
|
||||
// -- Partner content (upsert by name) --
|
||||
console.log(`[import] upserting ${contentRecords.length} content records...`);
|
||||
let contentCreated = 0;
|
||||
let contentUpdated = 0;
|
||||
for (const c of contentRecords) {
|
||||
const name = contentName(c);
|
||||
const partnerId = c.partnerPerson?.id ? localPartnerIdByTftPersonId.get(c.partnerPerson.id) : undefined;
|
||||
const customerCompanyId = await upsertCompany(c.customerCompany?.name, c.customerCompany?.domainName?.primaryLinkUrl);
|
||||
const data: Record<string, unknown> = {
|
||||
name,
|
||||
...(Array.isArray(c.typeCustom) && c.typeCustom.length ? { contentType: c.typeCustom } : {}),
|
||||
...(c.status ? { status: c.status } : {}),
|
||||
...(c.approvalDate ? { approvalDate: c.approvalDate } : {}),
|
||||
...(c.interview?.primaryLinkUrl ? { interview: { primaryLinkUrl: c.interview.primaryLinkUrl } } : {}),
|
||||
...(partnerId && APPLY ? { partnerId } : {}),
|
||||
...(customerCompanyId && APPLY ? { customerCompanyId } : {}),
|
||||
};
|
||||
const existingId = contentIdByName.get(name);
|
||||
await upsert('PartnerContent', existingId, data, name);
|
||||
if (existingId) contentUpdated++;
|
||||
else contentCreated++;
|
||||
}
|
||||
console.log(`[import] partner content created=${contentCreated} updated=${contentUpdated}`);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 4. Preflight: distinct TFT values vs local option coverage. Derived
|
||||
// directly from the fetched source rows (no per-loop bookkeeping).
|
||||
// ---------------------------------------------------------------------
|
||||
const report = (label: string, values: string[], optionKey?: string) => {
|
||||
const allowed = optionKey ? LOCAL_OPTIONS[optionKey] : undefined;
|
||||
const uncovered = allowed ? values.filter((v) => !allowed.has(v)) : [];
|
||||
console.log(
|
||||
`[preflight] ${label}: ${values.join(', ') || '(none)'}` +
|
||||
(uncovered.length ? ` ⚠️ NOT IN LOCAL OPTIONS: ${uncovered.join(', ')}` : ''),
|
||||
);
|
||||
};
|
||||
const partnerStages = distinct(tftPeople, (p: any) => p.partnerStage);
|
||||
const oppStages = distinct(tftOpps, (o: any) => o.stage);
|
||||
const timezones = distinct(tftPeople, (p: any) => p.partnerTimezone);
|
||||
console.log('--- preflight: distinct TFT values seen ---');
|
||||
report('partnerStage (-> validationStage map)', partnerStages);
|
||||
report('partnerTier', distinct(tftPeople, (p: any) => p.partnerTier), 'partnerTier');
|
||||
report('partnerScope', distinct(tftPeople, (p: any) => p.partnerScope), 'partnerScope');
|
||||
report('typeOfTeam', distinct(tftPeople, (p: any) => p.partnerTypeOfTeam), 'typeOfTeam');
|
||||
report('partnerTimezone (-> region map)', timezones);
|
||||
report('opp stage (-> matchStatus map)', oppStages);
|
||||
report('hostingType', distinct(tftOpps, (o: any) => o.hostingType), 'hostingType');
|
||||
report('subscriptionType', distinct(tftOpps, (o: any) => o.subscriptionType), 'subscriptionType');
|
||||
report('subscriptionFrequency', distinct(tftOpps, (o: any) => o.subscriptionFrequence), 'subscriptionFrequency');
|
||||
report('quote status', distinct(contentRecords, (c: any) => c.status), 'quoteStatus');
|
||||
report('customerContent typeCustom', distinct(contentRecords, (c: any) => c.typeCustom));
|
||||
const unmappedStages = partnerStages.filter((s) => !(s in PARTNER_STAGE_TO_VALIDATION));
|
||||
const unmappedOpps = oppStages.filter((s) => !(s in STAGE_TO_MATCH_STATUS));
|
||||
const unmappedTz = timezones.filter((t) => !(t in TIMEZONE_TO_REGION));
|
||||
if (unmappedStages.length) console.log(`[preflight] ⚠️ partnerStage not mapped: ${unmappedStages.join(', ')}`);
|
||||
if (unmappedOpps.length) console.log(`[preflight] ⚠️ opp stage not mapped: ${unmappedOpps.join(', ')}`);
|
||||
if (unmappedTz.length) console.log(`[preflight] ⚠️ partnerTimezone not mapped: ${unmappedTz.join(', ')}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// Hard-destroy soft-deleted records that block re-imports.
|
||||
//
|
||||
// Twenty SOFT-deletes (sets deletedAt); the row stays in the DB and keeps holding
|
||||
// unique constraints (e.g. company domain, partner slug). But normal queries —
|
||||
// including the import's existence checks — exclude soft-deleted rows. So after a
|
||||
// UI "delete" or a partial import that got rolled back, re-running the import hits
|
||||
// "A duplicate entry was detected" on records it cannot see. This purges those
|
||||
// ghosts permanently so idempotent upserts work again.
|
||||
//
|
||||
// Only touches soft-deleted rows (deletedAt IS NOT NULL); active/default data is
|
||||
// left untouched. One bulk destroy per object, so it is not rate-limited.
|
||||
//
|
||||
// yarn purge # against .env.local
|
||||
// yarn purge:prod # against .env.prod
|
||||
//
|
||||
import { config } from 'dotenv';
|
||||
config({ path: process.env.ENV_FILE ?? '.env.local' });
|
||||
|
||||
const requireEnv = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`Missing ${name} env var`);
|
||||
return value;
|
||||
};
|
||||
|
||||
// Objects the import writes to. partners + partnerContents are app custom objects;
|
||||
// companies + opportunities are standard but populated by the import.
|
||||
const OBJECTS = ['companies', 'partners', 'opportunities', 'partnerContents'] as const;
|
||||
|
||||
const gql = async (url: string, key: string, query: string): Promise<any> => {
|
||||
const response = await fetch(`${url.replace(/\/$/, '')}/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
const json: any = await response.json();
|
||||
if (json.errors?.length) throw new Error(JSON.stringify(json.errors));
|
||||
return json.data;
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const url = requireEnv('TWENTY_PARTNERS_API_URL');
|
||||
const key = requireEnv('TWENTY_PARTNERS_API_KEY');
|
||||
console.log(`[purge] target: ${url} — destroying soft-deleted rows only`);
|
||||
|
||||
for (const obj of OBJECTS) {
|
||||
// destroy<Object>s is the bulk variant; capitalise + already-plural names.
|
||||
const mutationName = `destroy${obj.charAt(0).toUpperCase()}${obj.slice(1)}`;
|
||||
const data = await gql(url, key, `mutation { ${mutationName}(filter: { deletedAt: { is: NOT_NULL } }) { id } }`);
|
||||
const destroyed = data[mutationName]?.length ?? 0;
|
||||
console.log(`[purge] ${obj}: destroyed ${destroyed} soft-deleted`);
|
||||
}
|
||||
|
||||
console.log('[purge] done');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
// Single demo seed for the twenty-partners app. Idempotent UPSERT by natural key.
|
||||
// Covers: partners across ALL validationStage values, companies + people,
|
||||
// opportunities across ALL 10 matchStatus values, and partner quotes across ALL
|
||||
// 5 statuses (each linked to a partner + opportunity).
|
||||
//
|
||||
// Run from this app directory, against a running Twenty server with the app
|
||||
// installed (deploy first; do NOT run `yarn test` after — its teardown wipes the app).
|
||||
// Credentials from shell env or a gitignored .env.local (TWENTY_PARTNERS_API_URL/KEY).
|
||||
//
|
||||
// yarn twenty dev --once
|
||||
// tsx src/scripts/seed.ts
|
||||
|
||||
import { config } from 'dotenv';
|
||||
config({ path: process.env.ENV_FILE ?? '.env.local' });
|
||||
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const requireEnv = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`Missing ${name} env var`);
|
||||
return value;
|
||||
};
|
||||
|
||||
const CAL = 'https://calendly.com/placeholder';
|
||||
|
||||
type Partner = {
|
||||
slug: string;
|
||||
name: string;
|
||||
validationStage: string;
|
||||
availability: string;
|
||||
introduction: string;
|
||||
calendarLink: string;
|
||||
deploymentExpertise: string[];
|
||||
region: string[];
|
||||
languagesSpoken: string[];
|
||||
partnerTier: string;
|
||||
partnerScope: string[];
|
||||
typeOfTeam: string;
|
||||
country: string;
|
||||
};
|
||||
|
||||
const PARTNERS: Partner[] = [
|
||||
{ slug: 'nine-dots-ventures', name: 'Nine Dots Ventures', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Boutique CRM implementer for real-estate workflows and WhatsApp automation.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['EUROPE', 'MENA'], languagesSpoken: ['ENGLISH', 'FRENCH', 'ARABIC'], partnerTier: 'ADVANCED', partnerScope: ['DATA_MODEL', 'WORKFLOWS', 'APPS'], typeOfTeam: 'AGENCY', country: 'FRANCE' },
|
||||
{ slug: 'elevate-consulting', name: 'Elevate Consulting', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Revenue-operations partner for B2B SaaS teams scaling seed to Series C.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['US', 'LATAM'], languagesSpoken: ['ENGLISH', 'SPANISH'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MIGRATION', 'DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'UNITED_STATES' },
|
||||
{ slug: 'w3villa-technologies', name: 'W3Villa Technologies', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Engineering-heavy partner running large self-hosted Twenty deployments.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'MENA'], languagesSpoken: ['ENGLISH', 'HINDI'], partnerTier: 'ADVANCED', partnerScope: ['HOSTING_ENVIRONMENT', 'APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'INDIA' },
|
||||
{ slug: 'act-education', name: 'Act Education', validationStage: 'VALIDATED', availability: 'UNAVAILABLE', introduction: 'CRM partner for European education providers; compliance-first self-hosting.', calendarLink: CAL, deploymentExpertise: ['SELF_HOST'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'GERMAN'], partnerTier: 'NEW', partnerScope: ['HOSTING_ENVIRONMENT', 'DATA_MODEL'], typeOfTeam: 'SOLO', country: 'GERMANY' },
|
||||
{ slug: 'netzero-systems', name: 'NetZero Systems', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'LATAM go-to-market partner for climate-tech and renewable-energy companies.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['LATAM', 'US'], languagesSpoken: ['ENGLISH', 'SPANISH', 'PORTUGUESE'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'BRAZIL' },
|
||||
{ slug: 'meridian-craft', name: 'Meridian Craft', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'APAC implementation studio for fintech and logistics; English + Chinese.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'AFRICA'], languagesSpoken: ['ENGLISH', 'CHINESE', 'MALAY'], partnerTier: 'ADVANCED', partnerScope: ['APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'SINGAPORE' },
|
||||
{ slug: 'applicant-studio', name: 'Applicant Studio', validationStage: 'APPLICATION', availability: 'UNAVAILABLE', introduction: 'New applicant; awaiting first review.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'FRENCH'], partnerTier: 'NEW', partnerScope: ['DATA_MODEL'], typeOfTeam: 'SOLO', country: 'FRANCE' },
|
||||
{ slug: 'rising-crm', name: 'Rising CRM', validationStage: 'POTENTIAL', availability: 'AVAILABLE', introduction: 'Promising applicant in evaluation.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['US'], languagesSpoken: ['ENGLISH'], partnerTier: 'NEW', partnerScope: ['WORKFLOWS', 'APPS'], typeOfTeam: 'AGENCY', country: 'UNITED_STATES' },
|
||||
{ slug: 'legacy-partners', name: 'Legacy Partners', validationStage: 'FORMER', availability: 'UNAVAILABLE', introduction: 'Former partner; no longer active in the program.', calendarLink: CAL, deploymentExpertise: ['SELF_HOST'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'GERMAN'], partnerTier: 'INTERMEDIATE', partnerScope: ['HOSTING_ENVIRONMENT'], typeOfTeam: 'AGENCY', country: 'UNITED_KINGDOM' },
|
||||
{ slug: 'declined-co', name: 'Declined Co', validationStage: 'REJECTED', availability: 'UNAVAILABLE', introduction: 'Application rejected after review.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['MENA'], languagesSpoken: ['ENGLISH', 'ARABIC'], partnerTier: 'NEW', partnerScope: ['APPS'], typeOfTeam: 'SOLO', country: 'UNITED_ARAB_EMIRATES' },
|
||||
];
|
||||
|
||||
const COMPANIES = [
|
||||
{ name: 'Acme Real Estate', domain: 'https://acmerealestate.example' },
|
||||
{ name: 'Helix Bio', domain: 'https://helixbio.example' },
|
||||
{ name: 'Sunrise Logistics', domain: 'https://sunriselogistics.example' },
|
||||
];
|
||||
|
||||
const PERSONS = [
|
||||
{ firstName: 'Camille', lastName: 'Durand', companyName: 'Acme Real Estate', email: 'camille@acmerealestate.example', city: 'Paris' },
|
||||
{ firstName: 'Maya', lastName: 'Patel', companyName: 'Helix Bio', email: 'maya@helixbio.example', city: 'Boston' },
|
||||
{ firstName: 'Wei', lastName: 'Chen', companyName: 'Sunrise Logistics', email: 'wei@sunriselogistics.example', city: 'Singapore' },
|
||||
];
|
||||
|
||||
type Opp = {
|
||||
name: string;
|
||||
companyName: string;
|
||||
matchStatus: string;
|
||||
partnerSlug?: string;
|
||||
numberOfSeats?: number;
|
||||
hostingType?: string;
|
||||
subscriptionType?: string;
|
||||
subscriptionFrequency?: string;
|
||||
};
|
||||
|
||||
// One+ opportunity for every matchStatus value (all 10 covered).
|
||||
const OPPORTUNITIES: Opp[] = [
|
||||
{ name: 'Acme RE — Q3 renewal', companyName: 'Acme Real Estate', matchStatus: 'TO_BE_MATCHED', numberOfSeats: 20, hostingType: 'CLOUD', subscriptionType: 'PRO', subscriptionFrequency: 'ANNUAL' },
|
||||
{ name: 'Helix Bio — investor reporting', companyName: 'Helix Bio', matchStatus: 'MANUAL_MATCH', numberOfSeats: 12, hostingType: 'CLOUD', subscriptionType: 'ORG', subscriptionFrequency: 'MONTHLY' },
|
||||
{ name: 'Helix Bio — pipeline review', companyName: 'Helix Bio', matchStatus: 'AUTO_MATCH', numberOfSeats: 8 },
|
||||
{ name: 'Acme RE — CRM rollout', companyName: 'Acme Real Estate', matchStatus: 'MATCHED', partnerSlug: 'elevate-consulting', numberOfSeats: 30, hostingType: 'CLOUD', subscriptionType: 'ENT', subscriptionFrequency: 'ANNUAL' },
|
||||
{ name: 'Sunrise — APAC fleet CRM', companyName: 'Sunrise Logistics', matchStatus: 'INTRODUCED_TO_A_PARTNER', partnerSlug: 'nine-dots-ventures', numberOfSeats: 50, hostingType: 'SELF_HOSTING', subscriptionType: 'ENT', subscriptionFrequency: 'ANNUAL' },
|
||||
{ name: 'Helix Bio — clinical trials CRM', companyName: 'Helix Bio', matchStatus: 'WORKING_WITH_A_PARTNER', partnerSlug: 'netzero-systems', numberOfSeats: 25, hostingType: 'CLOUD', subscriptionType: 'ORG', subscriptionFrequency: 'MONTHLY' },
|
||||
{ name: 'Helix Bio — self-host evaluation', companyName: 'Helix Bio', matchStatus: 'IMPLEMENTING', partnerSlug: 'meridian-craft', numberOfSeats: 40, hostingType: 'SELF_HOSTING', subscriptionType: 'ENT', subscriptionFrequency: 'ANNUAL' },
|
||||
{ name: 'Sunrise — LATAM expansion', companyName: 'Sunrise Logistics', matchStatus: 'WON', partnerSlug: 'nine-dots-ventures', numberOfSeats: 60, hostingType: 'CLOUD', subscriptionType: 'ENT', subscriptionFrequency: 'ANNUAL' },
|
||||
{ name: 'Acme RE — annual review', companyName: 'Acme Real Estate', matchStatus: 'RECONNECT_LATER', partnerSlug: 'w3villa-technologies', numberOfSeats: 15 },
|
||||
{ name: 'Sunrise — vendor onboarding', companyName: 'Sunrise Logistics', matchStatus: 'LOST', numberOfSeats: 10, hostingType: 'CLOUD', subscriptionType: 'PRO', subscriptionFrequency: 'MONTHLY' },
|
||||
];
|
||||
|
||||
type Quote = { name: string; status: string; partnerSlug: string; contentType: string[] };
|
||||
const QUOTES: Quote[] = [
|
||||
{ name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', contentType: ['PARTNER_QUOTE'] },
|
||||
{ name: 'Helix clinical — NetZero quote', status: 'INTERVIEW_SCHEDULED', partnerSlug: 'netzero-systems', contentType: ['PARTNER_QUOTE'] },
|
||||
{ name: 'Acme rollout — Elevate quote', status: 'UNDER_CUSTOMER_PARTNER_REVIEW', partnerSlug: 'elevate-consulting', contentType: ['PARTNER_QUOTE'] },
|
||||
{ name: 'Sunrise LATAM — Nine Dots quote', status: 'APPROVED', partnerSlug: 'nine-dots-ventures', contentType: ['PARTNER_QUOTE'] },
|
||||
{ name: 'Helix self-host — Meridian quote', status: 'REJECTED', partnerSlug: 'meridian-craft', contentType: ['CASE_STUDY'] },
|
||||
];
|
||||
|
||||
const nodes = (r: any, key: string): any[] => (r?.[key]?.edges ?? []).map((e: any) => e.node);
|
||||
|
||||
async function main() {
|
||||
const client = new CoreApiClient({
|
||||
url: `${requireEnv('TWENTY_PARTNERS_API_URL').replace(/\/$/, '')}/graphql`,
|
||||
headers: { Authorization: `Bearer ${requireEnv('TWENTY_PARTNERS_API_KEY')}` },
|
||||
});
|
||||
|
||||
// -- Partners (upsert by slug) --
|
||||
const existingPartners = nodes(
|
||||
await client.query({ partners: { __args: { filter: { slug: { in: PARTNERS.map((p) => p.slug) } }, first: 100 }, edges: { node: { id: true, slug: true } } } } as any),
|
||||
'partners',
|
||||
);
|
||||
const partnerIdBySlug = new Map<string, string>(existingPartners.map((n: any) => [n.slug, n.id]));
|
||||
for (const p of PARTNERS) {
|
||||
const data = {
|
||||
name: p.name, slug: p.slug, validationStage: p.validationStage, availability: p.availability,
|
||||
introduction: p.introduction, calendarLink: { primaryLinkUrl: p.calendarLink },
|
||||
deploymentExpertise: p.deploymentExpertise, region: p.region, languagesSpoken: p.languagesSpoken,
|
||||
partnerTier: p.partnerTier, partnerScope: p.partnerScope, typeOfTeam: p.typeOfTeam,
|
||||
country: p.country,
|
||||
};
|
||||
const id = partnerIdBySlug.get(p.slug);
|
||||
if (id) {
|
||||
await client.mutation({ updatePartner: { __args: { id, data }, id: true } } as any);
|
||||
} else {
|
||||
const r: any = await client.mutation({ createPartner: { __args: { data }, id: true } } as any);
|
||||
partnerIdBySlug.set(p.slug, r.createPartner.id);
|
||||
}
|
||||
}
|
||||
console.log(`[seed] partners: ${partnerIdBySlug.size}`);
|
||||
|
||||
// -- Companies (upsert by name) --
|
||||
const companyIdByName = new Map<string, string>();
|
||||
for (const c of COMPANIES) {
|
||||
const existing = nodes(await client.query({ companies: { __args: { filter: { name: { eq: c.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'companies');
|
||||
let id = existing[0]?.id;
|
||||
if (!id) {
|
||||
const r: any = await client.mutation({ createCompany: { __args: { data: { name: c.name, domainName: { primaryLinkUrl: c.domain } } }, id: true } } as any);
|
||||
id = r.createCompany.id;
|
||||
}
|
||||
companyIdByName.set(c.name, id);
|
||||
}
|
||||
|
||||
// -- People (upsert by firstName+lastName) --
|
||||
for (const person of PERSONS) {
|
||||
const existing = nodes(await client.query({ people: { __args: { filter: { name: { firstName: { eq: person.firstName } } }, first: 10 }, edges: { node: { id: true, name: { firstName: true, lastName: true } } } } } as any), 'people');
|
||||
const match = existing.find((n: any) => n.name?.firstName === person.firstName && n.name?.lastName === person.lastName);
|
||||
if (!match) {
|
||||
await client.mutation({ createPerson: { __args: { data: { name: { firstName: person.firstName, lastName: person.lastName }, emails: { primaryEmail: person.email }, city: person.city, companyId: companyIdByName.get(person.companyName) } }, id: true } } as any);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Opportunities (upsert by name) --
|
||||
const oppIdByName = new Map<string, string>();
|
||||
for (const o of OPPORTUNITIES) {
|
||||
const data: Record<string, unknown> = {
|
||||
name: o.name, matchStatus: o.matchStatus, companyId: companyIdByName.get(o.companyName),
|
||||
...(o.partnerSlug ? { partnerId: partnerIdBySlug.get(o.partnerSlug) } : {}),
|
||||
...(o.numberOfSeats != null ? { numberOfSeats: o.numberOfSeats } : {}),
|
||||
...(o.hostingType ? { hostingType: o.hostingType } : {}),
|
||||
...(o.subscriptionType ? { subscriptionType: o.subscriptionType } : {}),
|
||||
...(o.subscriptionFrequency ? { subscriptionFrequency: o.subscriptionFrequency } : {}),
|
||||
};
|
||||
const existing = nodes(await client.query({ opportunities: { __args: { filter: { name: { eq: o.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'opportunities');
|
||||
let id = existing[0]?.id;
|
||||
if (id) {
|
||||
await client.mutation({ updateOpportunity: { __args: { id, data }, id: true } } as any);
|
||||
} else {
|
||||
const r: any = await client.mutation({ createOpportunity: { __args: { data }, id: true } } as any);
|
||||
id = r.createOpportunity.id;
|
||||
}
|
||||
oppIdByName.set(o.name, id);
|
||||
}
|
||||
console.log(`[seed] opportunities: ${oppIdByName.size}`);
|
||||
|
||||
// -- Partner quotes (upsert by name) --
|
||||
let quoteCount = 0;
|
||||
for (const q of QUOTES) {
|
||||
const data = { name: q.name, status: q.status, contentType: q.contentType, partnerId: partnerIdBySlug.get(q.partnerSlug) };
|
||||
const existing = nodes(await client.query({ partnerContents: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerContents');
|
||||
if (existing[0]?.id) {
|
||||
await client.mutation({ updatePartnerContent: { __args: { id: existing[0].id, data }, id: true } } as any);
|
||||
} else {
|
||||
await client.mutation({ createPartnerContent: { __args: { data }, id: true } } as any);
|
||||
}
|
||||
quoteCount++;
|
||||
}
|
||||
console.log(`[seed] partner quotes: ${quoteCount}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
ViewFilterOperand,
|
||||
ViewType,
|
||||
defineView,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
// Opportunities where a partner is engaged (MATCHED or later). Distinct from
|
||||
// Matches overview, which also includes AUTO_MATCH (in-flight, no partner yet).
|
||||
export default defineView({
|
||||
universalIdentifier: ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All matched deals',
|
||||
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
type: ViewType.TABLE,
|
||||
fields: [
|
||||
{ universalIdentifier: '76f6aea5-0e0b-4787-84f0-430d0799e913', fieldMetadataUniversalIdentifier: '20202020-8609-4f65-a2d9-44009eb422b5', position: 0, isVisible: true },
|
||||
{ universalIdentifier: 'd9862d49-eff8-4103-9f48-a193cf8e1de2', fieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER, position: 1, isVisible: true },
|
||||
{ universalIdentifier: '91c42a01-4ec8-4527-b9ac-9bdeb58e7243', fieldMetadataUniversalIdentifier: 'd9eeacaa-2f9e-44cc-b5f6-5e1526256d49', position: 2, isVisible: true },
|
||||
{ universalIdentifier: 'c9689260-86f5-4e19-a86c-7afc95d4d6fe', fieldMetadataUniversalIdentifier: 'cc6b8a59-f860-493f-8b9a-f138c078fbf1', position: 3, isVisible: true },
|
||||
{ universalIdentifier: 'd51c2737-26f8-4f27-b078-7bb0cf58c662', fieldMetadataUniversalIdentifier: 'fcf39b0c-0547-415e-806d-b238131ad7cc', position: 4, isVisible: true },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
universalIdentifier: '71de9b3a-e59b-4baf-99e6-84fe01e037ee',
|
||||
fieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
// See filter-syntax note in Task 8.1.
|
||||
operand: ViewFilterOperand.IS,
|
||||
value: ['MATCHED', 'INTRODUCED_TO_A_PARTNER', 'WORKING_WITH_A_PARTNER', 'IMPLEMENTING', 'WON', 'RECONNECT_LATER', 'LOST'],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
ViewType,
|
||||
defineView,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
// Default Opportunities view replacement. Surfaces matchStatus + partner alongside
|
||||
// the standard Opportunity columns. partnerEligible column was dropped as part of
|
||||
// the match-status redesign.
|
||||
export default defineView({
|
||||
universalIdentifier: ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Opportunities',
|
||||
icon: 'IconTargetArrow',
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
type: ViewType.TABLE,
|
||||
fields: [
|
||||
{ universalIdentifier: '62844317-546c-4b65-a292-917bf0b5bfce', fieldMetadataUniversalIdentifier: '20202020-8609-4f65-a2d9-44009eb422b5', position: 0, isVisible: true },
|
||||
{ universalIdentifier: '295a5b86-0b37-475a-8645-26f7e7a3dd0a', fieldMetadataUniversalIdentifier: '20202020-cbac-457e-b565-adece5fc815f', position: 1, isVisible: true },
|
||||
{ universalIdentifier: 'ce684df4-4456-427c-b33b-38a34368e380', fieldMetadataUniversalIdentifier: '20202020-6f76-477d-8551-28cd65b2b4b9', position: 2, isVisible: true },
|
||||
{ universalIdentifier: '9f72d1ce-7c39-418c-95cb-480d1b176821', fieldMetadataUniversalIdentifier: 'd9eeacaa-2f9e-44cc-b5f6-5e1526256d49', position: 3, isVisible: true },
|
||||
{ universalIdentifier: '5db9ee26-8688-4a5c-9fe8-f76b41d8e80b', fieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER, position: 4, isVisible: true },
|
||||
{ universalIdentifier: '3727d213-e3f5-43c7-ab05-b0fb2f211273', fieldMetadataUniversalIdentifier: '20202020-527e-44d6-b1ac-c4158d307b97', position: 5, isVisible: true },
|
||||
{ universalIdentifier: 'c9ad9056-fd3a-448c-b4dc-e95e0c5d22e9', fieldMetadataUniversalIdentifier: '20202020-a63e-4a62-8e63-42a51828f831', position: 6, isVisible: true },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ViewType, defineView } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Partners',
|
||||
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: ViewType.TABLE,
|
||||
fields: [
|
||||
{ universalIdentifier: '21afcc69-09c5-42eb-a609-26c062de3bd3', fieldMetadataUniversalIdentifier: 'a0000001-0000-4000-8000-000000000001', position: 0, isVisible: true },
|
||||
{ universalIdentifier: '529912f0-38fb-4821-92d3-8a0a68b9f340', fieldMetadataUniversalIdentifier: '2ca9856f-f54a-4326-9ff3-668fd7da0b50', position: 1, isVisible: true },
|
||||
{ universalIdentifier: '68c6b96d-8c3d-4a3e-b4cd-3751d035b085', fieldMetadataUniversalIdentifier: 'a0000004-0000-4000-8000-000000000004', position: 2, isVisible: true },
|
||||
{ universalIdentifier: '8862e4a5-525a-4a0c-8381-93ff0d01ccf0', fieldMetadataUniversalIdentifier: 'a0000007-0000-4000-8000-000000000007', position: 3, isVisible: true },
|
||||
{ universalIdentifier: '4ebe0b9d-0c2d-4416-b187-150b02473a01', fieldMetadataUniversalIdentifier: 'a0000010-0000-4000-8000-000000000010', position: 4, isVisible: true },
|
||||
{ universalIdentifier: '52408b5f-5e13-4e3c-af2d-ce50033ec126', fieldMetadataUniversalIdentifier: '560503de-6330-4c1d-af97-a8dee125f2ad', position: 5, isVisible: true },
|
||||
{ universalIdentifier: '02cc471b-e9ba-4643-b403-40299d6bbbdd', fieldMetadataUniversalIdentifier: 'a0000005-0000-4000-8000-000000000005', position: 6, isVisible: true },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
ViewType,
|
||||
defineView,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
MATCHES_OVERVIEW_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
// Full matching funnel: a Kanban grouped by matchStatus, no filter (every
|
||||
// opportunity appears in its matchStatus column).
|
||||
export default defineView({
|
||||
universalIdentifier: MATCHES_OVERVIEW_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Matches overview',
|
||||
icon: 'IconLayoutKanban',
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
type: ViewType.KANBAN,
|
||||
mainGroupByFieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: '7a6403c1-7ab9-4c3a-b833-3c028d43140e', fieldMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.name.universalIdentifier, position: 0, isVisible: true },
|
||||
{ universalIdentifier: '2e718b4b-fde8-4839-9cdf-deb09db0e6b6', fieldMetadataUniversalIdentifier: 'd9eeacaa-2f9e-44cc-b5f6-5e1526256d49', position: 1, isVisible: true },
|
||||
{ universalIdentifier: '5ae8805c-3d71-4ccc-a2be-38368f32e3e1', fieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER, position: 2, isVisible: true },
|
||||
{ universalIdentifier: 'cb4e5d2b-7003-4f30-874c-acda310b250c', fieldMetadataUniversalIdentifier: 'fcf39b0c-0547-415e-806d-b238131ad7cc', position: 3, isVisible: true },
|
||||
{ universalIdentifier: '0fc87e70-7aa1-4c85-9152-d0edff8ae8a4', fieldMetadataUniversalIdentifier: 'cc6b8a59-f860-493f-8b9a-f138c078fbf1', position: 4, isVisible: true },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ViewFilterOperand, ViewType, defineView } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
// Partners still in the application stage (not yet validated).
|
||||
export default defineView({
|
||||
universalIdentifier: PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Partner applications',
|
||||
icon: 'IconUserPlus',
|
||||
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: ViewType.TABLE,
|
||||
fields: [
|
||||
{ universalIdentifier: 'b4f505d7-3849-4a74-a27f-1c91733702b5', fieldMetadataUniversalIdentifier: 'a0000001-0000-4000-8000-000000000001', position: 0, isVisible: true },
|
||||
{ universalIdentifier: '8a39e510-e533-4cd7-9b65-5e16b5f773d0', fieldMetadataUniversalIdentifier: '2ca9856f-f54a-4326-9ff3-668fd7da0b50', position: 1, isVisible: true },
|
||||
{ universalIdentifier: 'b92d7fd4-4a24-4333-89fe-5d726634d428', fieldMetadataUniversalIdentifier: 'd4fa6461-37b6-49ee-9181-dd482e74a70b', position: 2, isVisible: true },
|
||||
{ universalIdentifier: '2c34a120-b0f8-421b-9546-6483f1202d9f', fieldMetadataUniversalIdentifier: 'a77d7fa6-c398-47db-af0f-036a5c719f20', position: 3, isVisible: true },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
universalIdentifier: '210deb57-cfca-4086-b9da-ca346fbd3126',
|
||||
fieldMetadataUniversalIdentifier: '2ca9856f-f54a-4326-9ff3-668fd7da0b50',
|
||||
operand: ViewFilterOperand.IS,
|
||||
value: ['APPLICATION'],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ViewType, defineView } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
// Index view for partner content.
|
||||
export default defineView({
|
||||
universalIdentifier: PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Partner content',
|
||||
icon: 'IconQuote',
|
||||
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: ViewType.TABLE,
|
||||
fields: [
|
||||
{ universalIdentifier: 'ad7b3702-b552-4355-afec-1e1e96d9f3df', fieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6', position: 0, isVisible: true },
|
||||
{ universalIdentifier: 'a9bf3eaa-ec27-4a0a-8df2-e18c8f4239a7', fieldMetadataUniversalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692', position: 1, isVisible: true },
|
||||
{ universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 2, isVisible: true },
|
||||
{ universalIdentifier: 'fbd1f953-1dd2-4d0f-a239-148a0688fbff', fieldMetadataUniversalIdentifier: 'b52d263e-423e-40b0-b82c-29214597c005', position: 3, isVisible: true },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ViewFilterOperand, ViewType, defineView } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
// Validated partners — serves both partner intros and the public website list.
|
||||
export default defineView({
|
||||
universalIdentifier: VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Validated partners',
|
||||
icon: 'IconCircleCheck',
|
||||
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: ViewType.TABLE,
|
||||
fields: [
|
||||
{ universalIdentifier: '9463bf58-69d5-4309-bc6e-4835df346246', fieldMetadataUniversalIdentifier: 'a0000001-0000-4000-8000-000000000001', position: 0, isVisible: true },
|
||||
{ universalIdentifier: 'c8fd88c5-ffa9-4944-b5b9-deec3acd3dff', fieldMetadataUniversalIdentifier: 'd4fa6461-37b6-49ee-9181-dd482e74a70b', position: 1, isVisible: true },
|
||||
{ universalIdentifier: '9cb93542-2a91-4e75-a9f8-4a1866445322', fieldMetadataUniversalIdentifier: '500021ad-ca42-4fd3-8727-392dd26b722a', position: 2, isVisible: true },
|
||||
{ universalIdentifier: 'd6df98ac-9c6c-46c2-8784-d4e1d6521f75', fieldMetadataUniversalIdentifier: '560503de-6330-4c1d-af97-a8dee125f2ad', position: 3, isVisible: true },
|
||||
{ universalIdentifier: '375cc871-fdda-42d0-8308-e60174b6d467', fieldMetadataUniversalIdentifier: 'a0000004-0000-4000-8000-000000000004', position: 4, isVisible: true },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
universalIdentifier: 'a3b7e215-693d-420c-b444-37d4109ca535',
|
||||
fieldMetadataUniversalIdentifier: '2ca9856f-f54a-4326-9ff3-668fd7da0b50',
|
||||
operand: ViewFilterOperand.IS,
|
||||
value: ['VALIDATED'],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
ViewFilterOperand,
|
||||
ViewSortDirection,
|
||||
ViewType,
|
||||
defineView,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
WAITING_FOR_MATCH_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
// Ops inbox: opportunities awaiting a human matching decision.
|
||||
// Includes TO_BE_MATCHED (default for new opps) and MANUAL_MATCH
|
||||
// (opps that auto-match couldn't resolve or that ops opted to handle manually).
|
||||
export default defineView({
|
||||
universalIdentifier: WAITING_FOR_MATCH_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Waiting for match',
|
||||
icon: 'IconClockHour4',
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
type: ViewType.KANBAN,
|
||||
mainGroupByFieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: 'd74b5eb3-21ee-48fa-b703-4cfd629738b4', fieldMetadataUniversalIdentifier: '20202020-8609-4f65-a2d9-44009eb422b5', position: 0, isVisible: true },
|
||||
{ universalIdentifier: '50822cf9-c238-4450-ba31-5807011afa65', fieldMetadataUniversalIdentifier: '20202020-cbac-457e-b565-adece5fc815f', position: 1, isVisible: true },
|
||||
{ universalIdentifier: 'f432a71f-3bd0-495e-b5b1-8a78e155dc5a', fieldMetadataUniversalIdentifier: '20202020-d01b-4132-9b32-123456789abc', position: 2, isVisible: true },
|
||||
{ universalIdentifier: '9e47592f-9965-4ee7-9c6a-303477b293f4', fieldMetadataUniversalIdentifier: 'cc6b8a59-f860-493f-8b9a-f138c078fbf1', position: 3, isVisible: true },
|
||||
// matchStatus column (replaces the dropped partnerEligible column)
|
||||
{ universalIdentifier: '909e1eee-077a-4f23-8c9b-4c8027623a78', fieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER, position: 4, isVisible: true },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
universalIdentifier: '93476207-1471-49d9-898c-f8a1d52f468f',
|
||||
fieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
operand: ViewFilterOperand.IS,
|
||||
value: ['TO_BE_MATCHED', 'MANUAL_MATCH'],
|
||||
},
|
||||
],
|
||||
sorts: [
|
||||
{
|
||||
universalIdentifier: 'a7c5a89e-d9d7-4cf6-a6d2-3ad9f12a7b1f',
|
||||
fieldMetadataUniversalIdentifier: '20202020-d01b-4132-9b32-123456789abc',
|
||||
direction: ViewSortDirection.ASC,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"allowUnreachableCode": false,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"target": "es2018",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.integration-test.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["vitest/globals", "node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { loadEnv } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// Integration tests authenticate to a local Twenty server. Credentials are
|
||||
// resolved from the shell env, then a gitignored .env.local in this directory
|
||||
// (see .env.example). No API key is committed; if none is found, global-setup
|
||||
// fails with a clear message.
|
||||
const fileEnv = loadEnv('test', process.cwd(), 'TWENTY_');
|
||||
|
||||
const TWENTY_API_URL =
|
||||
process.env.TWENTY_API_URL ?? fileEnv.TWENTY_API_URL ?? 'http://localhost:2020';
|
||||
const TWENTY_API_KEY = process.env.TWENTY_API_KEY ?? fileEnv.TWENTY_API_KEY;
|
||||
|
||||
// Make env available to globalSetup (runs in the main process); test.env below
|
||||
// covers the worker processes.
|
||||
process.env.TWENTY_API_URL = TWENTY_API_URL;
|
||||
if (TWENTY_API_KEY) {
|
||||
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
projects: ['tsconfig.spec.json'],
|
||||
ignoreConfigErrors: true,
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
testTimeout: 120_000,
|
||||
hookTimeout: 120_000,
|
||||
fileParallelism: false,
|
||||
include: ['src/**/*.integration-test.ts'],
|
||||
globalSetup: ['src/__tests__/global-setup.ts'],
|
||||
env: {
|
||||
TWENTY_API_URL,
|
||||
...(TWENTY_API_KEY ? { TWENTY_API_KEY } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-client-sdk",
|
||||
"version": "2.7.0",
|
||||
"version": "2.9.0",
|
||||
"sideEffects": false,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@@ -42,7 +42,7 @@
|
||||
"dependencies": {
|
||||
"@genql/cli": "^3.0.3",
|
||||
"@genql/runtime": "^2.10.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"graphql": "^16.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -247,7 +247,7 @@ type FieldPermission {
|
||||
type RolePermissionFlag {
|
||||
id: UUID!
|
||||
roleId: UUID!
|
||||
flag: PermissionFlagType!
|
||||
flag: String!
|
||||
}
|
||||
|
||||
type ApiKeyForRole {
|
||||
@@ -523,6 +523,7 @@ type IndexField {
|
||||
id: UUID!
|
||||
fieldMetadataId: UUID!
|
||||
order: Float!
|
||||
subFieldName: String
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
@@ -1764,6 +1765,18 @@ type WorkspaceUrls {
|
||||
subdomainUrl: String!
|
||||
}
|
||||
|
||||
type ApplicationRegistrationVariableDTO {
|
||||
id: UUID!
|
||||
key: String!
|
||||
value: String
|
||||
description: String!
|
||||
isSecret: Boolean!
|
||||
isRequired: Boolean!
|
||||
isFilled: Boolean!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type BillingTrialPeriod {
|
||||
duration: Float!
|
||||
isCreditCardRequired: Boolean!
|
||||
@@ -1968,18 +1981,6 @@ type RotateClientSecret {
|
||||
clientSecret: String!
|
||||
}
|
||||
|
||||
type ApplicationRegistrationVariableDTO {
|
||||
id: UUID!
|
||||
key: String!
|
||||
value: String
|
||||
description: String!
|
||||
isSecret: Boolean!
|
||||
isRequired: Boolean!
|
||||
isFilled: Boolean!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type Relation {
|
||||
type: RelationType!
|
||||
sourceObjectMetadata: Object!
|
||||
@@ -2476,6 +2477,18 @@ type ImapSmtpCaldavConnectionSuccess {
|
||||
connectedAccountId: String!
|
||||
}
|
||||
|
||||
type Webhook {
|
||||
id: UUID!
|
||||
targetUrl: String!
|
||||
operations: [String!]!
|
||||
description: String
|
||||
secret: String!
|
||||
applicationId: UUID!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
deletedAt: DateTime
|
||||
}
|
||||
|
||||
type ToolIndexEntry {
|
||||
name: String!
|
||||
description: String!
|
||||
@@ -2902,18 +2915,6 @@ type MinimalMetadata {
|
||||
collectionHashes: [CollectionHash!]!
|
||||
}
|
||||
|
||||
type Webhook {
|
||||
id: UUID!
|
||||
targetUrl: String!
|
||||
operations: [String!]!
|
||||
description: String
|
||||
secret: String!
|
||||
applicationId: UUID!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
deletedAt: DateTime
|
||||
}
|
||||
|
||||
type Query {
|
||||
navigationMenuItems: [NavigationMenuItem!]!
|
||||
navigationMenuItem(id: UUID!): NavigationMenuItem
|
||||
@@ -2982,6 +2983,8 @@ type Query {
|
||||
getRoles: [Role!]!
|
||||
getToolIndex: [ToolIndexEntry!]!
|
||||
getToolInputSchema(toolName: String!): JSON
|
||||
webhooks: [Webhook!]!
|
||||
webhook(id: UUID!): Webhook
|
||||
field(
|
||||
"""The id of the record to find."""
|
||||
id: UUID!
|
||||
@@ -2999,8 +3002,6 @@ type Query {
|
||||
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
|
||||
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
|
||||
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
|
||||
webhooks: [Webhook!]!
|
||||
webhook(id: UUID!): Webhook
|
||||
minimalMetadata: MinimalMetadata!
|
||||
chatThreads: [AgentChatThread!]!
|
||||
chatThread(id: UUID!): AgentChatThread!
|
||||
@@ -3209,6 +3210,8 @@ type Mutation {
|
||||
createOneObject(input: CreateOneObjectInput!): Object!
|
||||
deleteOneObject(input: DeleteOneObjectInput!): Object!
|
||||
updateOneObject(input: UpdateOneObjectInput!): Object!
|
||||
createOneIndex(input: CreateOneIndexInput!): Index!
|
||||
deleteOneIndex(input: DeleteOneIndexInput!): Index!
|
||||
createOneAgent(input: CreateAgentInput!): Agent!
|
||||
updateOneAgent(input: UpdateAgentInput!): Agent!
|
||||
deleteOneAgent(input: AgentIdInput!): Agent!
|
||||
@@ -3222,6 +3225,9 @@ type Mutation {
|
||||
upsertRowLevelPermissionPredicates(input: UpsertRowLevelPermissionPredicatesInput!): UpsertRowLevelPermissionPredicatesResult!
|
||||
assignRoleToAgent(agentId: UUID!, roleId: UUID!): Boolean!
|
||||
removeRoleFromAgent(agentId: UUID!): Boolean!
|
||||
createWebhook(input: CreateWebhookInput!): Webhook!
|
||||
updateWebhook(input: UpdateWebhookInput!): Webhook!
|
||||
deleteWebhook(id: UUID!): Webhook!
|
||||
createOneField(input: CreateOneFieldMetadataInput!): Field!
|
||||
updateOneField(input: UpdateOneFieldMetadataInput!): Field!
|
||||
deleteOneField(input: DeleteOneFieldInput!): Field!
|
||||
@@ -3238,9 +3244,6 @@ type Mutation {
|
||||
deleteEmailGroupChannel(id: UUID!): MessageChannel!
|
||||
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
|
||||
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
|
||||
createWebhook(input: CreateWebhookInput!): Webhook!
|
||||
updateWebhook(input: UpdateWebhookInput!): Webhook!
|
||||
deleteWebhook(id: UUID!): Webhook!
|
||||
createChatThread: AgentChatThread!
|
||||
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
|
||||
stopAgentChatStream(threadId: UUID!): Boolean!
|
||||
@@ -3294,7 +3297,6 @@ type Mutation {
|
||||
updateWorkspace(data: UpdateWorkspaceInput!): Workspace!
|
||||
deleteCurrentWorkspace: Workspace!
|
||||
checkCustomDomainValidRecords: DomainValidRecords
|
||||
installApplication(appRegistrationId: String!, version: String): Boolean!
|
||||
runWorkspaceMigration(workspaceMigration: WorkspaceMigrationInput!): Boolean!
|
||||
uninstallApplication(universalIdentifier: String!): Boolean!
|
||||
createOIDCIdentityProvider(input: SetupOIDCSsoInput!): SetupSso!
|
||||
@@ -3315,7 +3317,8 @@ type Mutation {
|
||||
deleteEmailingDomain(id: String!): Boolean!
|
||||
verifyEmailingDomain(id: String!): EmailingDomain!
|
||||
createOneAppToken(input: CreateOneAppTokenInput!): AppToken!
|
||||
installMarketplaceApp(universalIdentifier: String!, version: String): Application!
|
||||
installMarketplaceApp(universalIdentifier: String!, version: String): Boolean! @deprecated(reason: "Use installApplication instead")
|
||||
installApplication(universalIdentifier: String!, version: String): Application!
|
||||
syncMarketplaceCatalog: Boolean!
|
||||
createDevelopmentApplication(universalIdentifier: String!, name: String!): DevelopmentApplication!
|
||||
generateApplicationToken(applicationId: UUID!): ApplicationTokenPair!
|
||||
@@ -3392,7 +3395,7 @@ input UpdateViewFilterGroupInput {
|
||||
input CreateViewFilterInput {
|
||||
id: UUID
|
||||
fieldMetadataId: UUID!
|
||||
operand: ViewFilterOperand = CONTAINS
|
||||
operand: ViewFilterOperand
|
||||
value: JSON!
|
||||
viewFilterGroupId: UUID
|
||||
positionInViewFilterGroup: Float
|
||||
@@ -3500,7 +3503,7 @@ input UpsertViewWidgetViewFieldInput {
|
||||
input UpsertViewWidgetViewFilterInput {
|
||||
id: UUID
|
||||
fieldMetadataId: UUID!
|
||||
operand: ViewFilterOperand = CONTAINS
|
||||
operand: ViewFilterOperand
|
||||
value: JSON!
|
||||
viewFilterGroupId: UUID
|
||||
positionInViewFilterGroup: Float
|
||||
@@ -3925,6 +3928,27 @@ input UpdateObjectPayload {
|
||||
isSearchable: Boolean
|
||||
}
|
||||
|
||||
input CreateOneIndexInput {
|
||||
"""The custom index to create"""
|
||||
index: CreateIndexInput!
|
||||
}
|
||||
|
||||
input CreateIndexInput {
|
||||
objectMetadataId: UUID!
|
||||
fields: [CreateIndexFieldInput!]!
|
||||
indexType: IndexType! = BTREE
|
||||
}
|
||||
|
||||
input CreateIndexFieldInput {
|
||||
fieldMetadataId: UUID!
|
||||
subFieldName: String
|
||||
}
|
||||
|
||||
input DeleteOneIndexInput {
|
||||
"""The id of the custom index to delete."""
|
||||
id: UUID!
|
||||
}
|
||||
|
||||
input CreateAgentInput {
|
||||
name: String
|
||||
label: String!
|
||||
@@ -4005,7 +4029,7 @@ input ObjectPermissionInput {
|
||||
|
||||
input UpsertPermissionFlagsInput {
|
||||
roleId: UUID!
|
||||
permissionFlagKeys: [PermissionFlagType!]!
|
||||
permissionFlagKeys: [String!]!
|
||||
}
|
||||
|
||||
input UpsertFieldPermissionsInput {
|
||||
@@ -4047,6 +4071,29 @@ input RowLevelPermissionPredicateGroupInput {
|
||||
positionInRowLevelPermissionPredicateGroup: Float
|
||||
}
|
||||
|
||||
input CreateWebhookInput {
|
||||
id: UUID
|
||||
targetUrl: String!
|
||||
operations: [String!]!
|
||||
description: String
|
||||
secret: String
|
||||
}
|
||||
|
||||
input UpdateWebhookInput {
|
||||
"""The id of the webhook to update"""
|
||||
id: UUID!
|
||||
|
||||
"""The webhook fields to update"""
|
||||
update: UpdateWebhookInputUpdates!
|
||||
}
|
||||
|
||||
input UpdateWebhookInputUpdates {
|
||||
targetUrl: String
|
||||
operations: [String!]
|
||||
description: String
|
||||
secret: String
|
||||
}
|
||||
|
||||
input CreateOneFieldMetadataInput {
|
||||
"""The record to create"""
|
||||
field: CreateFieldInput!
|
||||
@@ -4184,29 +4231,6 @@ input UpdateCalendarChannelInputUpdates {
|
||||
isSyncEnabled: Boolean
|
||||
}
|
||||
|
||||
input CreateWebhookInput {
|
||||
id: UUID
|
||||
targetUrl: String!
|
||||
operations: [String!]!
|
||||
description: String
|
||||
secret: String
|
||||
}
|
||||
|
||||
input UpdateWebhookInput {
|
||||
"""The id of the webhook to update"""
|
||||
id: UUID!
|
||||
|
||||
"""The webhook fields to update"""
|
||||
update: UpdateWebhookInputUpdates!
|
||||
}
|
||||
|
||||
input UpdateWebhookInputUpdates {
|
||||
targetUrl: String
|
||||
operations: [String!]
|
||||
description: String
|
||||
secret: String
|
||||
}
|
||||
|
||||
input FileAttachmentInput {
|
||||
id: UUID!
|
||||
filename: String!
|
||||
|
||||
@@ -193,7 +193,7 @@ export interface FieldPermission {
|
||||
export interface RolePermissionFlag {
|
||||
id: Scalars['UUID']
|
||||
roleId: Scalars['UUID']
|
||||
flag: PermissionFlagType
|
||||
flag: Scalars['String']
|
||||
__typename: 'RolePermissionFlag'
|
||||
}
|
||||
|
||||
@@ -386,6 +386,7 @@ export interface IndexField {
|
||||
id: Scalars['UUID']
|
||||
fieldMetadataId: Scalars['UUID']
|
||||
order: Scalars['Float']
|
||||
subFieldName?: Scalars['String']
|
||||
createdAt: Scalars['DateTime']
|
||||
updatedAt: Scalars['DateTime']
|
||||
__typename: 'IndexField'
|
||||
@@ -1401,6 +1402,19 @@ export interface WorkspaceUrls {
|
||||
__typename: 'WorkspaceUrls'
|
||||
}
|
||||
|
||||
export interface ApplicationRegistrationVariableDTO {
|
||||
id: Scalars['UUID']
|
||||
key: Scalars['String']
|
||||
value?: Scalars['String']
|
||||
description: Scalars['String']
|
||||
isSecret: Scalars['Boolean']
|
||||
isRequired: Scalars['Boolean']
|
||||
isFilled: Scalars['Boolean']
|
||||
createdAt: Scalars['DateTime']
|
||||
updatedAt: Scalars['DateTime']
|
||||
__typename: 'ApplicationRegistrationVariableDTO'
|
||||
}
|
||||
|
||||
export interface BillingTrialPeriod {
|
||||
duration: Scalars['Float']
|
||||
isCreditCardRequired: Scalars['Boolean']
|
||||
@@ -1609,19 +1623,6 @@ export interface RotateClientSecret {
|
||||
__typename: 'RotateClientSecret'
|
||||
}
|
||||
|
||||
export interface ApplicationRegistrationVariableDTO {
|
||||
id: Scalars['UUID']
|
||||
key: Scalars['String']
|
||||
value?: Scalars['String']
|
||||
description: Scalars['String']
|
||||
isSecret: Scalars['Boolean']
|
||||
isRequired: Scalars['Boolean']
|
||||
isFilled: Scalars['Boolean']
|
||||
createdAt: Scalars['DateTime']
|
||||
updatedAt: Scalars['DateTime']
|
||||
__typename: 'ApplicationRegistrationVariableDTO'
|
||||
}
|
||||
|
||||
export interface Relation {
|
||||
type: RelationType
|
||||
sourceObjectMetadata: Object
|
||||
@@ -2160,6 +2161,19 @@ export interface ImapSmtpCaldavConnectionSuccess {
|
||||
__typename: 'ImapSmtpCaldavConnectionSuccess'
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
id: Scalars['UUID']
|
||||
targetUrl: Scalars['String']
|
||||
operations: Scalars['String'][]
|
||||
description?: Scalars['String']
|
||||
secret: Scalars['String']
|
||||
applicationId: Scalars['UUID']
|
||||
createdAt: Scalars['DateTime']
|
||||
updatedAt: Scalars['DateTime']
|
||||
deletedAt?: Scalars['DateTime']
|
||||
__typename: 'Webhook'
|
||||
}
|
||||
|
||||
export interface ToolIndexEntry {
|
||||
name: Scalars['String']
|
||||
description: Scalars['String']
|
||||
@@ -2528,19 +2542,6 @@ export interface MinimalMetadata {
|
||||
__typename: 'MinimalMetadata'
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
id: Scalars['UUID']
|
||||
targetUrl: Scalars['String']
|
||||
operations: Scalars['String'][]
|
||||
description?: Scalars['String']
|
||||
secret: Scalars['String']
|
||||
applicationId: Scalars['UUID']
|
||||
createdAt: Scalars['DateTime']
|
||||
updatedAt: Scalars['DateTime']
|
||||
deletedAt?: Scalars['DateTime']
|
||||
__typename: 'Webhook'
|
||||
}
|
||||
|
||||
export interface Query {
|
||||
navigationMenuItems: NavigationMenuItem[]
|
||||
navigationMenuItem?: NavigationMenuItem
|
||||
@@ -2591,6 +2592,8 @@ export interface Query {
|
||||
getRoles: Role[]
|
||||
getToolIndex: ToolIndexEntry[]
|
||||
getToolInputSchema?: Scalars['JSON']
|
||||
webhooks: Webhook[]
|
||||
webhook?: Webhook
|
||||
field: Field
|
||||
fields: FieldConnection
|
||||
getViewGroups: ViewGroup[]
|
||||
@@ -2599,8 +2602,6 @@ export interface Query {
|
||||
myMessageChannels: MessageChannel[]
|
||||
myConnectedAccounts: ConnectedAccountPublicDTO[]
|
||||
myCalendarChannels: CalendarChannel[]
|
||||
webhooks: Webhook[]
|
||||
webhook?: Webhook
|
||||
minimalMetadata: MinimalMetadata
|
||||
chatThreads: AgentChatThread[]
|
||||
chatThread: AgentChatThread
|
||||
@@ -2742,6 +2743,8 @@ export interface Mutation {
|
||||
createOneObject: Object
|
||||
deleteOneObject: Object
|
||||
updateOneObject: Object
|
||||
createOneIndex: Index
|
||||
deleteOneIndex: Index
|
||||
createOneAgent: Agent
|
||||
updateOneAgent: Agent
|
||||
deleteOneAgent: Agent
|
||||
@@ -2755,6 +2758,9 @@ export interface Mutation {
|
||||
upsertRowLevelPermissionPredicates: UpsertRowLevelPermissionPredicatesResult
|
||||
assignRoleToAgent: Scalars['Boolean']
|
||||
removeRoleFromAgent: Scalars['Boolean']
|
||||
createWebhook: Webhook
|
||||
updateWebhook: Webhook
|
||||
deleteWebhook: Webhook
|
||||
createOneField: Field
|
||||
updateOneField: Field
|
||||
deleteOneField: Field
|
||||
@@ -2771,9 +2777,6 @@ export interface Mutation {
|
||||
deleteEmailGroupChannel: MessageChannel
|
||||
deleteConnectedAccount: ConnectedAccountPublicDTO
|
||||
updateCalendarChannel: CalendarChannel
|
||||
createWebhook: Webhook
|
||||
updateWebhook: Webhook
|
||||
deleteWebhook: Webhook
|
||||
createChatThread: AgentChatThread
|
||||
sendChatMessage: SendChatMessageResult
|
||||
stopAgentChatStream: Scalars['Boolean']
|
||||
@@ -2827,7 +2830,6 @@ export interface Mutation {
|
||||
updateWorkspace: Workspace
|
||||
deleteCurrentWorkspace: Workspace
|
||||
checkCustomDomainValidRecords?: DomainValidRecords
|
||||
installApplication: Scalars['Boolean']
|
||||
runWorkspaceMigration: Scalars['Boolean']
|
||||
uninstallApplication: Scalars['Boolean']
|
||||
createOIDCIdentityProvider: SetupSso
|
||||
@@ -2848,7 +2850,9 @@ export interface Mutation {
|
||||
deleteEmailingDomain: Scalars['Boolean']
|
||||
verifyEmailingDomain: EmailingDomain
|
||||
createOneAppToken: AppToken
|
||||
installMarketplaceApp: Application
|
||||
/** @deprecated Use installApplication instead */
|
||||
installMarketplaceApp: Scalars['Boolean']
|
||||
installApplication: Application
|
||||
syncMarketplaceCatalog: Scalars['Boolean']
|
||||
createDevelopmentApplication: DevelopmentApplication
|
||||
generateApplicationToken: ApplicationTokenPair
|
||||
@@ -3256,6 +3260,7 @@ export interface IndexFieldGenqlSelection{
|
||||
id?: boolean | number
|
||||
fieldMetadataId?: boolean | number
|
||||
order?: boolean | number
|
||||
subFieldName?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
updatedAt?: boolean | number
|
||||
__typename?: boolean | number
|
||||
@@ -4332,6 +4337,20 @@ export interface WorkspaceUrlsGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ApplicationRegistrationVariableDTOGenqlSelection{
|
||||
id?: boolean | number
|
||||
key?: boolean | number
|
||||
value?: boolean | number
|
||||
description?: boolean | number
|
||||
isSecret?: boolean | number
|
||||
isRequired?: boolean | number
|
||||
isFilled?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
updatedAt?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface BillingTrialPeriodGenqlSelection{
|
||||
duration?: boolean | number
|
||||
isCreditCardRequired?: boolean | number
|
||||
@@ -4553,20 +4572,6 @@ export interface RotateClientSecretGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ApplicationRegistrationVariableDTOGenqlSelection{
|
||||
id?: boolean | number
|
||||
key?: boolean | number
|
||||
value?: boolean | number
|
||||
description?: boolean | number
|
||||
isSecret?: boolean | number
|
||||
isRequired?: boolean | number
|
||||
isFilled?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
updatedAt?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface RelationGenqlSelection{
|
||||
type?: boolean | number
|
||||
sourceObjectMetadata?: ObjectGenqlSelection
|
||||
@@ -5165,6 +5170,20 @@ export interface ImapSmtpCaldavConnectionSuccessGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface WebhookGenqlSelection{
|
||||
id?: boolean | number
|
||||
targetUrl?: boolean | number
|
||||
operations?: boolean | number
|
||||
description?: boolean | number
|
||||
secret?: boolean | number
|
||||
applicationId?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
updatedAt?: boolean | number
|
||||
deletedAt?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ToolIndexEntryGenqlSelection{
|
||||
name?: boolean | number
|
||||
description?: boolean | number
|
||||
@@ -5540,20 +5559,6 @@ export interface MinimalMetadataGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface WebhookGenqlSelection{
|
||||
id?: boolean | number
|
||||
targetUrl?: boolean | number
|
||||
operations?: boolean | number
|
||||
description?: boolean | number
|
||||
secret?: boolean | number
|
||||
applicationId?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
updatedAt?: boolean | number
|
||||
deletedAt?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface QueryGenqlSelection{
|
||||
navigationMenuItems?: NavigationMenuItemGenqlSelection
|
||||
navigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
@@ -5616,6 +5621,8 @@ export interface QueryGenqlSelection{
|
||||
getRoles?: RoleGenqlSelection
|
||||
getToolIndex?: ToolIndexEntryGenqlSelection
|
||||
getToolInputSchema?: { __args: {toolName: Scalars['String']} }
|
||||
webhooks?: WebhookGenqlSelection
|
||||
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
field?: (FieldGenqlSelection & { __args: {
|
||||
/** The id of the record to find. */
|
||||
id: Scalars['UUID']} })
|
||||
@@ -5630,8 +5637,6 @@ export interface QueryGenqlSelection{
|
||||
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
|
||||
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
|
||||
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
|
||||
webhooks?: WebhookGenqlSelection
|
||||
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
minimalMetadata?: MinimalMetadataGenqlSelection
|
||||
chatThreads?: AgentChatThreadGenqlSelection
|
||||
chatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
@@ -5794,6 +5799,8 @@ export interface MutationGenqlSelection{
|
||||
createOneObject?: (ObjectGenqlSelection & { __args: {input: CreateOneObjectInput} })
|
||||
deleteOneObject?: (ObjectGenqlSelection & { __args: {input: DeleteOneObjectInput} })
|
||||
updateOneObject?: (ObjectGenqlSelection & { __args: {input: UpdateOneObjectInput} })
|
||||
createOneIndex?: (IndexGenqlSelection & { __args: {input: CreateOneIndexInput} })
|
||||
deleteOneIndex?: (IndexGenqlSelection & { __args: {input: DeleteOneIndexInput} })
|
||||
createOneAgent?: (AgentGenqlSelection & { __args: {input: CreateAgentInput} })
|
||||
updateOneAgent?: (AgentGenqlSelection & { __args: {input: UpdateAgentInput} })
|
||||
deleteOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
|
||||
@@ -5807,6 +5814,9 @@ export interface MutationGenqlSelection{
|
||||
upsertRowLevelPermissionPredicates?: (UpsertRowLevelPermissionPredicatesResultGenqlSelection & { __args: {input: UpsertRowLevelPermissionPredicatesInput} })
|
||||
assignRoleToAgent?: { __args: {agentId: Scalars['UUID'], roleId: Scalars['UUID']} }
|
||||
removeRoleFromAgent?: { __args: {agentId: Scalars['UUID']} }
|
||||
createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} })
|
||||
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
|
||||
deleteWebhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
createOneField?: (FieldGenqlSelection & { __args: {input: CreateOneFieldMetadataInput} })
|
||||
updateOneField?: (FieldGenqlSelection & { __args: {input: UpdateOneFieldMetadataInput} })
|
||||
deleteOneField?: (FieldGenqlSelection & { __args: {input: DeleteOneFieldInput} })
|
||||
@@ -5823,9 +5833,6 @@ export interface MutationGenqlSelection{
|
||||
deleteEmailGroupChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
|
||||
createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} })
|
||||
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
|
||||
deleteWebhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
createChatThread?: AgentChatThreadGenqlSelection
|
||||
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} })
|
||||
stopAgentChatStream?: { __args: {threadId: Scalars['UUID']} }
|
||||
@@ -5879,7 +5886,6 @@ export interface MutationGenqlSelection{
|
||||
updateWorkspace?: (WorkspaceGenqlSelection & { __args: {data: UpdateWorkspaceInput} })
|
||||
deleteCurrentWorkspace?: WorkspaceGenqlSelection
|
||||
checkCustomDomainValidRecords?: DomainValidRecordsGenqlSelection
|
||||
installApplication?: { __args: {appRegistrationId: Scalars['String'], version?: (Scalars['String'] | null)} }
|
||||
runWorkspaceMigration?: { __args: {workspaceMigration: WorkspaceMigrationInput} }
|
||||
uninstallApplication?: { __args: {universalIdentifier: Scalars['String']} }
|
||||
createOIDCIdentityProvider?: (SetupSsoGenqlSelection & { __args: {input: SetupOIDCSsoInput} })
|
||||
@@ -5900,7 +5906,9 @@ export interface MutationGenqlSelection{
|
||||
deleteEmailingDomain?: { __args: {id: Scalars['String']} }
|
||||
verifyEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {id: Scalars['String']} })
|
||||
createOneAppToken?: (AppTokenGenqlSelection & { __args: {input: CreateOneAppTokenInput} })
|
||||
installMarketplaceApp?: (ApplicationGenqlSelection & { __args: {universalIdentifier: Scalars['String'], version?: (Scalars['String'] | null)} })
|
||||
/** @deprecated Use installApplication instead */
|
||||
installMarketplaceApp?: { __args: {universalIdentifier: Scalars['String'], version?: (Scalars['String'] | null)} }
|
||||
installApplication?: (ApplicationGenqlSelection & { __args: {universalIdentifier: Scalars['String'], version?: (Scalars['String'] | null)} })
|
||||
syncMarketplaceCatalog?: boolean | number
|
||||
createDevelopmentApplication?: (DevelopmentApplicationGenqlSelection & { __args: {universalIdentifier: Scalars['String'], name: Scalars['String']} })
|
||||
generateApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationId: Scalars['UUID']} })
|
||||
@@ -6124,6 +6132,18 @@ id: Scalars['UUID']}
|
||||
|
||||
export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null),isSearchable?: (Scalars['Boolean'] | null)}
|
||||
|
||||
export interface CreateOneIndexInput {
|
||||
/** The custom index to create */
|
||||
index: CreateIndexInput}
|
||||
|
||||
export interface CreateIndexInput {objectMetadataId: Scalars['UUID'],fields: CreateIndexFieldInput[],indexType: IndexType}
|
||||
|
||||
export interface CreateIndexFieldInput {fieldMetadataId: Scalars['UUID'],subFieldName?: (Scalars['String'] | null)}
|
||||
|
||||
export interface DeleteOneIndexInput {
|
||||
/** The id of the custom index to delete. */
|
||||
id: Scalars['UUID']}
|
||||
|
||||
export interface CreateAgentInput {name?: (Scalars['String'] | null),label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),prompt: Scalars['String'],modelId: Scalars['String'],roleId?: (Scalars['UUID'] | null),responseFormat?: (Scalars['JSON'] | null),modelConfiguration?: (Scalars['JSON'] | null),evaluationInputs?: (Scalars['String'][] | null)}
|
||||
|
||||
export interface UpdateAgentInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),prompt?: (Scalars['String'] | null),modelId?: (Scalars['String'] | null),roleId?: (Scalars['UUID'] | null),responseFormat?: (Scalars['JSON'] | null),modelConfiguration?: (Scalars['JSON'] | null),evaluationInputs?: (Scalars['String'][] | null)}
|
||||
@@ -6140,7 +6160,7 @@ export interface UpsertObjectPermissionsInput {roleId: Scalars['UUID'],objectPer
|
||||
|
||||
export interface ObjectPermissionInput {objectMetadataId: Scalars['UUID'],canReadObjectRecords?: (Scalars['Boolean'] | null),canUpdateObjectRecords?: (Scalars['Boolean'] | null),canSoftDeleteObjectRecords?: (Scalars['Boolean'] | null),canDestroyObjectRecords?: (Scalars['Boolean'] | null)}
|
||||
|
||||
export interface UpsertPermissionFlagsInput {roleId: Scalars['UUID'],permissionFlagKeys: PermissionFlagType[]}
|
||||
export interface UpsertPermissionFlagsInput {roleId: Scalars['UUID'],permissionFlagKeys: Scalars['String'][]}
|
||||
|
||||
export interface UpsertFieldPermissionsInput {roleId: Scalars['UUID'],fieldPermissions: FieldPermissionInput[]}
|
||||
|
||||
@@ -6152,6 +6172,16 @@ export interface RowLevelPermissionPredicateInput {id?: (Scalars['UUID'] | null)
|
||||
|
||||
export interface RowLevelPermissionPredicateGroupInput {id?: (Scalars['UUID'] | null),objectMetadataId: Scalars['UUID'],parentRowLevelPermissionPredicateGroupId?: (Scalars['UUID'] | null),logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator,positionInRowLevelPermissionPredicateGroup?: (Scalars['Float'] | null)}
|
||||
|
||||
export interface CreateWebhookInput {id?: (Scalars['UUID'] | null),targetUrl: Scalars['String'],operations: Scalars['String'][],description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
|
||||
|
||||
export interface UpdateWebhookInput {
|
||||
/** The id of the webhook to update */
|
||||
id: Scalars['UUID'],
|
||||
/** The webhook fields to update */
|
||||
update: UpdateWebhookInputUpdates}
|
||||
|
||||
export interface UpdateWebhookInputUpdates {targetUrl?: (Scalars['String'] | null),operations?: (Scalars['String'][] | null),description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
|
||||
|
||||
export interface CreateOneFieldMetadataInput {
|
||||
/** The record to create */
|
||||
field: CreateFieldInput}
|
||||
@@ -6204,16 +6234,6 @@ export interface UpdateCalendarChannelInput {id: Scalars['UUID'],update: UpdateC
|
||||
|
||||
export interface UpdateCalendarChannelInputUpdates {visibility?: (CalendarChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (CalendarChannelContactAutoCreationPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null)}
|
||||
|
||||
export interface CreateWebhookInput {id?: (Scalars['UUID'] | null),targetUrl: Scalars['String'],operations: Scalars['String'][],description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
|
||||
|
||||
export interface UpdateWebhookInput {
|
||||
/** The id of the webhook to update */
|
||||
id: Scalars['UUID'],
|
||||
/** The webhook fields to update */
|
||||
update: UpdateWebhookInputUpdates}
|
||||
|
||||
export interface UpdateWebhookInputUpdates {targetUrl?: (Scalars['String'] | null),operations?: (Scalars['String'][] | null),description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
|
||||
|
||||
export interface FileAttachmentInput {id: Scalars['UUID'],filename: Scalars['String']}
|
||||
|
||||
export interface CreateSkillInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content: Scalars['String']}
|
||||
@@ -7183,6 +7203,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const ApplicationRegistrationVariableDTO_possibleTypes: string[] = ['ApplicationRegistrationVariableDTO']
|
||||
export const isApplicationRegistrationVariableDTO = (obj?: { __typename?: any } | null): obj is ApplicationRegistrationVariableDTO => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationRegistrationVariableDTO"')
|
||||
return ApplicationRegistrationVariableDTO_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const BillingTrialPeriod_possibleTypes: string[] = ['BillingTrialPeriod']
|
||||
export const isBillingTrialPeriod = (obj?: { __typename?: any } | null): obj is BillingTrialPeriod => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingTrialPeriod"')
|
||||
@@ -7367,14 +7395,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const ApplicationRegistrationVariableDTO_possibleTypes: string[] = ['ApplicationRegistrationVariableDTO']
|
||||
export const isApplicationRegistrationVariableDTO = (obj?: { __typename?: any } | null): obj is ApplicationRegistrationVariableDTO => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationRegistrationVariableDTO"')
|
||||
return ApplicationRegistrationVariableDTO_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const Relation_possibleTypes: string[] = ['Relation']
|
||||
export const isRelation = (obj?: { __typename?: any } | null): obj is Relation => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isRelation"')
|
||||
@@ -7935,6 +7955,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const Webhook_possibleTypes: string[] = ['Webhook']
|
||||
export const isWebhook = (obj?: { __typename?: any } | null): obj is Webhook => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isWebhook"')
|
||||
return Webhook_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ToolIndexEntry_possibleTypes: string[] = ['ToolIndexEntry']
|
||||
export const isToolIndexEntry = (obj?: { __typename?: any } | null): obj is ToolIndexEntry => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isToolIndexEntry"')
|
||||
@@ -8199,14 +8227,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const Webhook_possibleTypes: string[] = ['Webhook']
|
||||
export const isWebhook = (obj?: { __typename?: any } | null): obj is Webhook => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isWebhook"')
|
||||
return Webhook_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const Query_possibleTypes: string[] = ['Query']
|
||||
export const isQuery = (obj?: { __typename?: any } | null): obj is Query => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isQuery"')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,10 @@
|
||||
"ignorePatterns": ["node_modules", ".next", "storybook-static"],
|
||||
"rules": {
|
||||
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
|
||||
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
|
||||
"no-console": [
|
||||
"warn",
|
||||
{ "allow": ["group", "groupCollapsed", "groupEnd"] }
|
||||
],
|
||||
"no-control-regex": "off",
|
||||
"no-debugger": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
@@ -13,22 +16,31 @@
|
||||
"no-redeclare": "off",
|
||||
"import/no-duplicates": "error",
|
||||
"typescript/no-redeclare": "error",
|
||||
"typescript/consistent-type-imports": ["error", {
|
||||
"prefer": "type-imports",
|
||||
"fixStyle": "inline-type-imports"
|
||||
}],
|
||||
"typescript/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
"prefer": "type-imports",
|
||||
"fixStyle": "inline-type-imports"
|
||||
}
|
||||
],
|
||||
"typescript/explicit-function-return-type": "off",
|
||||
"typescript/explicit-module-boundary-types": "off",
|
||||
"typescript/no-empty-object-type": ["error", {
|
||||
"allowInterfaces": "with-single-extends"
|
||||
}],
|
||||
"typescript/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
"allowInterfaces": "with-single-extends"
|
||||
}
|
||||
],
|
||||
"typescript/no-empty-function": "off",
|
||||
"typescript/no-explicit-any": "off",
|
||||
"typescript/no-unused-vars": ["warn", {
|
||||
"vars": "all",
|
||||
"varsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"argsIgnorePattern": "^_"
|
||||
}]
|
||||
"typescript/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"vars": "all",
|
||||
"varsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
opacity: 0.85 !important;
|
||||
}
|
||||
|
||||
:is(.dark, [data-theme="dark"]) #topbar-cta-button a,
|
||||
:is(.dark, [data-theme="dark"]) #topbar-cta-button a span {
|
||||
:is(.dark, [data-theme='dark']) #topbar-cta-button a,
|
||||
:is(.dark, [data-theme='dark']) #topbar-cta-button a span {
|
||||
background-color: #ffffff !important;
|
||||
color: #141414 !important;
|
||||
}
|
||||
@@ -35,14 +35,14 @@
|
||||
* We use :has() to scope the rule to only the sidebar group that contains
|
||||
* the developers/introduction link, so other tabs are unaffected.
|
||||
*/
|
||||
div:has(> .sidebar-group a[href="/developers/introduction"]),
|
||||
div:has(> .sidebar-group a[href="/user-guide/introduction"]) {
|
||||
div:has(> .sidebar-group a[href='/developers/introduction']),
|
||||
div:has(> .sidebar-group a[href='/user-guide/introduction']) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Remove the top margin on the group that follows the hidden overview group,
|
||||
so it sits flush at the top of the sidebar without a gap. */
|
||||
div:has(> .sidebar-group a[href="/developers/introduction"]) + div,
|
||||
div:has(> .sidebar-group a[href="/user-guide/introduction"]) + div {
|
||||
div:has(> .sidebar-group a[href='/developers/introduction']) + div,
|
||||
div:has(> .sidebar-group a[href='/user-guide/introduction']) + div {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
@@ -44,8 +44,53 @@ A Twenty app's **data layer** is the data your app *adds* to a workspace — the
|
||||
| **Object** | A new custom record type (e.g. PostCard, Invoice) with its own fields | `defineObject()` |
|
||||
| **Field** | A column on an object. Standalone fields can extend objects you didn't create (e.g. add `loyaltyTier` to Company) | `defineField()` |
|
||||
| **Relation** | A bidirectional link between two objects — both sides declared as fields | `defineField()` with `FieldType.RELATION` |
|
||||
| **Index** | A database index to speed up a recurring query on one of your objects | `defineIndex()` |
|
||||
|
||||
The SDK detects these via AST analysis at build time, so file organization is up to you — the convention is `src/objects/` and `src/fields/`. Stable `universalIdentifier` UUIDs tie everything together across deploys.
|
||||
The SDK detects these via AST analysis at build time, so file organization is up to you — the convention is `src/objects/`, `src/fields/`, and `src/indexes/`. Stable `universalIdentifier` UUIDs tie everything together across deploys.
|
||||
|
||||
## Indexes (optional)
|
||||
|
||||
Apps can ship indexes alongside their objects to keep recurring queries fast. The most common case is a status or foreign-key column that you read frequently.
|
||||
|
||||
```ts src/indexes/post-card-status.index.ts
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Unique indexes
|
||||
|
||||
`defineIndex` accepts `isUnique: true` for both single- and multi-column uniqueness. This is the recommended primitive — `defineField({ isUnique: true })` is deprecated and will be removed in a future release.
|
||||
|
||||
```ts
|
||||
defineIndex({
|
||||
universalIdentifier: '…',
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
|
||||
isUnique: true,
|
||||
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
|
||||
});
|
||||
```
|
||||
|
||||
### Other constraints
|
||||
|
||||
- Partial `WHERE` clauses stay under admin control — apps can't declare them.
|
||||
- Each object is capped at 10 custom indexes (the framework's own indexes don't count).
|
||||
|
||||
Order the `fields` array the way Postgres should use it — leftmost column first, like a phone book. Indexes are not free: every write to the table updates them. Add one only when you have a query that needs it.
|
||||
|
||||
<Note>
|
||||
Looking for **Application Config** or **Roles & Permissions**? Those describe the app itself rather than the data it adds — they live under [Config](/developers/extend/apps/config/overview). Looking for **Connections** (Linear, GitHub, Slack OAuth)? Those exist to be called *from* logic functions and live under [Logic](/developers/extend/apps/logic/connections).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Views
|
||||
description: Ship pre-configured saved views — column order, filters, groups — for objects in your app.
|
||||
icon: "list"
|
||||
icon: 'list'
|
||||
---
|
||||
|
||||
A **view** is a saved configuration for how records of an object are displayed: which fields appear, their order, whether they're visible, and any filters or groups applied. Use `defineView()` to ship pre-configured views with your app — typically a default index view for each custom object you create.
|
||||
@@ -38,6 +38,60 @@ export default defineView({
|
||||
- You can also declare `filters`, `filterGroups`, `groups`, and `fieldGroups` for advanced configurations.
|
||||
- `position` controls ordering when multiple views exist for the same object.
|
||||
|
||||
## Filters
|
||||
|
||||
A view can ship with pre-applied filters. Each filter has three coordinates: the **field** being filtered, the **operand** (how to compare), and the **value** (what to compare against). All three must line up — using an operand that doesn't apply to a field type will be rejected at sync time.
|
||||
|
||||
```ts
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
|
||||
filters: [
|
||||
{
|
||||
universalIdentifier: '...',
|
||||
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
operand: ViewFilterOperand.IS,
|
||||
value: ['ACTIVE'],
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
### Supported operands per field type
|
||||
|
||||
| Field type | Supported operands |
|
||||
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `TEXT`, `EMAILS`, `FULL_NAME`, `ADDRESS`, `LINKS`, `PHONES`, `RAW_JSON`, `FILES`, `ACTOR`, `ARRAY` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `ACTOR.source`, `ACTOR.workspaceMemberId` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `SELECT` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `MULTI_SELECT` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `RELATION` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `NUMBER` | `IS`, `IS_NOT`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `RATING` | `IS`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `CURRENCY`, `CURRENCY.amountMicros` | `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Field types with similar names can use entirely different operands — `SELECT` and `MULTI_SELECT` being a common case.
|
||||
|
||||
### Value shape per operand
|
||||
|
||||
The `value` field is always a JSON-serializable value, but its expected shape depends on the operand:
|
||||
|
||||
| Operand family | Value shape | Example |
|
||||
| ----------------------------------------------------- | ------------------------------ | ------------------------ |
|
||||
| `IS`, `IS_NOT` on `SELECT` | array of option keys (strings) | `['ACTIVE', 'PENDING']` |
|
||||
| `CONTAINS`, `DOES_NOT_CONTAIN` on `MULTI_SELECT` | array of option keys (strings) | `['TAG_A']` |
|
||||
| `IS`, `IS_NOT` on `RELATION` | array of record IDs (uuids) | `['c5a1...']` |
|
||||
| `CONTAINS`, `DOES_NOT_CONTAIN` on text-like fields | string | `'acme'` |
|
||||
| `IS`, `IS_NOT` on `NUMBER` | string (the value) | `'5'` |
|
||||
| `IS` on `RATING` / `UUID` | string (the value) | `'5'` |
|
||||
| `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL` | string (the bound) | `'10'` |
|
||||
| `IS`, `IS_BEFORE`, `IS_AFTER` on `DATE` / `DATE_TIME` | ISO 8601 string | `'2025-01-01T00:00:00Z'` |
|
||||
| `IS_EMPTY`, `IS_NOT_EMPTY` | empty string | `''` |
|
||||
| `IS` on `BOOLEAN` | `'true'` or `'false'` | `'true'` |
|
||||
|
||||
## How views show up in the UI
|
||||
|
||||
A view by itself isn't reachable from the sidebar. To make it appear there, pair it with a [navigation menu item](/developers/extend/apps/layout/navigation-menu-items) of type `VIEW` that points at the view's `universalIdentifier`. That's the canonical pattern: every custom object typically ships a default view + a sidebar entry that opens it.
|
||||
|
||||
@@ -16,10 +16,9 @@ Each key carries a `publicKey` (kept indefinitely so it can verify previously is
|
||||
|
||||
### Rotate the current key
|
||||
|
||||
- **Manual** — **Settings → Admin Panel → Signing keys → Revoke** on the current row. Revoking wipes its encrypted private material and demotes it; the next sign call automatically mints a fresh ES256 keypair as the new current. Tokens signed under any other (non-revoked) `kid` keep verifying until they expire.
|
||||
- **Enterprise (automatic)** — a daily cron (`'15 3 * * *'` UTC) issues a new current key once the existing one has been current for `SIGNING_KEY_ROTATION_DAYS` (default `90`). The previous key is *not* revoked, so tokens signed under it keep verifying. Register it once with `yarn command:prod cron:register:all`.
|
||||
Set `SIGNING_KEY_ROTATION_DAYS` to opt in: a daily cron then issues a new current key once the existing one is older than that threshold. Previous keys are *not* revoked, so tokens signed under them keep verifying. Leave the variable unset to disable auto-rotation.
|
||||
|
||||
<Note>The Enterprise cron and `SIGNING_KEY_ROTATION_DAYS` ship in v2.6+.</Note>
|
||||
<Note>Auto-rotation ships in v2.6+.</Note>
|
||||
|
||||
### Revoke a key (leak / emergency only)
|
||||
|
||||
|
||||
@@ -44,8 +44,53 @@ Die **Datenebene** einer Twenty-App umfasst die Daten, die Ihre App zu einem Wor
|
||||
| **Objekt** | Ein neuer benutzerdefinierter Datensatztyp (z. B. PostCard, Invoice) mit eigenen Feldern | `defineObject()` |
|
||||
| **Feld** | Eine Spalte in einem Objekt. Eigenständige Felder können Objekte erweitern, die Sie nicht erstellt haben (z. B. `loyaltyTier` zu Company hinzufügen) | `defineField()` |
|
||||
| **Beziehung** | Eine bidirektionale Verknüpfung zwischen zwei Objekten – beide Seiten werden als Felder deklariert | `defineField()` mit `FieldType.RELATION` |
|
||||
| **Indizes** | Ein Datenbankindex, um eine wiederkehrende Abfrage für eines Ihrer Objekte zu beschleunigen | `defineIndex()` |
|
||||
|
||||
Das SDK erkennt diese zur Build-Zeit über eine AST-Analyse, sodass die Dateiorganisation Ihnen überlassen ist – die Konvention ist `src/objects/` und `src/fields/`. Stabile `universalIdentifier`-UUIDs verknüpfen alles über Deploys hinweg.
|
||||
Das SDK erkennt diese zur Build-Zeit über eine AST-Analyse, sodass die Dateiorganisation Ihnen überlassen ist – die Konvention ist `src/objects/`, `src/fields/` und `src/indexes/`. Stabile `universalIdentifier`-UUIDs verknüpfen alles über Deploys hinweg.
|
||||
|
||||
## Indizes (optional)
|
||||
|
||||
Apps können Indizes gemeinsam mit ihren Objekten ausliefern, um wiederkehrende Abfragen schnell zu halten. Der häufigste Fall ist eine Status- oder Fremdschlüsselspalte, die Sie häufig lesen.
|
||||
|
||||
```ts src/indexes/post-card-status.index.ts
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Eindeutige Indizes
|
||||
|
||||
`defineIndex` akzeptiert `isUnique: true` sowohl für Einspalten- als auch Mehrspalteneindeutigkeit. Dies ist das empfohlene Primitive – `defineField({ isUnique: true })` ist veraltet und wird in einer zukünftigen Version entfernt.
|
||||
|
||||
```ts
|
||||
defineIndex({
|
||||
universalIdentifier: '…',
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
|
||||
isUnique: true,
|
||||
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
|
||||
});
|
||||
```
|
||||
|
||||
### Andere Einschränkungen
|
||||
|
||||
* Partielle `WHERE`-Klauseln bleiben unter Kontrolle der Administratoren – Apps können sie nicht deklarieren.
|
||||
* Jedes Objekt ist auf 10 benutzerdefinierte Indizes begrenzt (die Indizes des Frameworks selbst werden nicht mitgezählt).
|
||||
|
||||
Ordnen Sie das `fields`-Array so an, wie Postgres es verwenden soll – die ganz linke Spalte zuerst, wie in einem Telefonbuch. Indizes sind nicht kostenlos: Jeder Schreibvorgang in die Tabelle aktualisiert sie. Fügen Sie einen nur dann hinzu, wenn Sie eine Abfrage haben, die ihn benötigt.
|
||||
|
||||
<Note>
|
||||
Suchen Sie nach **Application Config** oder **Roles & Permissions**? Diese beschreiben die App selbst und nicht die Daten, die sie hinzufügt – sie befinden sich unter [Config](/l/de/developers/extend/apps/config/overview). Suchen Sie nach **Connections** (Linear, GitHub, Slack OAuth)? Diese existieren, um *von* Logikfunktionen aufgerufen zu werden, und befinden sich unter [Logic](/l/de/developers/extend/apps/logic/connections).
|
||||
|
||||
@@ -38,6 +38,60 @@ export default defineView({
|
||||
* Für erweiterte Konfigurationen können Sie außerdem `filters`, `filterGroups`, `groups` und `fieldGroups` deklarieren.
|
||||
* `position` steuert die Reihenfolge, wenn mehrere Ansichten für dasselbe Objekt existieren.
|
||||
|
||||
## Filter
|
||||
|
||||
Eine Ansicht kann mit vorab angewendeten Filtern ausgeliefert werden. Jeder Filter hat drei Koordinaten: das **Feld**, das gefiltert wird, der **Operand** (wie verglichen wird) und der **Wert** (womit verglichen wird). Alle drei müssen übereinstimmen — die Verwendung eines Operanden, der nicht auf einen Feldtyp anwendbar ist, wird bei der Synchronisierung zurückgewiesen.
|
||||
|
||||
```ts
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
|
||||
filters: [
|
||||
{
|
||||
universalIdentifier: '...',
|
||||
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
operand: ViewFilterOperand.IS,
|
||||
value: ['ACTIVE'],
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
### Unterstützte Operanden pro Feldtyp
|
||||
|
||||
| Feldtyp | Unterstützte Operanden |
|
||||
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `TEXT`, `EMAILS`, `FULL_NAME`, `ADDRESS`, `LINKS`, `PHONES`, `RAW_JSON`, `FILES`, `ACTOR`, `ARRAY` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `ACTOR.source`, `ACTOR.workspaceMemberId` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `SELECT` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `MULTI_SELECT` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `RELATION` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `NUMBER` | `IS`, `IS_NOT`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `RATING` | `IS`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `CURRENCY`, `CURRENCY.amountMicros` | `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Feldtypen mit ähnlichen Namen können völlig unterschiedliche Operanden verwenden – `SELECT` und `MULTI_SELECT` sind ein häufiges Beispiel.
|
||||
|
||||
### Wertstruktur je Operand
|
||||
|
||||
Das Feld `value` ist immer ein JSON-serialisierbarer Wert, aber die erwartete Struktur hängt vom Operanden ab:
|
||||
|
||||
| Operandenfamilie | Wertstruktur | Beispiel |
|
||||
| -------------------------------------------------------- | ------------------------------------- | ------------------------ |
|
||||
| `IS`, `IS_NOT` bei `SELECT` | Array von Optionsschlüsseln (Strings) | `['ACTIVE', 'PENDING']` |
|
||||
| `CONTAINS`, `DOES_NOT_CONTAIN` bei `MULTI_SELECT` | Array von Optionsschlüsseln (Strings) | `['TAG_A']` |
|
||||
| `IS`, `IS_NOT` bei `RELATION` | Array von Datensatz-IDs (UUIDs) | `['c5a1...']` |
|
||||
| `CONTAINS`, `DOES_NOT_CONTAIN` bei textähnlichen Feldern | Zeichenkette | `'acme'` |
|
||||
| `IS`, `IS_NOT` bei `NUMBER` | String (der Wert) | `'5'` |
|
||||
| `IS` bei `RATING` / `UUID` | String (der Wert) | `'5'` |
|
||||
| `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL` | String (der Grenzwert) | `'10'` |
|
||||
| `IS`, `IS_BEFORE`, `IS_AFTER` bei `DATE` / `DATE_TIME` | ISO-8601-String | `'2025-01-01T00:00:00Z'` |
|
||||
| `IS_EMPTY`, `IS_NOT_EMPTY` | leerer String | `''` |
|
||||
| `IS` bei `BOOLEAN` | `'true'` oder `'false'` | `'true'` |
|
||||
|
||||
## Wie Ansichten in der UI angezeigt werden
|
||||
|
||||
Eine Ansicht für sich ist aus der Seitenleiste nicht erreichbar. Damit sie dort erscheint, verknüpfen Sie sie mit einem [Navigationsmenüeintrag](/l/de/developers/extend/apps/layout/navigation-menu-items) des Typs `VIEW`, der auf den `universalIdentifier` der Ansicht zeigt. Das ist das kanonische Muster: Jedes benutzerdefinierte Objekt liefert typischerweise eine Standardansicht plus einen Eintrag in der Seitenleiste, der sie öffnet.
|
||||
|
||||
@@ -16,10 +16,9 @@ Jeder Schlüssel enthält einen `publicKey` (unbefristet aufbewahrt, damit er zu
|
||||
|
||||
### Aktuellen Schlüssel rotieren
|
||||
|
||||
* **Manuell** — **Settings → Admin Panel → Signing keys → Revoke** in der aktuellen Zeile. Das Widerrufen löscht das verschlüsselte private Material und stuft es herab; der nächste Signieraufruf erstellt automatisch ein neues ES256-Schlüsselpaar als neuen aktuellen Schlüssel. Tokens, die unter einem anderen (nicht widerrufenen) `kid` signiert wurden, werden weiterhin verifiziert, bis sie ablaufen.
|
||||
* **Enterprise (automatisch)** — ein täglicher Cron-Job (`'15 3 * * *'` UTC) erstellt einen neuen aktuellen Schlüssel, sobald der vorhandene Schlüssel seit `SIGNING_KEY_ROTATION_DAYS` (Standard `90`) aktuell ist. Der vorherige Schlüssel wird *nicht* widerrufen, daher werden Tokens, die darunter signiert wurden, weiterhin verifiziert. Registriere ihn einmalig mit `yarn command:prod cron:register:all`.
|
||||
Setzen Sie `SIGNING_KEY_ROTATION_DAYS`, um die Funktion zu aktivieren: Ein täglicher Cronjob erstellt dann einen neuen aktuellen Schlüssel, sobald der bestehende Schlüssel älter als dieser Schwellenwert ist. Frühere Schlüssel werden *nicht* widerrufen, daher werden Tokens, die darunter signiert wurden, weiterhin verifiziert. Lassen Sie die Variable ungesetzt, um die automatische Rotation zu deaktivieren.
|
||||
|
||||
<Note>Der Enterprise-Cron-Job und `SIGNING_KEY_ROTATION_DAYS` sind ab v2.6+ enthalten.</Note>
|
||||
<Note>Die automatische Rotation ist ab Version v2.6 verfügbar.</Note>
|
||||
|
||||
### Einen Schlüssel widerrufen (nur bei Leak / Notfall)
|
||||
|
||||
|
||||
@@ -101,6 +101,10 @@ Machen Sie ein Feld einzigartig, um sicherzustellen, dass sich keine verschieden
|
||||
|
||||
Wenn beim Einstellen der Einzigartigkeit ein Fehler auftritt, überprüfen Sie auf doppelte Werte in Ihren Daten (einschließlich gelöschter Datensätze).
|
||||
|
||||
## Indizes (Erweitert)
|
||||
|
||||
Datenbankindizes werden automatisch verwaltet – eigene hinzuzufügen ist selten erforderlich und kann leicht schiefgehen. Wenn der Erweiterte Modus aktiviert ist, hat jedes Objekt unter `Einstellungen → Datenmodell → <object>` einen Abschnitt **Indizes** für die Fälle, in denen du weißt, dass du einen brauchst.
|
||||
|
||||
## Beste Praktiken zur Feldkonfiguration
|
||||
|
||||
### Benennungskonventionen und Einschränkungen
|
||||
|
||||
@@ -98,6 +98,22 @@ Klicken Sie auf **+ Regel hinzufügen** und wählen Sie ein Objekt aus, um eine
|
||||
| Opportunities → „Datensätze ansehen“ deaktivieren | Praktikant kann das Opportunities-Objekt überhaupt nicht sehen |
|
||||
| People → „Datensätze bearbeiten“ aktivieren | Praktikant kann People-Datensätze bearbeiten (aber keine anderen Objekte) |
|
||||
|
||||
### Berechtigungen auf Zeilenebene
|
||||
|
||||
<Note>
|
||||
Berechtigungen auf Zeilenebene sind ein **Premium-Feature**, das im Tarif **Organization** (Cloud und Self-Hosted) verfügbar ist.
|
||||
</Note>
|
||||
|
||||
Berechtigungen auf Zeilenebene ermöglichen es dir, anhand dynamischer Kriterien einzuschränken, welche einzelnen Datensätze eine Rolle sehen oder bearbeiten kann. Im Gegensatz zu Objektberechtigungen (die für einen gesamten Objekttyp gelten) werden Berechtigungen auf Zeilenebene für jeden Datensatz einzeln ausgewertet.
|
||||
|
||||
**Beispielanwendungsfälle:**
|
||||
|
||||
* Vertriebsmitarbeitende können nur ihre eigenen Opportunities sehen
|
||||
* Manager:innen können alle Datensätze in ihrer Region sehen
|
||||
* Support-Mitarbeitende können nur Tickets sehen, die ihnen zugewiesen sind
|
||||
|
||||
Um Berechtigungen auf Zeilenebene zu konfigurieren, öffne eine Rolle, navigiere zum Tab **Objects** und verwende den Abschnitt **Row-Level**, um Filterbedingungen für ein bestimmtes Objekt zu definieren.
|
||||
|
||||
### Feldberechtigungen
|
||||
|
||||
Innerhalb jeder Regel auf Objektebene können Sie weitergehen und **Berechtigungen auf Feldebene** konfigurieren, um den Zugriff auf bestimmte Felder zu steuern.
|
||||
|
||||
@@ -60,7 +60,7 @@ Für Felder:
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Kann ich den Zugriff auf bestimmte Datensätze einschränken (Berechtigungen auf Zeilenebene)?">
|
||||
Berechtigungen auf Zeilenebene werden bis Q1 2026 im Tarif **Organization** verfügbar sein. Damit können Sie den Zugriff auf bestimmte Datensätze anhand von Kriterien einschränken (z. B. nur die eigenen Verkaufschancen sehen).
|
||||
Berechtigungen auf Zeilenebene sind im Tarif **Organization** verfügbar. Damit können Sie den Zugriff auf bestimmte Datensätze anhand von Kriterien einschränken (z. B. nur die eigenen Verkaufschancen sehen).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Wie mache ich ein Feld für bestimmte Benutzer schreibgeschützt?">
|
||||
|
||||
@@ -44,8 +44,53 @@ A **camada de dados** de um app Twenty é o conjunto de dados que seu app *adici
|
||||
| **Objeto** | Um novo tipo de registro personalizado (por exemplo, PostCard, Invoice) com seus próprios campos | `defineObject()` |
|
||||
| **Campo** | Uma coluna em um objeto. Campos independentes podem estender objetos que você não criou (por exemplo, adicionar `loyaltyTier` ao objeto Company) | `defineField()` |
|
||||
| **Relação** | Um vínculo bidirecional entre dois objetos — ambos os lados declarados como campos | `defineField()` com `FieldType.RELATION` |
|
||||
| **Índice** | Um índice de banco de dados para acelerar uma consulta recorrente em um dos seus objetos | `defineIndex()` |
|
||||
|
||||
O SDK detecta esses elementos por meio de análise de AST em tempo de build, então a organização dos arquivos fica a seu critério — a convenção é `src/objects/` e `src/fields/`. UUIDs `universalIdentifier` estáveis conectam tudo em implantações diferentes.
|
||||
O SDK detecta esses elementos por meio de análise de AST em tempo de build, então a organização dos arquivos fica a seu critério — a convenção é `src/objects/`, `src/fields/` e `src/indexes/`. UUIDs `universalIdentifier` estáveis conectam tudo em implantações diferentes.
|
||||
|
||||
## Índices (Opcional)
|
||||
|
||||
Os apps podem incluir índices junto com seus objetos para manter rápidas as consultas recorrentes. O caso mais comum é uma coluna de status ou de chave estrangeira que você lê com frequência.
|
||||
|
||||
```ts src/indexes/post-card-status.index.ts
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Índices únicos
|
||||
|
||||
`defineIndex` aceita `isUnique: true` tanto para unicidade de uma única coluna quanto de múltiplas colunas. Este é o recurso recomendado — `defineField({ isUnique: true })` está obsoleto e será removido em uma versão futura.
|
||||
|
||||
```ts
|
||||
defineIndex({
|
||||
universalIdentifier: '…',
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
|
||||
isUnique: true,
|
||||
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
|
||||
});
|
||||
```
|
||||
|
||||
### Outras restrições
|
||||
|
||||
* Cláusulas `WHERE` parciais permanecem sob controle do administrador — os apps não podem declará-las.
|
||||
* Cada objeto é limitado a 10 índices personalizados (os índices do próprio framework não contam).
|
||||
|
||||
Ordene o array `fields` da forma como o Postgres deve usá-lo — coluna mais à esquerda primeiro, como em uma lista telefônica. Índices não são gratuitos: cada gravação na tabela os atualiza. Adicione um apenas quando você tiver uma consulta que precise dele.
|
||||
|
||||
<Note>
|
||||
Procurando por **Application Config** ou **Roles & Permissions**? Esses descrevem o próprio app em vez dos dados que ele adiciona — eles ficam em [Config](/l/pt/developers/extend/apps/config/overview). Procurando por **Connections** (Linear, GitHub, Slack OAuth)? Essas existem para serem chamadas *a partir de* funções de lógica e ficam em [Logic](/l/pt/developers/extend/apps/logic/connections).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user