Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e27b5ec62 | |||
| 5751cacb4d | |||
| 64f9e07d5e | |||
| a6aeb10484 | |||
| 2327c5dd30 | |||
| 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 | |||
| 570c57563a | |||
| 4b8c722b41 | |||
| d13cc7c349 | |||
| d192d2d492 | |||
| 7ea652ddf8 | |||
| 9b9c97a049 | |||
| f1b2be041a | |||
| 2963fa9324 | |||
| a6a08439f7 | |||
| 383edd5871 | |||
| 16ad50ea46 | |||
| 74fa26f9e5 | |||
| 7ca2efeb96 | |||
| 224376233b | |||
| a3c92311e3 | |||
| 1ed347bc17 | |||
| b792f7654b | |||
| 42ad3cbd1a | |||
| e1378dd4cf | |||
| 5e1b23a28b | |||
| 7aef406696 | |||
| 923c3beead | |||
| 31842f7714 | |||
| 9988f98577 | |||
| 3c458ce4ca | |||
| b869107a22 | |||
| 4c4dc4cb21 | |||
| 237a943947 | |||
| 6e5e7963b5 |
+38
-6
@@ -1,6 +1,38 @@
|
||||
/.github/ @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
|
||||
/.github/CODEOWNERS @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
|
||||
/.github/workflows/ @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
|
||||
/.github/actions/ @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
|
||||
/.github/dependabot.yml @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
|
||||
/.yarnrc.yml @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
|
||||
# ============================================================
|
||||
# CI/CD & GitHub infrastructure
|
||||
# ============================================================
|
||||
/.github/ @Weiko @charlesBochet @prastoin
|
||||
/.github/CODEOWNERS @Weiko @charlesBochet @prastoin
|
||||
/.github/workflows/ @Weiko @charlesBochet @prastoin
|
||||
/.github/actions/ @Weiko @charlesBochet @prastoin
|
||||
/.github/dependabot.yml @Weiko @charlesBochet @prastoin
|
||||
|
||||
# ============================================================
|
||||
# Package management & dependency lockfiles
|
||||
# ============================================================
|
||||
/.yarnrc.yml @Weiko @charlesBochet @prastoin
|
||||
/.npmrc @Weiko @charlesBochet @prastoin
|
||||
**/package.json @Weiko @charlesBochet @prastoin
|
||||
**/yarn.lock @Weiko @charlesBochet @prastoin
|
||||
|
||||
# ============================================================
|
||||
# twenty-apps: exempt from strict ownership (last match wins)
|
||||
# ============================================================
|
||||
/packages/twenty-apps/
|
||||
|
||||
# ============================================================
|
||||
# Build & tooling configs
|
||||
# ============================================================
|
||||
/nx.json @Weiko @charlesBochet @prastoin
|
||||
|
||||
# ============================================================
|
||||
# Docker (container-level compromise)
|
||||
# ============================================================
|
||||
**/Dockerfile* @Weiko @charlesBochet @prastoin
|
||||
**/docker-compose*.yml @Weiko @charlesBochet @prastoin
|
||||
**/docker-compose*.yaml @Weiko @charlesBochet @prastoin
|
||||
|
||||
# ============================================================
|
||||
# Git hooks (execute on checkout/commit/push)
|
||||
# ============================================================
|
||||
/.husky/** @Weiko @charlesBochet @prastoin
|
||||
|
||||
@@ -50,4 +50,4 @@ runs:
|
||||
- name: Deploy
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}
|
||||
run: yarn twenty deploy --remote target
|
||||
run: yarn twenty app:publish --private --remote target
|
||||
|
||||
@@ -50,4 +50,4 @@ runs:
|
||||
- name: Install
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}
|
||||
run: yarn twenty install --remote target
|
||||
run: yarn twenty app:install --remote target
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
create-twenty-app --version
|
||||
mkdir -p /tmp/e2e-test-workspace
|
||||
cd /tmp/e2e-test-workspace
|
||||
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --workspace-url http://localhost:3000
|
||||
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --url http://localhost:3000
|
||||
|
||||
- name: Install scaffolded app dependencies
|
||||
run: |
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
- name: Authenticate with twenty-server
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
|
||||
npx --no-install twenty remote:add --api-key ${{ env.TWENTY_API_KEY }} --url ${{ env.TWENTY_API_URL }}
|
||||
|
||||
- name: Run scaffolded app integration test (deploys, installs, and verifies the app)
|
||||
run: |
|
||||
|
||||
@@ -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/**"
|
||||
]
|
||||
}
|
||||
@@ -10,4 +10,8 @@ nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 3d
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- twenty-sdk
|
||||
- twenty-client-sdk
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.13.0.cjs
|
||||
|
||||
@@ -63,7 +63,7 @@ export default defineObject({
|
||||
Then ship it to your workspace:
|
||||
|
||||
```bash
|
||||
npx twenty deploy
|
||||
npx twenty app:publish --private
|
||||
```
|
||||
|
||||
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@ The scaffolder will:
|
||||
| `--name <name>` | Set the app name |
|
||||
| `--display-name <displayName>` | Set the display name |
|
||||
| `--description <description>` | Set the description |
|
||||
| `--workspace-url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
|
||||
| `--url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
|
||||
| `--authentication-method <method>` | `oauth` or `apiKey` (default: `apiKey` for local, `oauth` for remote) |
|
||||
|
||||
## Documentation
|
||||
@@ -48,8 +48,8 @@ Full documentation is available at **[docs.twenty.com/developers/extend/apps](ht
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
|
||||
- Auth not working: run `yarn twenty remote add --local` to re-authenticate.
|
||||
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty docker:logs`.
|
||||
- Auth not working: run `yarn twenty remote:add --local` to re-authenticate.
|
||||
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.6.0",
|
||||
"version": "2.7.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -18,11 +18,8 @@ const program = new Command(packageJson.name)
|
||||
.option('-n, --name <name>', 'Application name')
|
||||
.option('-d, --display-name <displayName>', 'Application display name')
|
||||
.option('--description <description>', 'Application description')
|
||||
.option(
|
||||
'--workspace-url <workspaceUrl>',
|
||||
'Twenty workspace URL (default: http://localhost:2020)',
|
||||
)
|
||||
.option('--api-url <apiUrl>', '[deprecated: use --workspace-url]')
|
||||
.option('--url <url>', 'Twenty server URL (default: http://localhost:2020)')
|
||||
.option('--api-url <apiUrl>', '[deprecated: use --url]')
|
||||
.option(
|
||||
'--authentication-method <method>',
|
||||
'Authentication method: oauth or apiKey (default: apiKey for local, oauth for remote)',
|
||||
@@ -35,7 +32,7 @@ const program = new Command(packageJson.name)
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
workspaceUrl?: string;
|
||||
url?: string;
|
||||
apiUrl?: string;
|
||||
authenticationMethod?: AuthenticationMethod;
|
||||
},
|
||||
@@ -68,23 +65,18 @@ const program = new Command(packageJson.name)
|
||||
|
||||
if (options?.apiUrl) {
|
||||
console.warn(
|
||||
chalk.yellow(
|
||||
'Warning: --api-url is deprecated. Use --workspace-url instead.',
|
||||
),
|
||||
chalk.yellow('Warning: --api-url is deprecated. Use --url instead.'),
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceUrl = (options?.workspaceUrl ?? options?.apiUrl)?.replace(
|
||||
/\/+$/,
|
||||
'',
|
||||
);
|
||||
const serverUrl = (options?.url ?? options?.apiUrl)?.replace(/\/+$/, '');
|
||||
|
||||
await new CreateAppCommand().execute({
|
||||
directory,
|
||||
name: options?.name,
|
||||
displayName: options?.displayName,
|
||||
description: options?.description,
|
||||
workspaceUrl,
|
||||
serverUrl,
|
||||
authenticationMethod: options?.authenticationMethod,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -49,19 +49,19 @@
|
||||
|
||||
## Best practice
|
||||
|
||||
It's highly recommended to create new app entities using `yarn twenty add`. These are the options:
|
||||
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
|
||||
|
||||
| Entity type | Command | Generated file |
|
||||
| -------------------- | ------------------------------------ | ------------------------------------- |
|
||||
| Object | `yarn twenty add object` | `src/objects/<name>.ts` |
|
||||
| Field | `yarn twenty add field` | `src/fields/<name>.ts` |
|
||||
| Logic function | `yarn twenty add logicFunction` | `src/logic-functions/<name>.ts` |
|
||||
| Front component | `yarn twenty add frontComponent` | `src/front-components/<name>.tsx` |
|
||||
| Role | `yarn twenty add role` | `src/roles/<name>.ts` |
|
||||
| Skill | `yarn twenty add skill` | `src/skills/<name>.ts` |
|
||||
| Agent | `yarn twenty add agent` | `src/agents/<name>.ts` |
|
||||
| View | `yarn twenty add view` | `src/views/<name>.ts` |
|
||||
| Navigation menu item | `yarn twenty add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
|
||||
| Page layout | `yarn twenty add pageLayout` | `src/page-layouts/<name>.ts` |
|
||||
| Entity type | Command | Generated file |
|
||||
| -------------------- | ---------------------------------------- | ------------------------------------- |
|
||||
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
|
||||
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
|
||||
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
|
||||
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
|
||||
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
|
||||
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
|
||||
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
|
||||
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
|
||||
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
|
||||
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
|
||||
|
||||
This helps automatically generate required IDs etc.
|
||||
|
||||
@@ -11,8 +11,8 @@ Run `yarn twenty help` to list all available commands.
|
||||
## Useful Commands
|
||||
|
||||
- `yarn twenty dev` - Start the development server and sync your app
|
||||
- `yarn twenty server status` - Check the local Twenty server status
|
||||
- `yarn twenty server start` - Start the local Twenty server
|
||||
- `yarn twenty docker:status` - Check the local Twenty server status
|
||||
- `yarn twenty docker:start` - Start the local Twenty server
|
||||
- `yarn test` - Run integration tests
|
||||
|
||||
## Learn More
|
||||
|
||||
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ type CreateAppOptions = {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
workspaceUrl?: string;
|
||||
serverUrl?: string;
|
||||
authenticationMethod?: AuthenticationMethod;
|
||||
};
|
||||
|
||||
@@ -45,9 +45,9 @@ export class CreateAppCommand {
|
||||
const { appName, appDisplayName, appDirectory, appDescription } =
|
||||
this.getAppInfos(options);
|
||||
|
||||
const workspaceUrl = options.workspaceUrl ?? DEV_API_URL;
|
||||
const serverUrl = options.serverUrl ?? DEV_API_URL;
|
||||
|
||||
const skipLocalInstance = workspaceUrl !== DEV_API_URL;
|
||||
const skipLocalInstance = serverUrl !== DEV_API_URL;
|
||||
|
||||
if (!skipLocalInstance && !isDockerInstalled()) {
|
||||
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
|
||||
@@ -118,7 +118,7 @@ export class CreateAppCommand {
|
||||
console.log('');
|
||||
|
||||
let authSucceeded = false;
|
||||
let resolvedWorkspaceUrl = workspaceUrl;
|
||||
let resolvedServerUrl = serverUrl;
|
||||
let serverReady = skipLocalInstance;
|
||||
|
||||
if (!skipLocalInstance) {
|
||||
@@ -126,7 +126,7 @@ export class CreateAppCommand {
|
||||
const serverResult = await this.ensureDockerServer(dockerPullPromise);
|
||||
|
||||
if (isDefined(serverResult.url)) {
|
||||
resolvedWorkspaceUrl = serverResult.url;
|
||||
resolvedServerUrl = serverResult.url;
|
||||
serverReady = true;
|
||||
}
|
||||
}
|
||||
@@ -134,18 +134,16 @@ export class CreateAppCommand {
|
||||
if (serverReady) {
|
||||
this.logNextStep('Authenticating');
|
||||
|
||||
authSucceeded = await this.tryExistingAuth(resolvedWorkspaceUrl);
|
||||
authSucceeded = await this.tryExistingAuth(resolvedServerUrl);
|
||||
|
||||
if (authSucceeded) {
|
||||
this.logDetail('Reusing existing credentials');
|
||||
} else if (authenticationMethod === 'oauth') {
|
||||
this.logDetail('Starting OAuth flow');
|
||||
authSucceeded =
|
||||
await this.authenticateWithOAuth(resolvedWorkspaceUrl);
|
||||
authSucceeded = await this.authenticateWithOAuth(resolvedServerUrl);
|
||||
} else {
|
||||
this.logDetail('Using development API key');
|
||||
authSucceeded =
|
||||
await this.authenticateWithDevKey(resolvedWorkspaceUrl);
|
||||
authSucceeded = await this.authenticateWithDevKey(resolvedServerUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,10 +163,10 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
if (syncSucceeded) {
|
||||
await this.openMainPage(appDirectory, resolvedWorkspaceUrl);
|
||||
await this.openMainPage(appDirectory, resolvedServerUrl);
|
||||
}
|
||||
|
||||
this.logSuccess(appDirectory, resolvedWorkspaceUrl, authSucceeded);
|
||||
this.logSuccess(appDirectory, resolvedServerUrl, authSucceeded);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('\nCreate application failed:'),
|
||||
@@ -315,7 +313,7 @@ export class CreateAppCommand {
|
||||
|
||||
private async openMainPage(
|
||||
appDirectory: string,
|
||||
workspaceUrl: string,
|
||||
serverUrl: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const configService = new ConfigService();
|
||||
@@ -328,7 +326,7 @@ export class CreateAppCommand {
|
||||
|
||||
const [universalIdentifier, frontUrl] = await Promise.all([
|
||||
this.readMainPageLayoutUniversalIdentifier(appDirectory),
|
||||
this.resolveWorkspaceFrontUrl(workspaceUrl, token),
|
||||
this.resolveWorkspaceFrontUrl(serverUrl, token),
|
||||
]);
|
||||
|
||||
if (!universalIdentifier || !frontUrl) {
|
||||
@@ -336,7 +334,7 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
const pageLayoutId = await this.resolvePageLayoutId(
|
||||
workspaceUrl,
|
||||
serverUrl,
|
||||
universalIdentifier,
|
||||
token,
|
||||
);
|
||||
@@ -355,12 +353,12 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
private async resolveWorkspaceFrontUrl(
|
||||
workspaceUrl: string,
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
): Promise<string | null> {
|
||||
const query = `{ currentWorkspace { workspaceUrls { subdomainUrl customUrl } } }`;
|
||||
|
||||
const response = await fetch(`${workspaceUrl}/metadata`, {
|
||||
const response = await fetch(`${serverUrl}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -410,13 +408,13 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
private async resolvePageLayoutId(
|
||||
workspaceUrl: string,
|
||||
serverUrl: string,
|
||||
universalIdentifier: string,
|
||||
token: string,
|
||||
): Promise<string | null> {
|
||||
const query = `{ getPageLayouts { id universalIdentifier } }`;
|
||||
|
||||
const response = await fetch(`${workspaceUrl}/metadata`, {
|
||||
const response = await fetch(`${serverUrl}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -503,7 +501,7 @@ export class CreateAppCommand {
|
||||
});
|
||||
}
|
||||
|
||||
private async tryExistingAuth(workspaceUrl: string): Promise<boolean> {
|
||||
private async tryExistingAuth(serverUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const configService = new ConfigService();
|
||||
const remoteNames = await configService.getRemotes();
|
||||
@@ -511,7 +509,7 @@ export class CreateAppCommand {
|
||||
for (const remoteName of remoteNames) {
|
||||
const remoteConfig = await configService.getConfigForRemote(remoteName);
|
||||
|
||||
if (remoteConfig.apiUrl !== workspaceUrl) {
|
||||
if (remoteConfig.apiUrl !== serverUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -521,7 +519,7 @@ export class CreateAppCommand {
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = await fetch(`${workspaceUrl}/metadata`, {
|
||||
const response = await fetch(`${serverUrl}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -555,11 +553,11 @@ export class CreateAppCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private async authenticateWithDevKey(workspaceUrl: string): Promise<boolean> {
|
||||
private async authenticateWithDevKey(serverUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await authLogin({
|
||||
apiKey: DEV_API_KEY,
|
||||
apiUrl: workspaceUrl,
|
||||
apiUrl: serverUrl,
|
||||
remote: 'local',
|
||||
});
|
||||
|
||||
@@ -574,7 +572,7 @@ export class CreateAppCommand {
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Authentication failed. Run `yarn twenty remote add --local` manually.',
|
||||
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -582,7 +580,7 @@ export class CreateAppCommand {
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Authentication failed. Run `yarn twenty remote add --local` manually.',
|
||||
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -598,21 +596,21 @@ export class CreateAppCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private async authenticateWithOAuth(workspaceUrl: string): Promise<boolean> {
|
||||
private async authenticateWithOAuth(serverUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const remoteName = this.deriveRemoteName(workspaceUrl);
|
||||
const remoteName = this.deriveRemoteName(serverUrl);
|
||||
|
||||
ConfigService.setActiveRemote(remoteName);
|
||||
|
||||
this.logDetail('Opening browser for OAuth...');
|
||||
|
||||
const result = await authLoginOAuth({ apiUrl: workspaceUrl });
|
||||
const result = await authLoginOAuth({ apiUrl: serverUrl });
|
||||
|
||||
if (result.success) {
|
||||
const configService = new ConfigService();
|
||||
|
||||
await configService.setDefaultRemote(remoteName);
|
||||
this.logDetail(`Authenticated via OAuth to ${workspaceUrl}`);
|
||||
this.logDetail(`Authenticated via OAuth to ${serverUrl}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -620,7 +618,7 @@ export class CreateAppCommand {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` OAuth failed: ${result.error.message}\n` +
|
||||
` Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
|
||||
` Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -628,7 +626,7 @@ export class CreateAppCommand {
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` Authentication failed. Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
|
||||
` Authentication failed. Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -638,7 +636,7 @@ export class CreateAppCommand {
|
||||
|
||||
private logSuccess(
|
||||
appDirectory: string,
|
||||
workspaceUrl: string,
|
||||
serverUrl: string,
|
||||
authSucceeded: boolean,
|
||||
): void {
|
||||
const dirName = basename(appDirectory);
|
||||
@@ -656,9 +654,7 @@ export class CreateAppCommand {
|
||||
if (!authSucceeded) {
|
||||
console.log(chalk.white(` ${stepNumber}. Connect to a Twenty instance`));
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
' yarn twenty remote add --api-url <your-instance-url>\n',
|
||||
),
|
||||
chalk.cyan(' yarn twenty remote:add --url <your-instance-url>\n'),
|
||||
);
|
||||
stepNumber++;
|
||||
}
|
||||
@@ -668,7 +664,7 @@ export class CreateAppCommand {
|
||||
stepNumber++;
|
||||
|
||||
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
|
||||
console.log(chalk.cyan(` ${workspaceUrl}\n`));
|
||||
console.log(chalk.cyan(` ${serverUrl}\n`));
|
||||
|
||||
console.log(
|
||||
chalk.gray(
|
||||
|
||||
@@ -28,6 +28,6 @@ export const getDockerInstallInstructions = (): string => {
|
||||
' Then run this command again.',
|
||||
'',
|
||||
' Alternatively, connect to an existing Twenty instance:',
|
||||
' npx create-twenty-app@latest my-twenty-app --workspace-url <your-instance-url>',
|
||||
' npx create-twenty-app@latest my-twenty-app --url <your-instance-url>',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
@@ -93,7 +93,7 @@ yarn install
|
||||
# Register your local Twenty server as a remote (interactive prompt).
|
||||
# When asked for the URL use http://localhost:2021 and paste an API key
|
||||
# from Settings -> Developers in the Twenty UI.
|
||||
yarn twenty remote add
|
||||
yarn twenty remote:add
|
||||
|
||||
# Build, install, and watch for changes.
|
||||
yarn twenty dev
|
||||
@@ -107,8 +107,8 @@ watching `src/`. Edit any file and the change is re-synced within seconds.
|
||||
```bash
|
||||
cd packages/twenty-apps/community/github-connector
|
||||
yarn install
|
||||
yarn twenty remote add # same prompts as above
|
||||
yarn twenty install # builds and installs once
|
||||
yarn twenty remote:add # same prompts as above
|
||||
yarn twenty app:install # builds and installs once
|
||||
```
|
||||
|
||||
## Configure authentication
|
||||
|
||||
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Remotes & Authentication
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote status # Check auth status
|
||||
yarn twenty remote switch # Switch default remote
|
||||
yarn twenty remote list # List all configured remotes
|
||||
yarn twenty remote remove <name> # Remove a remote
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote:status # Check auth status
|
||||
yarn twenty remote:use # Set default remote
|
||||
yarn twenty remote:list # List all configured remotes
|
||||
yarn twenty remote:remove <name> # Remove a remote
|
||||
|
||||
# Application
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty logs # Stream function logs
|
||||
yarn twenty exec # Execute a function with JSON payload
|
||||
yarn twenty uninstall # Uninstall app from workspace
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty dev:function:logs # Stream function logs
|
||||
yarn twenty dev:function:exec # Execute a function with JSON payload
|
||||
yarn twenty app:uninstall # Uninstall app from workspace
|
||||
```
|
||||
|
||||
## Integration Tests
|
||||
|
||||
@@ -13,7 +13,7 @@ beforeAll(async () => {
|
||||
if (!apiUrl || !token) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"scripts": {
|
||||
"dev": "twenty dev",
|
||||
"exec": "twenty exec",
|
||||
"uninstall": "twenty uninstall",
|
||||
"exec": "twenty dev:fn-exec",
|
||||
"uninstall": "twenty app:uninstall",
|
||||
"lint": "oxlint -c .oxlintrc.json .",
|
||||
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
|
||||
},
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Remotes & Authentication
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote status # Check auth status
|
||||
yarn twenty remote switch # Switch default remote
|
||||
yarn twenty remote list # List all configured remotes
|
||||
yarn twenty remote remove <name> # Remove a remote
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote:status # Check auth status
|
||||
yarn twenty remote:use # Set default remote
|
||||
yarn twenty remote:list # List all configured remotes
|
||||
yarn twenty remote:remove <name> # Remove a remote
|
||||
|
||||
# Application
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty logs # Stream function logs
|
||||
yarn twenty exec # Execute a function with JSON payload
|
||||
yarn twenty uninstall # Uninstall app from workspace
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty dev:function:logs # Stream function logs
|
||||
yarn twenty dev:function:exec # Execute a function with JSON payload
|
||||
yarn twenty app:uninstall # Uninstall app from workspace
|
||||
```
|
||||
|
||||
## LLMs instructions
|
||||
|
||||
@@ -13,7 +13,7 @@ beforeAll(async () => {
|
||||
if (!apiUrl || !token) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,15 +64,15 @@ cd packages/twenty-apps/internal/twenty-linear
|
||||
# For day-to-day development (publish + install + watch in one):
|
||||
yarn twenty dev
|
||||
|
||||
# Manual publish flow (deploy registers the app, install activates it):
|
||||
yarn twenty deploy
|
||||
yarn twenty install
|
||||
# Manual publish flow (publish registers the app, install activates it):
|
||||
yarn twenty app:publish --private
|
||||
yarn twenty app:install
|
||||
```
|
||||
|
||||
`twenty dev` is recommended for iteration — it publishes, installs, and
|
||||
watches for changes in one command. Use `twenty deploy` + `twenty install`
|
||||
when you want to control each step separately (e.g. deploying to a
|
||||
production server without auto-installing).
|
||||
watches for changes in one command. Use `twenty app:publish --private` +
|
||||
`twenty app:install` when you want to control each step separately (e.g.
|
||||
deploying to a production server without auto-installing).
|
||||
|
||||
This serves as the reference implementation for Twenty's
|
||||
`defineConnectionProvider({ type: 'oauth' })` flow — useful as a template
|
||||
|
||||
@@ -0,0 +1,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,38 @@
|
||||
{
|
||||
"name": "twenty-partners",
|
||||
"version": "0.1.0",
|
||||
"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",
|
||||
"import:dryrun": "tsx src/scripts/import-from-tft.ts",
|
||||
"import:apply": "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_QUOTE_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_QUOTES_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_QUOTES_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_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',
|
||||
},
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export const PARTNER_QUOTE_OPPORTUNITY_FIELD_ID = '36684f3e-f01a-4270-8f34-78966747dd64';
|
||||
export const PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID = '4857f339-a051-4cdb-bd8a-6219b933d1ce';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_QUOTE_OPPORTUNITY_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'opportunity',
|
||||
label: 'Opportunity',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'opportunityId',
|
||||
},
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export const PARTNER_QUOTE_PARTNER_FIELD_ID = '1774738e-96a1-43e2-aca4-d3c7cc794c50';
|
||||
export const PARTNER_QUOTES_ON_PARTNER_FIELD_ID = 'ac7995f1-7ccf-4607-827b-0788eeacaee0';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_QUOTE_PARTNER_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partner',
|
||||
label: 'Partner',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTES_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_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID, PARTNER_QUOTE_OPPORTUNITY_FIELD_ID } from './partner-quote-opportunity.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID,
|
||||
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partnerQuotes',
|
||||
label: 'Partner Quotes',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTE_OPPORTUNITY_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_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { PARTNER_QUOTES_ON_PARTNER_FIELD_ID, PARTNER_QUOTE_PARTNER_FIELD_ID } from './partner-quote-partner.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_QUOTES_ON_PARTNER_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partnerQuotes',
|
||||
label: 'Partner Quotes',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTE_PARTNER_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
@@ -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 {
|
||||
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,
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
icon: 'IconFileDollar',
|
||||
position: 3,
|
||||
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
|
||||
viewUniversalIdentifier: PARTNER_QUOTES_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,65 @@
|
||||
import { FieldType, defineObject } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'partnerQuote',
|
||||
namePlural: 'partnerQuotes',
|
||||
labelSingular: 'Partner Quote',
|
||||
labelPlural: 'Partner Quotes',
|
||||
description: 'A quote a partner submitted for a customer deal',
|
||||
icon: 'IconFileDollar',
|
||||
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: '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: '2cbe67e3-24ec-421b-bab5-50f3306c2391',
|
||||
type: FieldType.CURRENCY,
|
||||
name: 'amount',
|
||||
label: 'Amount',
|
||||
icon: 'IconCoin',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'f303369e-288c-4a48-9920-c1de0ad9a159',
|
||||
type: FieldType.FILES,
|
||||
name: 'quoteFile',
|
||||
label: 'Quote File',
|
||||
icon: 'IconPaperclip',
|
||||
isNullable: true,
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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,420 @@
|
||||
// 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: '.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',
|
||||
};
|
||||
|
||||
// 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))];
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
// Distinct TFT values seen, for the preflight coverage report.
|
||||
const seen: Record<string, Set<string>> = {
|
||||
partnerStage: new Set(),
|
||||
partnerTier: new Set(),
|
||||
partnerScope: new Set(),
|
||||
typeOfTeam: new Set(),
|
||||
oppStage: new Set(),
|
||||
hostingType: new Set(),
|
||||
subscriptionType: new Set(),
|
||||
subscriptionFrequency: new Set(),
|
||||
quoteStatus: new Set(),
|
||||
typeCustom: new Set(),
|
||||
};
|
||||
const note = (bucket: string, value?: string | null) => {
|
||||
if (value) seen[bucket].add(value);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 1. Read all three TFT datasets (raw fetch; not rate-limited by local).
|
||||
// ---------------------------------------------------------------------
|
||||
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 partnerIsAvailable partnerSkills
|
||||
partnerBudgetMinimum { amountMicros currencyCode }
|
||||
partnerBudgetAverage { amountMicros currencyCode }
|
||||
company { id name domainName { primaryLinkUrl } }
|
||||
} }
|
||||
}
|
||||
}`),
|
||||
'people',
|
||||
);
|
||||
const tftOpps = 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',
|
||||
);
|
||||
const tftContent = edges(
|
||||
await tftQuery(`query {
|
||||
customerContents(first: 500) {
|
||||
edges { node { id name status approvalDate typeCustom partnerPerson { id } } }
|
||||
}
|
||||
}`),
|
||||
'customerContents',
|
||||
);
|
||||
const quotes = tftContent.filter((c: any) => Array.isArray(c.typeCustom) && c.typeCustom.includes('PARTNER_QUOTE'));
|
||||
console.log(`[import] TFT: ${tftPeople.length} partner-people, ${tftOpps.length} opportunities, ${quotes.length} partner quotes`);
|
||||
|
||||
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).
|
||||
// ---------------------------------------------------------------------
|
||||
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])
|
||||
: [],
|
||||
);
|
||||
|
||||
const companyNames = uniq([...tftPeople.map((p: any) => p.company?.name), ...tftOpps.map((o: any) => o.company?.name)]);
|
||||
const companyIdByName = new Map<string, string>(
|
||||
companyNames.length
|
||||
? edges(
|
||||
await local.query({
|
||||
companies: { __args: { filter: { name: { in: companyNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
|
||||
} as any),
|
||||
'companies',
|
||||
).map((n: any) => [n.name, n.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])
|
||||
: [],
|
||||
);
|
||||
|
||||
const quoteNames = uniq(quotes.map((q: any) => q.name || 'Partner quote'));
|
||||
const quoteIdByName = new Map<string, string>(
|
||||
quoteNames.length
|
||||
? edges(
|
||||
await local.query({
|
||||
partnerQuotes: { __args: { filter: { name: { in: quoteNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
|
||||
} as any),
|
||||
'partnerQuotes',
|
||||
).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);
|
||||
let id = `dry:company:${name}`;
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
const created: any = await local.mutation({
|
||||
createCompany: {
|
||||
__args: { data: { name, ...(domain ? { domainName: { primaryLinkUrl: domain } } : {}) } },
|
||||
id: true,
|
||||
},
|
||||
} as any);
|
||||
id = created.createCompany.id;
|
||||
}
|
||||
companyIdByName.set(name, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
const budgetCurrency = (amount: any) =>
|
||||
amount?.amountMicros != null ? { amountMicros: amount.amountMicros, currencyCode: amount.currencyCode ?? 'USD' } : undefined;
|
||||
|
||||
// -- Partners (upsert by slug) --
|
||||
const localPartnerIdByTftPersonId = new Map<string, string>();
|
||||
let partnersCreated = 0;
|
||||
let partnersUpdated = 0;
|
||||
for (const p of tftPeople) {
|
||||
note('partnerStage', p.partnerStage);
|
||||
note('partnerTier', p.partnerTier);
|
||||
(Array.isArray(p.partnerScope) ? p.partnerScope : []).forEach((s: string) => note('partnerScope', s));
|
||||
note('typeOfTeam', p.partnerTypeOfTeam);
|
||||
|
||||
const slug = personSlug(p);
|
||||
const companyId = await upsertCompany(p.company?.name, p.company?.domainName?.primaryLinkUrl);
|
||||
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',
|
||||
...(p.partnerTier ? { partnerTier: p.partnerTier } : {}),
|
||||
...(Array.isArray(p.partnerScope) && p.partnerScope.length ? { partnerScope: p.partnerScope } : {}),
|
||||
...(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 } : {}),
|
||||
};
|
||||
|
||||
let partnerId = partnerIdBySlug.get(slug);
|
||||
if (partnerId) {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
await local.mutation({ updatePartner: { __args: { id: partnerId, data }, id: true } } as any);
|
||||
}
|
||||
partnersUpdated++;
|
||||
} else {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
const created: any = await local.mutation({ createPartner: { __args: { data }, id: true } } as any);
|
||||
partnerId = created.createPartner.id;
|
||||
} else {
|
||||
partnerId = `dry:partner:${slug}`;
|
||||
}
|
||||
partnerIdBySlug.set(slug, partnerId as string);
|
||||
partnersCreated++;
|
||||
}
|
||||
localPartnerIdByTftPersonId.set(p.id, partnerId as string);
|
||||
}
|
||||
console.log(`[import] partners created=${partnersCreated} updated=${partnersUpdated}`);
|
||||
|
||||
// -- Opportunities (upsert by tftOpportunityId) --
|
||||
let oppsCreated = 0;
|
||||
let oppsUpdated = 0;
|
||||
let oppsPartnerLinked = 0;
|
||||
for (const o of tftOpps) {
|
||||
if (!o.name) continue;
|
||||
note('oppStage', o.stage);
|
||||
note('hostingType', o.hostingType);
|
||||
note('subscriptionType', o.subscriptionType);
|
||||
note('subscriptionFrequency', o.subscriptionFrequence);
|
||||
|
||||
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);
|
||||
if (existingId) {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
await local.mutation({ updateOpportunity: { __args: { id: existingId, data }, id: true } } as any);
|
||||
}
|
||||
oppsUpdated++;
|
||||
} else {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
await local.mutation({ createOpportunity: { __args: { data }, id: true } } as any);
|
||||
}
|
||||
oppsCreated++;
|
||||
}
|
||||
}
|
||||
console.log(`[import] opportunities created=${oppsCreated} updated=${oppsUpdated} (partner-linked=${oppsPartnerLinked})`);
|
||||
|
||||
// -- Partner quotes (upsert by name) --
|
||||
let quotesCreated = 0;
|
||||
let quotesUpdated = 0;
|
||||
tftContent.forEach((c: any) => (Array.isArray(c.typeCustom) ? c.typeCustom : []).forEach((t: string) => note('typeCustom', t)));
|
||||
for (const q of quotes) {
|
||||
note('quoteStatus', q.status);
|
||||
const name = q.name || 'Partner quote';
|
||||
const partnerId = q.partnerPerson?.id ? localPartnerIdByTftPersonId.get(q.partnerPerson.id) : undefined;
|
||||
const data: Record<string, unknown> = {
|
||||
name,
|
||||
...(q.status ? { status: q.status } : {}),
|
||||
...(q.approvalDate ? { approvalDate: q.approvalDate } : {}),
|
||||
...(partnerId && APPLY ? { partnerId } : {}),
|
||||
};
|
||||
const existingId = quoteIdByName.get(name);
|
||||
if (existingId) {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
await local.mutation({ updatePartnerQuote: { __args: { id: existingId, data }, id: true } } as any);
|
||||
}
|
||||
quotesUpdated++;
|
||||
} else {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
await local.mutation({ createPartnerQuote: { __args: { data }, id: true } } as any);
|
||||
}
|
||||
quotesCreated++;
|
||||
}
|
||||
}
|
||||
console.log(`[import] partner quotes created=${quotesCreated} updated=${quotesUpdated}`);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 4. Preflight: distinct TFT values vs local option coverage.
|
||||
// ---------------------------------------------------------------------
|
||||
const report = (label: string, bucket: string, optionKey?: string) => {
|
||||
const values = [...seen[bucket]].sort();
|
||||
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(', ')}` : ''),
|
||||
);
|
||||
};
|
||||
console.log('--- preflight: distinct TFT values seen ---');
|
||||
report('partnerStage (-> validationStage map)', 'partnerStage');
|
||||
report('partnerTier', 'partnerTier', 'partnerTier');
|
||||
report('partnerScope', 'partnerScope', 'partnerScope');
|
||||
report('typeOfTeam', 'typeOfTeam', 'typeOfTeam');
|
||||
report('opp stage (-> matchStatus map)', 'oppStage');
|
||||
report('hostingType', 'hostingType', 'hostingType');
|
||||
report('subscriptionType', 'subscriptionType', 'subscriptionType');
|
||||
report('subscriptionFrequency', 'subscriptionFrequency', 'subscriptionFrequency');
|
||||
report('quote status', 'quoteStatus', 'quoteStatus');
|
||||
report('customerContent typeCustom', 'typeCustom');
|
||||
const unmappedStages = [...seen.partnerStage].filter((s) => !(s in PARTNER_STAGE_TO_VALIDATION));
|
||||
const unmappedOpps = [...seen.oppStage].filter((s) => !(s in STAGE_TO_MATCH_STATUS));
|
||||
if (unmappedStages.length) console.log(`[preflight] ⚠️ partnerStage not mapped: ${unmappedStages.join(', ')}`);
|
||||
if (unmappedOpps.length) console.log(`[preflight] ⚠️ opp stage not mapped: ${unmappedOpps.join(', ')}`);
|
||||
}
|
||||
|
||||
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: '.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; oppName: string };
|
||||
const QUOTES: Quote[] = [
|
||||
{ name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — APAC fleet CRM' },
|
||||
{ name: 'Helix clinical — NetZero quote', status: 'INTERVIEW_SCHEDULED', partnerSlug: 'netzero-systems', oppName: 'Helix Bio — clinical trials CRM' },
|
||||
{ name: 'Acme rollout — Elevate quote', status: 'UNDER_CUSTOMER_PARTNER_REVIEW', partnerSlug: 'elevate-consulting', oppName: 'Acme RE — CRM rollout' },
|
||||
{ name: 'Sunrise LATAM — Nine Dots quote', status: 'APPROVED', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — LATAM expansion' },
|
||||
{ name: 'Helix self-host — Meridian quote', status: 'REJECTED', partnerSlug: 'meridian-craft', oppName: 'Helix Bio — self-host evaluation' },
|
||||
];
|
||||
|
||||
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, partnerId: partnerIdBySlug.get(q.partnerSlug), opportunityId: oppIdByName.get(q.oppName) };
|
||||
const existing = nodes(await client.query({ partnerQuotes: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerQuotes');
|
||||
if (existing[0]?.id) {
|
||||
await client.mutation({ updatePartnerQuote: { __args: { id: existing[0].id, data }, id: true } } as any);
|
||||
} else {
|
||||
await client.mutation({ createPartnerQuote: { __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_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
// Index view for partner quotes.
|
||||
export default defineView({
|
||||
universalIdentifier: PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Partner quotes',
|
||||
icon: 'IconFileDollar',
|
||||
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: ViewType.TABLE,
|
||||
fields: [
|
||||
{ universalIdentifier: 'ad7b3702-b552-4355-afec-1e1e96d9f3df', fieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6', position: 0, isVisible: true },
|
||||
{ universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 1, isVisible: true },
|
||||
{ universalIdentifier: '62bb1536-539c-4fb0-95bf-440c5c5da89f', fieldMetadataUniversalIdentifier: '2cbe67e3-24ec-421b-bab5-50f3306c2391', 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.6.0",
|
||||
"version": "2.7.0",
|
||||
"sideEffects": false,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Stub — overwritten by `twenty build` or `twenty dev`
|
||||
// Stub — overwritten by `twenty dev:build` or `twenty dev`
|
||||
export type CoreSchema = {};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user