Compare commits

..
Author SHA1 Message Date
Paul Rastoinandprastoin 7812486d32 fix(server): encrypt token post refresh (#20819)
# Introduction
Jobs were refreshing token and returning them as plain text, resulting
to underlying code flow failure as expecting encrypted tokens

## Next
We should define a strong typescript signature to avoid such things to
happen again, or least have an explicit naming
2026-05-21 18:54:39 +02:00
prastoin bace6c1802 fix(server): workpsace command system build 2026-05-21 15:53:31 +02:00
3199 changed files with 62855 additions and 131188 deletions
+5 -91
View File
@@ -144,9 +144,6 @@ jobs:
npx nx run twenty-server:database:init:prod
- name: Flush cache before seeding current branch
run: npx nx command-no-deps twenty-server -- cache:flush
- name: Seed current branch database with test data
run: |
npx nx command-no-deps twenty-server -- workspace:seed:dev
@@ -163,7 +160,7 @@ jobs:
- name: Wait for current branch server to be ready
run: |
echo "Waiting for current branch server to start..."
timeout=60
timeout=300
interval=5
elapsed=0
@@ -188,10 +185,9 @@ jobs:
done
if [ $elapsed -ge $timeout ]; then
echo "Timed out waiting for current branch server to serve a valid schema."
echo "::warning::Timed out waiting for current branch server to serve a valid schema. Validation will skip the API diff."
echo "Current server log:"
cat /tmp/current-server.log || echo "No current server log found"
exit 1
fi
- name: Download GraphQL and REST responses from current branch
@@ -315,9 +311,6 @@ jobs:
npx nx run twenty-server:database:init:prod
- name: Flush cache before seeding main branch
run: npx nx command-no-deps twenty-server -- cache:flush
- name: Seed main branch database with test data
run: |
npx nx command-no-deps twenty-server -- workspace:seed:dev
@@ -359,10 +352,9 @@ jobs:
done
if [ $elapsed -ge $timeout ]; then
echo "Timed out waiting for main branch server to serve a valid schema."
echo "::warning::Timed out waiting for main branch server to serve a valid schema. Validation will skip the API diff."
echo "Main server log:"
cat /tmp/main-server.log || echo "No main server log found"
exit 1
fi
- name: Download GraphQL and REST responses from main branch
@@ -458,7 +450,6 @@ jobs:
echo "Using OpenAPITools/openapi-diff via Docker"
- name: Generate GraphQL Schema Diff Reports
id: graphql-diff
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
@@ -472,7 +463,6 @@ jobs:
echo "✅ No changes in GraphQL schema"
else
echo "⚠️ Changes detected in GraphQL schema, generating report..."
echo "core_breaking=true" >> $GITHUB_OUTPUT
echo "# GraphQL Schema Changes" > graphql-schema-diff.md
echo "" >> graphql-schema-diff.md
graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >> graphql-schema-diff.md 2>&1 || {
@@ -490,7 +480,6 @@ jobs:
echo "✅ No changes in GraphQL metadata schema"
else
echo "⚠️ Changes detected in GraphQL metadata schema, generating report..."
echo "metadata_breaking=true" >> $GITHUB_OUTPUT
echo "# GraphQL Metadata Schema Changes" > graphql-metadata-diff.md
echo "" >> graphql-metadata-diff.md
graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >> graphql-metadata-diff.md 2>&1 || {
@@ -507,7 +496,6 @@ jobs:
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
- name: Check REST API Breaking Changes
id: rest-diff
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
@@ -530,7 +518,6 @@ jobs:
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST API"
echo "breaking=true" >> $GITHUB_OUTPUT
# Generate breaking changes report
echo "# REST API Breaking Changes" > rest-api-diff.md
@@ -578,7 +565,6 @@ jobs:
fi
- name: Check REST Metadata API Breaking Changes
id: rest-metadata-diff
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
@@ -601,7 +587,6 @@ jobs:
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST Metadata API"
echo "breaking=true" >> $GITHUB_OUTPUT
# Generate breaking changes report (only for breaking changes)
echo "# REST Metadata API Breaking Changes" > rest-metadata-api-diff.md
@@ -647,79 +632,6 @@ jobs:
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
fi
- name: Fail on breaking changes
if: steps.validate-schemas.outputs.valid == 'true'
run: |
breaking=false
if [ "${{ steps.graphql-diff.outputs.core_breaking }}" = "true" ]; then
echo "❌ GraphQL core schema has breaking changes"
breaking=true
if [ -f graphql-schema-diff.md ]; then
echo ""
cat graphql-schema-diff.md
echo ""
fi
fi
if [ "${{ steps.graphql-diff.outputs.metadata_breaking }}" = "true" ]; then
echo "❌ GraphQL metadata schema has breaking changes"
breaking=true
if [ -f graphql-metadata-diff.md ]; then
echo ""
cat graphql-metadata-diff.md
echo ""
fi
fi
if [ "${{ steps.rest-diff.outputs.breaking }}" = "true" ]; then
echo "❌ REST core API has breaking changes"
breaking=true
if [ -f rest-api-diff.json ]; then
echo ""
jq -r '
(if (.missingEndpoints | length) > 0 then
" Removed endpoints:\n" +
(.missingEndpoints | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end),
(if (.changedOperations | length) > 0 then
" Changed operations:\n" +
(.changedOperations | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end)
' rest-api-diff.json | sed '/^$/d'
echo ""
fi
fi
if [ "${{ steps.rest-metadata-diff.outputs.breaking }}" = "true" ]; then
echo "❌ REST metadata API has breaking changes"
breaking=true
if [ -f rest-metadata-api-diff.json ]; then
echo ""
jq -r '
(if (.missingEndpoints | length) > 0 then
" Removed endpoints:\n" +
(.missingEndpoints | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end),
(if (.changedOperations | length) > 0 then
" Changed operations:\n" +
(.changedOperations | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end)
' rest-metadata-api-diff.json | sed '/^$/d'
echo ""
fi
fi
if [ "$breaking" = "true" ]; then
echo ""
echo "This PR introduces breaking changes to the public API."
echo "If intentional, deprecate the old endpoint and introduce a new one."
echo "See the breaking changes report artifact and PR comment for details."
exit 1
fi
echo "✅ No breaking API changes detected"
- name: Upload breaking changes report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -740,3 +652,5 @@ jobs:
if [ -f /tmp/main-server.pid ]; then
kill $(cat /tmp/main-server.pid) || true
fi
-44
View File
@@ -1,44 +0,0 @@
name: CI Codex Plugin
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
packages/twenty-codex-plugin/**
.github/workflows/ci-codex-plugin.yaml
codex-plugin-validate:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Fetch local actions
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Codex Plugin / Validate
run: npx nx run twenty-codex-plugin:validate
- name: Codex Plugin / Test
run: npx nx run twenty-codex-plugin:test
@@ -80,20 +80,10 @@ jobs:
with:
name: storybook-twenty-front-component-renderer
path: packages/twenty-front-component-renderer/storybook-static
- name: Resolve Playwright version
id: playwright-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: v4-playwright-browsers-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: |
cd packages/twenty-front-component-renderer
npx playwright install chromium
npx playwright install
- name: Serve storybook & run tests
run: |
npx http-server packages/twenty-front-component-renderer/storybook-static --port 6008 --silent &
+1 -11
View File
@@ -96,20 +96,10 @@ jobs:
with:
name: storybook-static
path: packages/twenty-front/storybook-static
- name: Resolve Playwright version
id: playwright-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: v4-playwright-browsers-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: |
cd packages/twenty-front
npx playwright install chromium
npx playwright install
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Serve storybook & run tests
+18 -3
View File
@@ -1,6 +1,7 @@
name: 'Preview Environment Dispatch'
permissions: {}
permissions:
contents: write
on:
pull_request_target:
@@ -10,6 +11,7 @@ on:
- packages/twenty-server/**
- packages/twenty-front/**
- .github/workflows/preview-env-dispatch.yaml
- .github/workflows/preview-env-keepalive.yaml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -33,15 +35,28 @@ jobs:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Dispatch preview-env to ci-privileged
- name: Trigger preview environment workflow
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REPOSITORY: ${{ github.repository }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
gh api repos/"$REPOSITORY"/dispatches \
-f event_type=preview-environment \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
-f "client_payload[repo_full_name]=$REPOSITORY"
- name: Dispatch to ci-privileged for PR comment
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
KEEPALIVE_DISPATCH_TIME: ${{ github.event.pull_request.updated_at }}
REPOSITORY: ${{ github.repository }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=preview-env-url \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[keepalive_dispatch_time]=$KEEPALIVE_DISPATCH_TIME" \
-f "client_payload[repo]=$REPOSITORY"
@@ -0,0 +1,186 @@
name: 'Preview Environment Keep Alive'
permissions:
contents: read
on:
repository_dispatch:
types: [preview-environment]
jobs:
preview-environment:
timeout-minutes: 310
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ github.event.client_payload.pr_head_sha }}
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Run compose setup
run: |
echo "Patching docker-compose.yml..."
# change image to localbuild using yq
yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
echo "Adding SIGN_IN_PREFILLED environment variable to server service..."
yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml
echo "Setting up .env file..."
cp packages/twenty-docker/.env.example packages/twenty-docker/.env
echo "Generating secrets..."
echo "" >> packages/twenty-docker/.env
echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env
echo "APP_SECRET=$(openssl rand -base64 32)" >> packages/twenty-docker/.env
echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 16)" >> packages/twenty-docker/.env
echo "SIGN_IN_PREFILLED=true" >> packages/twenty-docker/.env
echo "Docker compose build..."
cd packages/twenty-docker/
docker compose build
working-directory: ./
- name: Create Tunnel
id: expose-tunnel
env:
CLOUDFLARED_VERSION: '2026.3.0'
run: |
set -euo pipefail
# Install cloudflared (pinned for reproducibility)
sudo curl -fsSL -o /usr/local/bin/cloudflared \
"https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64"
sudo chmod +x /usr/local/bin/cloudflared
cloudflared --version
# Start an account-less "quick tunnel" pointing at the server container.
# Cloudflare prints the assigned https://*.trycloudflare.com URL into the log.
log_file="$RUNNER_TEMP/cloudflared.log"
: > "$log_file"
cloudflared tunnel \
--url http://localhost:3000 \
--no-autoupdate \
--logfile "$log_file" \
--loglevel info \
> "$RUNNER_TEMP/cloudflared.stdout" 2>&1 &
pid=$!
echo "$pid" > "$RUNNER_TEMP/cloudflared.pid"
echo "cloudflared PID: $pid"
# Wait up to 2 minutes for the URL to appear; fail fast if cloudflared exits.
url=''
for _ in $(seq 1 60); do
url=$(grep -oE 'https://[a-zA-Z0-9-]+\.trycloudflare\.com' "$log_file" 2>/dev/null | head -n1 || true)
[ -n "$url" ] && break
if ! kill -0 "$pid" 2>/dev/null; then
echo "cloudflared exited before producing a URL"
cat "$log_file" || true
exit 1
fi
sleep 2
done
if [ -z "$url" ]; then
echo "Timed out waiting for tunnel URL"
cat "$log_file" || true
exit 1
fi
echo "Tunnel URL: $url"
echo "tunnel-url=$url" >> "$GITHUB_OUTPUT"
- name: Start services with correct SERVER_URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
run: |
cd packages/twenty-docker/
echo "Setting SERVER_URL to $TUNNEL_URL"
sed -i '/SERVER_URL=/d' .env
echo "" >> .env
echo "SERVER_URL=$TUNNEL_URL" >> .env
# Start the services
echo "Docker compose up..."
docker compose up -d || {
echo "Docker compose failed to start"
docker compose logs
exit 1
}
echo "Waiting for services to be ready..."
count=0
while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ] || [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do
sleep 5
count=$((count+1))
if [ $count -gt 60 ]; then
echo "Timeout waiting for services to be ready"
docker compose logs
exit 1
fi
echo "Still waiting for services... ($count/60)"
done
echo "All services are up and running!"
working-directory: ./
- name: Seed Dev Workspace
run: |
cd packages/twenty-docker/
echo "Seeding light dev workspace (Apple only)..."
if ! docker compose exec -T server yarn command:prod workspace:seed:dev --light; then
echo "❌ Seeding light dev workspace failed. Dumping server logs..."
docker compose logs server
exit 1
fi
working-directory: ./
- name: Output tunnel URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
run: |
echo "✅ Preview Environment Ready!"
echo "🔗 Preview URL: $TUNNEL_URL"
echo "⏱️ This environment will be available for 5 hours"
echo "## 🚀 Preview Environment Ready!" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Preview URL: $TUNNEL_URL" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "This environment will automatically shut down after 5 hours." >> "$GITHUB_STEP_SUMMARY"
echo "$TUNNEL_URL" > tunnel-url.txt
- name: Upload tunnel URL artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: tunnel-url
path: tunnel-url.txt
retention-days: 1
- name: Keep tunnel alive for 5 hours
run: timeout 300m sleep 18000 # Stop on whichever we reach first (300m or 5hour sleep)
- name: Cleanup
if: always()
run: |
if [ -f "$RUNNER_TEMP/cloudflared.pid" ]; then
kill "$(cat "$RUNNER_TEMP/cloudflared.pid")" 2>/dev/null || true
fi
cd packages/twenty-docker/
docker compose down -v
working-directory: ./
-1
View File
@@ -51,7 +51,6 @@ dump.rdb
mcp.json
/.junie/
/.agents/plugins/marketplace.json
TRANSLATION_QA_REPORT.md
.playwright-mcp/
.playwright-cli/
-27
View File
@@ -1,27 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf",
"printWidth": 80,
"sortPackageJson": false,
"ignorePatterns": [
"**/dist/**",
"**/build/**",
"**/lib/**",
"**/.next/**",
"**/coverage/**",
"**/generated/**",
"**/generated-admin/**",
"**/generated-metadata/**",
"**/.cache/**",
"**/node_modules/**",
"**/*.min.js",
"**/*.snap",
"**/*.md",
"**/*.mdx",
"**/seed-project/**/*.mjs",
"packages/twenty-zapier/build/**",
"**/upgrade-version-command/**"
]
}
-159
View File
@@ -1,159 +0,0 @@
# Twenty Website — DESIGN.md
> Visual system for the Twenty marketing site. Distilled from `packages/twenty-website/src/theme/`. Loaded by every `impeccable` invocation alongside PRODUCT.md.
## Theme
**Light by default.** A founder browsing a partner profile in daylight on a 1427 inch monitor is the default scene. The site does ship a `data-scheme="dark"` override (see `css-variables.ts`), but no current public page opts into it. Treat dark as a deferred surface.
## Color
Palette is OKLCH-equivalent neutrals at the surface level. The brand accents (blue, pink, yellow, green) are present in the token system but used sparingly — none of them appear on the partner pages.
### Strategy: Restrained
Tinted neutrals + one accent ≤10%. The accent for partner pages is the deep ink black (`var(--color-black-100)`) used in CTAs and hover states. Anything beyond a hairline border, an icon glyph, or a primary CTA should question whether it needs color at all.
### Tokens (from `src/theme/colors.ts` + `css-variables.ts`)
Neutrals (the workhorses):
| Token | Hex (computed) | Role |
| --- | --- | --- |
| `colors.primary.background[100]` | `#ffffff` | Page + card surface |
| `colors.primary.text[100]` | `#1c1c1c` | Headlines, primary text |
| `colors.primary.text[80]` | `#1c1c1ccc` | Body text |
| `colors.primary.text[60]` | `#1c1c1c99` | Eyebrows, meta, captions |
| `colors.primary.text[40]` | `#1c1c1c66` | Disabled / placeholder |
| `colors.primary.text[20]` | `#1c1c1c33` | Subtle separators |
| `colors.primary.text[10]` | `#1c1c1c1a` | Hairline borders |
| `colors.primary.text[5]` | `#1c1c1c0d` | Subtle fills (rates panel, skill chips) |
| `colors.primary.border[10]` | `#1c1c1c1a` | Default border |
| `colors.primary.border[20]` | `#1c1c1c33` | Hover border |
Reverse palette (for dark CTAs):
| Token | Role |
| --- | --- |
| `colors.secondary.background[100]` | Filled CTA background (deep ink) |
| `colors.secondary.text[100]` | Filled CTA text (white) |
Brand accents (currently absent from partner pages; available if needed):
- `colors.accent.blue``#4a38f5` / `#8174f8`
- `colors.accent.pink``#ed87fc` / `#f3abfd`
- `colors.accent.yellow``#feffb7` / `#feffd9`
- `colors.accent.green``#89fc9a` / `#b0fdbe`
- `colors.highlight` — same hue as blue accent
**Do not introduce gradients, glass blurs, or saturated fills on partner pages.** Color is conviction here, not decoration.
## Typography
Three families, each load-balanced via CSS variables:
| Family | Var | Use |
| --- | --- | --- |
| `theme.font.family.serif` | `--font-serif` | Headlines, partner names, headline values |
| `theme.font.family.sans` | `--font-sans` | Body, prose, interactive labels |
| `theme.font.family.mono` | `--font-mono` | Eyebrows, meta, currency labels, tabular numerics |
| `theme.font.family.retro` | `--font-retro` | Reserved (not used on partner pages) |
### Weight + Size Contrast
Weights: `light: 300`, `regular: 400`, `medium: 500`. No bold. Hierarchy is driven by scale and family contrast, never by weight alone.
Scale (`theme.font.size(n)``calc(var(--font-base) * n)`, where `--font-base: 0.25rem` ≈ 4px):
- Display / h1: size 912 (3648px)
- h2 / section heads: size 78 (2832px)
- h3 / card heads: size 56 (2024px)
- Body / prose: size 45 (1620px)
- Eyebrow / meta: size 3 (12px) with `letter-spacing: 0.060.08em` and `text-transform: uppercase`
Body line length: cap at 6575ch (the existing `PartnerProfileIntro` uses `max-width: 62ch` — keep that order of magnitude).
### Hierarchy contract
- A serif `<h1>` at size 9 light reads as a partner's name on the detail page.
- A mono eyebrow above or below it locates the partner (region · city · country).
- A serif size 6 light reads as a section head.
- Body prose is sans regular.
- Currency values are serif (they read as headline numbers, not stats).
- Currency labels and meta are mono.
## Spacing & Layout
Base unit `4px`. Spacing helper `theme.spacing(n)` returns `n * 4px`. Common rhythms on the partner pages:
- Inter-section gap on the detail page: `theme.spacing(1014)` — generous, editorial breathing room.
- Inter-element gap inside a section: `theme.spacing(35)`.
- Card padding: `theme.spacing(6)`.
- Page horizontal padding: `theme.spacing(4)` mobile, `theme.spacing(10)` ≥ md breakpoint.
### Radius
`theme.radius(n)` returns `n * 2px`. The default card radius is `theme.radius(2)` = 4px. Pills use `999px`. No softer rounding than that.
### Borders
Borders are hairline (`1px solid theme.colors.primary.border[10]`). They define edges quietly. On hover they step to `border[20]`. Never use a chunky border as decoration.
## Components
### Card (PartnerCard, RatesPanel)
White surface, hairline border, 4px radius, 24px padding, soft shadow on hover only:
```css
background-color: ${theme.colors.primary.background[100]};
border: 1px solid ${theme.colors.primary.border[10]};
border-radius: ${theme.radius(2)};
padding: ${theme.spacing(6)};
&:hover {
border-color: ${theme.colors.primary.border[20]};
box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.18);
transform: translateY(-2px);
}
```
### Chip / Pill
Rounded `999px`, 1px border, subtle background fill (`primary.text[5]` for filter pills, transparent for chip rows), `text[80]` color, mono or sans font.
### Button / LinkButton
Lives in `@/design-system/components`. Two color modes: `primary` (deep ink fill, white text) and `secondary` (transparent fill, ink text + 1px border). `variant="contained"` is what partner pages use.
### Avatar
`PartnerAvatar` is a deterministic generated mark from name + slug. Used as fallback when `profilePictureUrl` is missing. The real photo overlays it at 120px circle on the detail page, 56px on the list card.
## Motion
- Hover transitions: 250ms, ease-out (cubic-bezier curve in `PartnerCard`: `0.25s ease`).
- Card entrance: 700ms cubic-bezier `0.22, 1, 0.36, 1` (ease-out-quart), 90ms stagger per index.
- All motion respects `@media (prefers-reduced-motion: reduce)` — animations stop, hover translate disabled.
- **No bounce, no elastic, no parallax.** Editorial restraint.
## Iconography
`@tabler/icons-react`, 1416px on body-level chips, 1824px on buttons. Always `aria-hidden="true"` when decorative. Stroke width `2` (default).
## Accessibility Defaults
- Focus ring: `outline: 2px solid theme.colors.primary.text[100]; outline-offset: 4px` (already used on the card link).
- Touch target ≥ 40×40px on mobile.
- `aria-label` on icon-only buttons, `aria-labelledby` on sectioned regions.
- All `<a target="_blank">` includes `rel="noopener noreferrer"`.
- Color is never the sole carrier of meaning. The money pills carry both an icon and a text label.
## Anti-patterns (project-specific)
In addition to the impeccable shared absolute bans:
- **Do not use the brand accent colors (blue/pink/yellow/green) on partner pages** unless we have a stronger reason than "to add color".
- **No skeuomorphic shadows on cards.** The hover shadow is `0 12px 32px -16px rgba(0,0,0,0.18)` — that's the ceiling.
- **No gradients on anything.** Including text, borders, and backgrounds.
- **No floating "Trusted by" logo bars** on partner pages.
-80
View File
@@ -1,80 +0,0 @@
# Twenty Website — Product & Brand Context
> Strategic context for design work on the Twenty marketing site (`packages/twenty-website`). Loaded by every `impeccable` invocation.
## Register
**Brand.** The marketing site is a public-facing surface where the design itself is part of the credibility argument. Prospects evaluate Twenty partly by how the site feels. The product app (`packages/twenty-front`) is a separate product-register surface, governed elsewhere.
## Users & Purpose
The primary audience varies by route, but the working assumption for partner-related pages is:
- **Who:** A budget-holding decision maker (founder, RevOps lead, or COO) shopping for a CRM implementation partner. Already on Twenty's site, evaluating a shortlist of partners.
- **Context:** Doing a side-by-side comparison across 25 candidates over a single browsing session. Will spend 3090 seconds on each profile before deciding whether to book a call.
- **Decision being made:** "Is this partner credible, the right size, the right specialty, and within budget? Do I trust them enough to commit 30 minutes to a discovery call?"
What the partner pages must do, in priority order:
1. Communicate credibility (real firm, real person, real work).
2. Surface fit signals fast (skills, region, languages, deployment expertise, budget range).
3. Give the visitor a confident "next step" affordance (book a call or vet via LinkedIn) without pressure.
## Desired Outcome
The redesign should make `/partners/profile/[slug]` feel like a *thoughtfully curated profile of a top-tier partner*, not a generic templated card. A visitor should leave thinking "this firm is serious" even if they don't book a call this session.
Specifically:
- **Confidence over information density.** A short, well-typeset profile beats a packed-but-busy one.
- **Editorial restraint.** White space, deliberate type hierarchy, and a few well-chosen details say more than dozens of small components.
- **Quiet conviction.** No hype copy, no growth-hack patterns, no "Trusted by" logo strips. The partner's own work and intro speak for themselves.
## Brand Personality
**Editorial · Founder-led · Considered.**
The site reads like a thoughtful indie publication, not a SaaS landing page. Serif headlines, plenty of whitespace, deliberate typographic rhythm. Quietly opinionated — Twenty has a point of view about CRM (open-source, customizable, well-designed) and the site reflects that without shouting.
Tonal anchors:
- Stripe's documentation for clarity, Linear's marketing for restraint, an editorial print magazine for typography choices.
## Anti-references
**Reject these patterns. They make the work read as generic AI / generic SaaS:**
- **Generic SaaS landing.** Big-number heroes, identical icon-grid cards, gradient text, navy + lime accent color schemes, "supercharge your workflow" language.
- **Corporate enterprise tone.** Stock photos of diverse handshakes. "Trusted by Fortune 500" logo strips as the primary credibility move. Trust-badge bars.
- **Bento templates.** Repetitive same-size cards. Vercel-style scroll-pin animations on every section.
- **Side-stripe borders, gradient text, glassmorphism, hero-metric templates, identical card grids** — see impeccable's shared absolute bans.
## Strategic Design Principles
1. **Typography carries the design.** The brand has a serif/sans/mono trio. Hierarchy is set by scale + weight contrast, not by color or borders.
2. **Restrained palette.** Tinted neutrals (black/white via CSS variables, with alpha-tone variants for text and borders) carry 90%+ of the surface. Accent color used sparingly when it appears at all.
3. **Whitespace is a feature.** Tight cards feel cheap. Pages should breathe.
4. **Asymmetry over grid.** A 12-col bento is the wrong shape for a profile page. Use asymmetric two-column layouts where one column does heavy lifting.
5. **One opinionated detail per page.** Each surface should have one moment of editorial conviction (a typographic flourish, a precise micro-interaction, a deliberate space) rather than five generic flourishes.
## Accessibility
**WCAG AA + keyboard + screen reader baseline:**
- All interactive elements reachable by keyboard, focus visible (`outline: 2px solid`, not just color shift).
- Semantic landmarks: `<header>`, `<main>`, `<nav>`, `<section aria-labelledby=…>`, headings in order.
- All images with informational content have alt text. Decorative icons have `aria-hidden="true"`.
- Body text ≥ 4.5:1 contrast; large text (≥18pt or 14pt bold) ≥ 3:1.
- Respect `prefers-reduced-motion`. Animations stop, don't slow.
- Forms have explicit labels. Errors are announced.
## Tech & Constraints
- Next.js 16 app router (Server Components by default, `'use client'` for interactivity).
- Linaria styled-components (`@linaria/react`) for zero-runtime CSS-in-JS.
- Lingui (`@lingui/react`) for i18n; never hardcode user-visible strings.
- Theme tokens in `packages/twenty-website/src/theme/`. Colors are CSS variables resolved to OKLCH-tinted neutrals.
- `@tabler/icons-react` for iconography (no Heroicons, no custom SVGs unless purposeful).
- `@radix-ui/react-*` for primitives (Popover etc) where headless behavior is needed.
## Out of Scope for This File
- Detailed visual tokens (colors, type scale, motion specs) live in `DESIGN.md`.
- Per-page IA decisions live in shape briefs (`docs/superpowers/specs/`).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

+13 -9
View File
@@ -44,12 +44,12 @@
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "npx oxlint -c .oxlintrc.json . && (npx oxfmt --check . || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
"command": "npx oxlint -c .oxlintrc.json . && (prettier . --check --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
},
"configurations": {
"ci": {},
"fix": {
"command": "npx oxlint --fix -c .oxlintrc.json . && npx oxfmt ."
"command": "npx oxlint --fix -c .oxlintrc.json . && prettier . --write --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata"
}
},
"dependsOn": ["^build", "twenty-oxlint-rules:build"]
@@ -59,12 +59,12 @@
"cache": false,
"dependsOn": ["twenty-oxlint-rules:build"],
"options": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (npx oxfmt --check $FILES || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (prettier --check $FILES || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
"pattern": "\\.(ts|tsx|js|jsx)$"
},
"configurations": {
"fix": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && npx oxfmt $FILES)"
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && prettier --write $FILES)"
}
}
},
@@ -73,14 +73,18 @@
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "npx oxfmt --check {args.files} {args.write}",
"files": ".",
"write": ""
"command": "prettier {args.files} --check --cache {args.cache} --cache-location {args.cacheLocation} --write {args.write} --cache-strategy {args.cacheStrategy}",
"cache": true,
"cacheLocation": "../../.cache/prettier/{projectRoot}",
"cacheStrategy": "metadata",
"write": false
},
"configurations": {
"ci": {},
"ci": {
"cacheStrategy": "content"
},
"fix": {
"command": "npx oxfmt {args.files}"
"write": true
}
},
"dependsOn": ["^build"]
+1 -3
View File
@@ -13,7 +13,6 @@
"concurrently": "^8.2.2",
"http-server": "^14.1.1",
"nx": "22.5.4",
"oxfmt": "0.50.0",
"tsx": "^4.17.0",
"verdaccio": "^6.3.1"
},
@@ -28,7 +27,7 @@
"resolutions": {
"graphql": "16.8.1",
"type-fest": "4.10.1",
"typescript": "5.9.3",
"typescript": "5.9.2",
"nodemailer": "8.0.4",
"graphql-redis-subscriptions/ioredis": "^5.6.0",
"@lingui/core": "5.1.2",
@@ -63,7 +62,6 @@
"packages/twenty-client-sdk",
"packages/twenty-cli",
"packages/create-twenty-app",
"packages/twenty-codex-plugin",
"packages/twenty-oxlint-rules",
"packages/twenty-companion",
"packages/twenty-claude-skills"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.9.0",
"version": "2.7.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -50,7 +50,7 @@
"jest": "29.7.0",
"jest-environment-node": "^29.4.1",
"twenty-shared": "workspace:*",
"typescript": "^5.9.3",
"typescript": "^5.9.2",
"vite": "^7.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^4.2.1"
@@ -1,11 +0,0 @@
import { defineCommandMenuItem, objectMetadataItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: '7d3f2b9e-5c0a-4e8b-ad6e-2f9c3b4d5e6a',
frontComponentUniversalIdentifier: '6c2f1a8d-4b9e-4f7a-9c5d-1e8b2a3d4c5f',
label: 'Contributor Stats',
icon: 'IconChartBar',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'contributor',
});
@@ -1,11 +0,0 @@
import { defineCommandMenuItem, objectMetadataItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: '4640992f-c2c9-4bba-b5df-9c8f05dc9e80',
frontComponentUniversalIdentifier: '08f40f82-24ed-4f3e-8c99-695151e90e38',
label: 'Fetch Contributors',
icon: 'IconUsers',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'contributor',
});
@@ -1,6 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar, useRecordId } from 'twenty-sdk/front-component';
import {
enqueueSnackbar,
objectMetadataItem,
useRecordId,
} from 'twenty-sdk/front-component';
import { THEME } from 'src/modules/github/contributor/components/theme';
import { callAppRoute } from 'src/modules/shared/call-app-route';
@@ -678,4 +682,12 @@ export default defineFrontComponent({
description:
'Displays time-bucketed charts of PRs authored (merged only), merged and reviewed by a contributor over the last week, month, 3 months or year.',
component: ContributorStats,
command: {
universalIdentifier: '7d3f2b9e-5c0a-4e8b-ad6e-2f9c3b4d5e6a',
label: 'Contributor Stats',
icon: 'IconChartBar',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'contributor',
},
});
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
unmountFrontComponent,
updateProgress,
} from 'twenty-sdk/front-component';
@@ -94,4 +95,12 @@ export default defineFrontComponent({
'Fetches contributors from every configured GitHub repo (GITHUB_REPOS) and upserts them as Contributor records.',
isHeadless: true,
component: FetchContributors,
command: {
universalIdentifier: '4640992f-c2c9-4bba-b5df-9c8f05dc9e80',
label: 'Fetch Contributors',
icon: 'IconUsers',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'contributor',
},
});
@@ -1,11 +0,0 @@
import { defineCommandMenuItem, objectMetadataItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'c34f56aa-ff65-43a4-9db2-774945dbcc53',
frontComponentUniversalIdentifier: '9430e4fc-9ecb-428d-9bde-2babeb1f452f',
label: 'Fetch Issues',
icon: 'IconBug',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'issue',
});
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
unmountFrontComponent,
updateProgress,
} from 'twenty-sdk/front-component';
@@ -97,4 +98,12 @@ export default defineFrontComponent({
description: 'Fetches issues from GitHub repos',
isHeadless: true,
component: FetchIssues,
command: {
universalIdentifier: 'c34f56aa-ff65-43a4-9db2-774945dbcc53',
label: 'Fetch Issues',
icon: 'IconBug',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'issue',
},
});
@@ -1,11 +0,0 @@
import { defineCommandMenuItem, objectMetadataItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: '719cfe1c-d570-4c8c-89e6-88671c6ba1ea',
frontComponentUniversalIdentifier: '7c397b0c-8b19-4fac-924a-8f6aa1dece78',
label: 'Fetch Project Items',
icon: 'IconLayoutKanban',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'projectItem',
});
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
unmountFrontComponent,
updateProgress,
} from 'twenty-sdk/front-component';
@@ -96,4 +97,12 @@ export default defineFrontComponent({
description: 'Fetches project items from GitHub Projects V2',
isHeadless: true,
component: FetchProjectItems,
command: {
universalIdentifier: '719cfe1c-d570-4c8c-89e6-88671c6ba1ea',
label: 'Fetch Project Items',
icon: 'IconLayoutKanban',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'projectItem',
},
});
@@ -1,11 +0,0 @@
import { defineCommandMenuItem, objectMetadataItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: '0b24e6d6-da0c-4c0e-8d88-5bfd7f3cd75a',
frontComponentUniversalIdentifier: '5e2ba8df-1db5-4964-94d3-44cccfd791a0',
label: 'Fetch Pull Requests',
icon: 'IconGitPullRequest',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'pullRequest',
});
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
unmountFrontComponent,
updateProgress,
} from 'twenty-sdk/front-component';
@@ -100,4 +101,12 @@ export default defineFrontComponent({
description: 'Fetches pull requests and reviews from GitHub repos',
isHeadless: true,
component: FetchPrs,
command: {
universalIdentifier: '0b24e6d6-da0c-4c0e-8d88-5bfd7f3cd75a',
label: 'Fetch Pull Requests',
icon: 'IconGitPullRequest',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'pullRequest',
},
});
@@ -80,8 +80,8 @@ export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
throw new Error(
`App uninstall failed during teardown: ${JSON.stringify(uninstallResult.error, null, 2)}`,
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}
@@ -1,4 +1,4 @@
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
import { PermissionFlag, defineRole } from 'twenty-sdk/define';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
@@ -36,5 +36,5 @@ export default defineRole({
canUpdateFieldValue: true,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
permissionFlags: [PermissionFlag.APPLICATIONS],
});
@@ -1,17 +0,0 @@
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
@@ -1,4 +1,4 @@
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
import { PermissionFlag, defineRole } from 'twenty-sdk/define';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
@@ -36,5 +36,5 @@ export default defineRole({
canUpdateFieldValue: false,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
permissionFlags: [PermissionFlag.APPLICATIONS],
});
@@ -1,6 +0,0 @@
# Credentials for integration tests (vitest.config.ts) and seed scripts
# (vitest.seed.config.ts). Copy this file to .env.local and fill in the key.
# Get an API key from the Twenty UI: Settings -> APIs & Webhooks.
# .env.local is gitignored; never commit a real key.
TWENTY_API_URL=http://localhost:2020
TWENTY_API_KEY=
@@ -1,42 +0,0 @@
name: CD
on:
push:
branches:
- main
pull_request:
types: [labeled]
permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:2020
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-and-install:
if: >-
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
- name: Install
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
@@ -1,48 +0,0 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty test instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
@@ -1,43 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
# internal planning docs
docs/superpowers/
**/docs/superpowers/
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# typescript
*.tsbuildinfo
*.d.ts
@@ -1 +0,0 @@
24.5.0
@@ -1,37 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
},
"overrides": [
{
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["twenty-shared", "twenty-shared/*"],
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
}
]
}
]
}
}
]
}
@@ -1 +0,0 @@
nodeLinker: node-modules
@@ -1,101 +0,0 @@
# twenty-partners
A Twenty app that turns the CRM into the operating system for the Twenty partner program:
intake partner-eligible deals, match them to vetted marketplace partners, and track the
matching pipeline end-to-end.
Built on [Twenty](https://twenty.com) with [`twenty-sdk`](https://www.npmjs.com/package/twenty-sdk) v2.5.
## What's inside
- **Custom object: `Partner`** — slug, status, availability, served geos, languages spoken,
deployment expertise, Calendly link, last-match timestamp. See `src/objects/partner.object.ts`.
- **Opportunity extensions** — `matchStatus`, `designDocStatus`,
`introSentAt`, `lastRelanceSentAt`, `tftId`, plus a `partner` relation.
- **Logic functions**
- `on-opportunity-auto-match` — fires when `matchStatus` is set to `AUTO_MATCH`. Assigns the longest-idle available partner and flips status to `MATCHED`. If no partner is available, hands off to `MANUAL_MATCH` with an audit Note explaining why.
- `list-available-partners` — surfaces matchable partners for a given opportunity.
- `post-install` — first-run setup.
- **Roles** (`src/roles/`)
- **Twenty Partner Ops** — internal team role, full CRUD on Partner/Company/Person/Opportunity.
- **Partner** — placeholder external-partner role. *Do not assign until Twenty ships
row-level permissions* — it currently grants access to every record.
- **Views** (`src/views/`)
- `Waiting for match` — opportunities awaiting human action (`matchStatus` is `TO_BE_MATCHED` or `MANUAL_MATCH`).
- `Matches overview` — full matching funnel grouped by `matchStatus` (configure Kanban
grouping manually in the UI).
- `Opportunities` — replacement of the native opportunities view with the partner columns.
- `Partners` and `All matched deals` — partner-side index and deal log.
- **Sidebar nav** — surfaced in workflow order: `Waiting for match`, `All partner deals`,
`Matches overview`, `Partners`, `Opportunities`.
- **Seed scripts** (`src/scripts/`) — populate a fresh workspace with realistic demo data.
## Match status pipeline
`matchStatus` is a non-nullable SELECT field with a default of `TO_BE_MATCHED`. The 10 states follow the deal lifecycle:
| Status | Meaning |
| --- | --- |
| `TO_BE_MATCHED` | Default — deal entered, awaiting assignment |
| `MANUAL_MATCH` | Needs a human to pick a partner |
| `AUTO_MATCH` | Triggers automatic partner assignment |
| `MATCHED` | Partner assigned |
| `INTRODUCED_TO_A_PARTNER` | Customer intro sent |
| `WORKING_WITH_A_PARTNER` | Engagement underway |
| `IMPLEMENTING` | Active implementation |
| `WON` | Deal closed won |
| `RECONNECT_LATER` | Paused — reconnect in future |
| `LOST` | Deal closed lost |
## Getting started
Requires a local Twenty server at `http://localhost:2020` and Node `^24.5`.
```bash
yarn install
yarn twenty dev
```
Default dev credentials: `tim@apple.dev` / `tim@apple.dev`.
Run `yarn twenty help` for the full CLI reference.
## Common commands
| Command | What it does |
| --- | --- |
| `yarn twenty dev` | Start the dev server and sync the app on file changes |
| `yarn twenty server status` | Check the local Twenty server |
| `yarn lint` / `yarn lint:fix` | Run oxlint |
| `yarn test` | Run integration tests (`vitest.config.ts`) |
## Seeding demo data
Two idempotent seed scripts. Both run via the `vitest.seed.config.ts` config that skips
the global app uninstall/reinstall.
```bash
# 1. Marketplace partners (run first — pipeline seed wires opportunities to these by slug)
yarn vitest run --config vitest.seed.config.ts src/scripts/seed-marketplace-partners.ts
# 2. Pipeline demo: 3 companies, 3 people, 15 opportunities spread across matchStatus values
yarn vitest run --config vitest.seed.config.ts src/scripts/seed-pipeline-demo.ts
```
Both scripts skip records that already exist (by `slug`, `name`, or `firstName+lastName`),
so they are safe to re-run.
## Known limitations
Current SDK gaps blocking further polish:
- Custom Partner record page layout (RECORD_TABLE has no relation scoping).
- Native Opportunities view column-order override.
- Kanban view configuration from app code (`ViewType.KANBAN` is currently ignored).
- App and field descriptions.
## Learn more
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)
- [`twenty-sdk` on npm](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)
@@ -1,47 +0,0 @@
{
"name": "twenty-partners",
"version": "0.3.4",
"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:unit": "vitest run --config vitest.unit.config.ts",
"test:watch": "vitest",
"seed": "tsx src/scripts/seed.ts",
"seed:prod": "ENV_FILE=.env.prod tsx src/scripts/seed.ts",
"purge": "tsx src/scripts/purge-soft-deleted.ts",
"purge:prod": "ENV_FILE=.env.prod tsx src/scripts/purge-soft-deleted.ts",
"import:dryrun": "tsx src/scripts/import-from-tft.ts",
"import:dryrun:prod": "ENV_FILE=.env.prod tsx src/scripts/import-from-tft.ts",
"import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts",
"import:apply:prod": "ENV_FILE=.env.prod IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts",
"migrate:partner-scope": "tsx src/scripts/migrate-partner-scope.ts",
"migrate:partner-scope:prod": "ENV_FILE=.env.prod tsx src/scripts/migrate-partner-scope.ts"
},
"dependencies": {
"twenty-client-sdk": "2.4.0",
"twenty-sdk": "2.4.0",
"zod": "^4.1.11"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"dotenv": "^16.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsx": "^4.0.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
}
}
@@ -1,74 +0,0 @@
// Seed file: ensures at least one ACTIVE + AVAILABLE partner exists before
// the matching integration tests run. Idempotent — skips if already seeded.
import { CoreApiClient } from 'twenty-client-sdk/core';
import { beforeAll, describe, it } from 'vitest';
describe('seed: marketplace partners', () => {
let client: CoreApiClient;
beforeAll(() => {
client = new CoreApiClient();
});
it('ensures at least one ACTIVE AVAILABLE partner exists', async () => {
const existingResult = await client.query({
partners: {
__args: {
filter: { validationStage: { eq: 'VALIDATED' }, availability: { eq: 'AVAILABLE' } },
first: 1,
},
edges: { node: { id: true } },
},
} as any);
const existing = (existingResult as any).partners.edges;
if (existing.length > 0) {
console.log('[seed] partner already exists, skipping');
return;
}
const PARTNERS = [
{
slug: 'nine-dots-ventures',
name: 'Nine Dots Ventures',
introduction: 'Boutique CRM implementer specialising in real-estate workflows.',
calendlyLink: 'https://calendly.com/placeholder',
deploymentExpertise: ['CLOUD', 'SELF_HOST'],
servedGeos: ['EUROPE', 'MENA'],
languagesSpoken: ['ENGLISH', 'FRENCH'],
},
{
slug: 'elevate-consulting',
name: 'Elevate Consulting',
introduction: 'Revenue-operations partner for B2B SaaS teams.',
calendlyLink: 'https://calendly.com/placeholder',
deploymentExpertise: ['CLOUD'],
servedGeos: ['US', 'LATAM'],
languagesSpoken: ['ENGLISH', 'SPANISH'],
},
];
for (const p of PARTNERS) {
const r = await client.mutation({
createPartner: {
__args: {
data: {
name: p.name,
slug: p.slug,
introduction: p.introduction,
calendarLink: { primaryLinkUrl: p.calendlyLink },
deploymentExpertise: p.deploymentExpertise,
region: p.servedGeos,
languagesSpoken: p.languagesSpoken,
validationStage: 'VALIDATED',
availability: 'AVAILABLE',
},
},
id: true,
name: true,
},
} as any);
console.log('[seed] created', (r as any).createPartner.name);
}
});
});
@@ -1,87 +0,0 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}
@@ -1,46 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CoreApiClient', () => {
it('should support CRUD on standard objects', async () => {
const client = new CoreApiClient();
const created = await client.mutation({
createNote: {
__args: { data: { title: 'Integration test note' } },
id: true,
},
});
expect(created.createNote.id).toBeDefined();
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
id: true,
},
});
});
});
@@ -1,21 +0,0 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
applicationVariables: {
PARTNER_APPLICATION_SECRET: {
universalIdentifier: '2026a052-9f01-4d18-b6a7-31c3a5b1c7d2',
description:
'Shared secret required in the X-Application-Secret header on POST /partner-applications. Must match the website route\'s PARTNER_APPLICATION_SECRET env var. Set per-workspace in Settings → Apps → Twenty Partners → Variables.',
isSecret: true,
},
},
});
@@ -1,41 +0,0 @@
export const APP_DISPLAY_NAME = 'Twenty partners';
export const APP_DESCRIPTION = '';
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'e662fc1f-02c1-41ff-b8ba-c95a447b3965';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'ee18c3f3-ebe7-4c56-ad6d-aad555cc32db';
export const PARTNER_OBJECT_UNIVERSAL_IDENTIFIER = '39101b39-1c16-4148-9e82-45dc271bb90d';
export const PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd';
export const ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '379b11d5-44d5-476b-ba7d-31d5f515c9b4';
export const ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER = '7a34da39-a8e1-44c7-88b4-91ceaa064920';
export const PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '3fe15ab5-e38b-4914-af17-2270b210aeb2';
export const PARTNER_DEALS_NAV_UNIVERSAL_IDENTIFIER = 'c5e4ac36-bede-4f4b-bfe8-bbd09518abae';
export const ON_OPP_AUTO_MATCH_FN_UNIVERSAL_IDENTIFIER = 'eb8d4d26-8103-4b66-9026-6a86556f7ca5';
export const POST_INSTALL_FN_UNIVERSAL_IDENTIFIER = 'f92bad2e-5905-4757-96ee-af9869d4ca0c';
export const MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER = 'd8dd0623-3a4c-4ab3-a1e0-4ece7df24fb2';
export const INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER = 'fcf39b0c-0547-415e-806d-b238131ad7cc';
// Roles (Task 2)
export const TWENTY_PARTNER_OPS_ROLE_UNIVERSAL_IDENTIFIER = '3340ca65-863d-4cdc-95c9-8abdec13d0f6';
export const PARTNER_ROLE_UNIVERSAL_IDENTIFIER = 'c3c1dc2e-1a08-4de5-abb7-2139b3d99343';
// Views (Task 3)
export const WAITING_FOR_MATCH_VIEW_UNIVERSAL_IDENTIFIER = 'fe11e738-6bf3-4714-929c-51c76a3fd050';
export const MATCHES_OVERVIEW_VIEW_UNIVERSAL_IDENTIFIER = '5a8fd51a-cf9e-4a6a-b1b4-b833b215fc1c';
// Nav items (Task 5)
export const WAITING_FOR_MATCH_NAV_UNIVERSAL_IDENTIFIER = '00be7449-8927-47c8-a6a1-212d9106587f';
export const MATCHES_OVERVIEW_NAV_UNIVERSAL_IDENTIFIER = '0cf349c9-fcbf-40f8-8e91-142c02bbde9c';
// Page layout (Task 6)
export const PARTNER_RECORD_PAGE_UNIVERSAL_IDENTIFIER = 'a888b39e-d64a-48ba-a044-d8cb685fad74';
// All Opportunities view + nav
export const ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER = '6ce1300b-6e91-4c28-83bb-6f692dbc7a98';
export const ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER = '37944f52-cbe5-4814-a1e6-be5b21425870';
// Partner views + nav (harmonization)
export const PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER = 'b57a84ed-d7c1-420d-b0eb-348db0dac612';
export const VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '13cca6a7-b9f1-4103-b011-ea2e39430899';
export const PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b891-6201149510b3';
export const PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER = '13e2334a-6b1e-4080-8c74-d11109990cc1';
export const VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '6aed30c6-d80f-4ac6-aab0-db5bc59e5c4b';
export const PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
@@ -1,16 +0,0 @@
import { defineApplicationRole } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default function role`,
description: `${APP_DISPLAY_NAME} default function role`,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { OPPORTUNITIES_ON_PARTNER_FIELD_ID, PARTNER_ON_OPPORTUNITY_FIELD_ID } from './partner-on-opportunity.field';
export default defineField({
universalIdentifier: OPPORTUNITIES_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'opportunities',
label: 'Opportunities',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_ON_OPPORTUNITY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,15 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'cc6b8a59-f860-493f-8b9a-f138c078fbf1',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'designDocStatus',
label: 'Design Doc Status',
defaultValue: "'DRAFT'",
options: [
{ id: '1901c790-22af-4149-a792-09374d67acfd', value: 'DRAFT', label: 'Draft', position: 0, color: 'gray' },
{ id: '02cbe191-cc96-4b42-9d8e-f85cd47bed24', value: 'DONE', label: 'Done', position: 1, color: 'green' },
{ id: '943e1389-12cd-4605-8066-db27ba68a50a', value: 'SHARED_WITH_PARTNER', label: 'Shared with Partner', position: 2, color: 'blue' },
],
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '37e5428c-6c8c-4616-b626-f0ea1caa443d',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.LINKS,
name: 'designDocUrl',
label: 'Design Doc URL',
isNullable: true,
});
@@ -1,14 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '7ac7517f-bbca-4b4c-8996-6f864f71219b',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'hostingType',
label: 'Hosting Type',
isNullable: true,
options: [
{ id: '42c108d7-a874-4d1f-be4c-e87edd08f3c7', value: 'CLOUD', label: 'Cloud', position: 0, color: 'sky' },
{ id: '0fe995f4-42de-4160-96af-b3e7d542dfdd', value: 'SELF_HOSTING', label: 'Self-hosting', position: 1, color: 'purple' },
],
});
@@ -1,11 +0,0 @@
import { INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.DATE_TIME,
name: 'introSentAt',
label: 'Intro Sent At',
isNullable: true,
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '834e233d-b171-409e-825f-77ac49b0f19d',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'lostReason',
label: 'Lost Reason',
isNullable: true,
});
@@ -1,26 +0,0 @@
import { MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'matchStatus',
label: 'Match Status',
isNullable: false,
defaultValue: "'TO_BE_MATCHED'",
options: [
// Pre-match (new). NEW UUIDs generated with `uuidgen` (v4).
{ id: '8b3a1c0e-2f64-4a87-9d2b-1e3c4f5a6b78', value: 'TO_BE_MATCHED', label: 'To Be Matched', position: 0, color: 'grey' },
{ id: '4c5d6e7f-8a9b-4c0d-9e1f-2a3b4c5d6e7f', value: 'MANUAL_MATCH', label: 'Manual Match', position: 1, color: 'grey' },
{ id: '7e8f9a0b-1c2d-4e3f-8a4b-5c6d7e8f9a0b', value: 'AUTO_MATCH', label: 'Auto Match', position: 2, color: 'yellow' },
// Post-match. Reuse DELIVERED's UUID for MATCHED so existing rows auto-relabel.
{ id: '095428d8-4680-4a2c-af83-7809dcb3f194', value: 'MATCHED', label: 'Matched', position: 3, color: 'blue' },
{ id: '2f1c79a1-ca91-4937-a4c0-6422f6534d34', value: 'INTRODUCED_TO_A_PARTNER', label: 'Introduced to a partner', position: 4, color: 'sky' },
{ id: '45cdf6ef-8672-40d5-b71f-1e5687ba5776', value: 'WORKING_WITH_A_PARTNER', label: 'Working with a partner', position: 5, color: 'turquoise' },
{ id: '7189b18d-b0f7-435a-9272-f812cba5d13d', value: 'IMPLEMENTING', label: 'Implementing', position: 6, color: 'green' },
{ id: '54cd33bc-11ea-42f1-87c8-cd9d32d2c266', value: 'WON', label: 'Won', position: 7, color: 'purple' },
{ id: '505433e8-5367-4dfb-a89a-708d5182165b', value: 'RECONNECT_LATER', label: 'Reconnect later', position: 8, color: 'orange' },
{ id: '572a9ad2-e0a6-49f2-b1f5-e36c75cc5176', value: 'LOST', label: 'Lost', position: 9, color: 'red' },
],
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '90c683ec-2365-4533-a187-7b9ae162b753',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.NUMBER,
name: 'numberOfSeats',
label: 'Number of Seats',
isNullable: true,
});
@@ -1,14 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '59d5de53-202f-4913-a417-8a08970d87cc',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'subscriptionFrequency',
label: 'Subscription Frequency',
isNullable: true,
options: [
{ id: 'e53a9ebb-11d6-40de-93f2-6bcb6ab7141c', value: 'MONTHLY', label: 'Monthly', position: 0, color: 'blue' },
{ id: '6eca9f01-f891-4c9d-bef6-9238c8e67392', value: 'ANNUAL', label: 'Annual', position: 1, color: 'green' },
],
});
@@ -1,15 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'a58214e9-38f9-4faf-8927-09b3980fd8c3',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'subscriptionType',
label: 'Subscription Type',
isNullable: true,
options: [
{ id: '6d91b477-5bf1-4f5c-8aef-577b6c21fe45', value: 'PRO', label: 'Pro', position: 0, color: 'blue' },
{ id: '7b64f281-3445-4429-a5f0-af9484dff8b4', value: 'ORG', label: 'Org', position: 1, color: 'green' },
{ id: 'bf202f8f-caf9-45bf-a80c-65b344b2798d', value: 'ENT', label: 'Enterprise', position: 2, color: 'purple' },
],
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '2e3e1d04-2719-4e0d-9a6b-ec73acf896c5',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'tftOpportunityId',
label: 'TfT Opportunity ID',
isNullable: true,
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '1bc57f52-a621-4243-ae3e-05c3f504b90c',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'useCase',
label: 'Use Case',
isNullable: true,
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_COMPANY_FIELD_ID = '2779015b-28fa-4117-8ce1-b0c98cf16de2';
export const PARTNERS_ON_COMPANY_FIELD_ID = '2896d888-a4ab-4c29-bf63-e8bfdbd1924f';
export default defineField({
universalIdentifier: PARTNER_COMPANY_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'company',
label: 'Company',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNERS_ON_COMPANY_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'companyId',
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID = 'd448f6be-533f-4c43-a70f-55dc361dcdd7';
export const PARTNER_CONTENTS_ON_COMPANY_FIELD_ID = '941e7707-1b3d-47d4-b13a-45c6628c1b3d';
export default defineField({
universalIdentifier: PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID,
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'customerCompany',
label: 'Customer Company',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_COMPANY_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'customerCompanyId',
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID = 'f17547be-76aa-4231-bd1a-3f57bb1ae323';
export const PARTNER_CONTENTS_ON_PERSON_FIELD_ID = 'fae42f4e-b054-4267-a761-81a6792f7c12';
export default defineField({
universalIdentifier: PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID,
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'customerPerson',
label: 'Customer Person',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PERSON_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'customerPersonId',
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_CONTENT_PARTNER_FIELD_ID = '1774738e-96a1-43e2-aca4-d3c7cc794c50';
export const PARTNER_CONTENTS_ON_PARTNER_FIELD_ID = 'ac7995f1-7ccf-4607-827b-0788eeacaee0';
export default defineField({
universalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENTS_ON_COMPANY_FIELD_ID, PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID } from './partner-content-customer-company.field';
export default defineField({
universalIdentifier: PARTNER_CONTENTS_ON_COMPANY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerContents',
label: 'Partner Content',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENTS_ON_PARTNER_FIELD_ID, PARTNER_CONTENT_PARTNER_FIELD_ID } from './partner-content-partner.field';
export default defineField({
universalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partnerContents',
label: 'Partner Content',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENTS_ON_PERSON_FIELD_ID, PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID } from './partner-content-customer-person.field';
export default defineField({
universalIdentifier: PARTNER_CONTENTS_ON_PERSON_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerContents',
label: 'Partner Content',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_ON_OPPORTUNITY_FIELD_ID = 'd9eeacaa-2f9e-44cc-b5f6-5e1526256d49';
export const OPPORTUNITIES_ON_PARTNER_FIELD_ID = '8c04a5d4-c423-487e-bd78-7142a75b2896';
export default defineField({
universalIdentifier: PARTNER_ON_OPPORTUNITY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: OPPORTUNITIES_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_ON_PERSON_FIELD_ID = 'b49eeaa3-c7ef-4a5c-8c47-d2c234b5122f';
export const PERSONS_ON_PARTNER_FIELD_ID = '2c0e2035-db52-434b-9706-cd2210009a86';
export default defineField({
universalIdentifier: PARTNER_ON_PERSON_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PERSONS_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNERS_ON_COMPANY_FIELD_ID, PARTNER_COMPANY_FIELD_ID } from './partner-company.field';
export default defineField({
universalIdentifier: PARTNERS_ON_COMPANY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.RELATION,
name: 'partners',
label: 'Partners',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_COMPANY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'c6862035-bf2e-42ea-86c4-bf46636f7859',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.TEXT,
name: 'discord',
label: 'Discord',
isNullable: true,
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_ON_PERSON_FIELD_ID, PERSONS_ON_PARTNER_FIELD_ID } from './partner-on-person.field';
export default defineField({
universalIdentifier: PERSONS_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'persons',
label: 'Persons',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_ON_PERSON_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,220 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// Helpers
async function createOpp(client: CoreApiClient, name: string) {
const r = await client.mutation({
createOpportunity: {
__args: { data: { name } },
id: true,
matchStatus: true,
},
} as any);
return (r as any).createOpportunity as { id: string; matchStatus: string };
}
async function destroyOpp(client: CoreApiClient, id: string) {
await client
.mutation({ destroyOpportunity: { __args: { id }, id: true } } as any)
.catch(() => {});
}
async function getOpp(client: CoreApiClient, id: string) {
const r = await client.query({
opportunity: {
__args: { filter: { id: { eq: id } } },
id: true,
matchStatus: true,
partner: { id: true },
},
} as any);
return (r as any).opportunity as {
id: string;
matchStatus: string;
partner: { id: string } | null;
};
}
async function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
describe('on-opportunity-auto-match (success path)', () => {
let client: CoreApiClient;
const created: string[] = [];
let seededPartnerId: string | null = null;
beforeAll(async () => {
client = new CoreApiClient();
// Ensure at least one ACTIVE+AVAILABLE partner exists for the function to pick.
const existing = await client.query({
partners: {
__args: {
filter: { validationStage: { eq: 'VALIDATED' }, availability: { eq: 'AVAILABLE' } },
first: 1,
},
edges: { node: { id: true } },
},
} as any);
if ((existing as any).partners.edges.length === 0) {
const r = await client.mutation({
createPartner: {
__args: {
data: {
name: '[test] auto-match seed partner',
slug: 'test-auto-match-seed',
validationStage: 'VALIDATED',
availability: 'AVAILABLE',
},
},
id: true,
},
} as any);
seededPartnerId = (r as any).createPartner.id;
}
});
afterAll(async () => {
if (seededPartnerId) {
await client
.mutation({ destroyPartner: { __args: { id: seededPartnerId }, id: true } } as any)
.catch(() => {});
}
});
beforeEach(() => {
client = new CoreApiClient();
});
afterEach(async () => {
for (const id of created) await destroyOpp(client, id);
created.length = 0;
});
it('defaults a new opportunity to TO_BE_MATCHED', async () => {
const opp = await createOpp(client, `[test] default status ${Date.now()}`);
created.push(opp.id);
expect(opp.matchStatus).toBe('TO_BE_MATCHED');
});
it('assigns a partner and flips to MATCHED when set to AUTO_MATCH', async () => {
const opp = await createOpp(client, `[test] auto match ${Date.now()}`);
created.push(opp.id);
await client.mutation({
updateOpportunity: {
__args: { id: opp.id, data: { matchStatus: 'AUTO_MATCH' } },
id: true,
},
} as any);
// Logic function runs async (~2s per spec). Poll up to 10s.
let final = await getOpp(client, opp.id);
for (let i = 0; i < 20 && final.matchStatus === 'AUTO_MATCH'; i++) {
await sleep(500);
final = await getOpp(client, opp.id);
}
expect(final.matchStatus).toBe('MATCHED');
expect(final.partner?.id).toBeDefined();
});
});
describe('on-opportunity-auto-match (failure path)', () => {
let client: CoreApiClient;
const createdOpps: string[] = [];
const flippedPartners: Array<{ id: string; prevAvailability: string }> = [];
beforeEach(() => {
client = new CoreApiClient();
});
afterEach(async () => {
// Restore partner availabilities first so other tests find a partner.
for (const p of flippedPartners) {
await client
.mutation({
updatePartner: {
__args: { id: p.id, data: { availability: p.prevAvailability } },
id: true,
},
} as any)
.catch(() => {});
}
flippedPartners.length = 0;
for (const id of createdOpps) await destroyOpp(client, id);
createdOpps.length = 0;
});
it('hands off to MANUAL_MATCH with a Note when no partner is available', async () => {
// Make every AVAILABLE partner UNAVAILABLE for the duration of this test.
const all = await client.query({
partners: {
__args: {
filter: { availability: { eq: 'AVAILABLE' } },
first: 100,
},
edges: { node: { id: true, availability: true } },
},
} as any);
const edges = ((all as any)?.partners?.edges ?? []) as Array<{
node: { id: string; availability: string };
}>;
for (const e of edges) {
flippedPartners.push({ id: e.node.id, prevAvailability: e.node.availability });
await client.mutation({
updatePartner: {
__args: { id: e.node.id, data: { availability: 'UNAVAILABLE' } },
id: true,
},
} as any);
}
const opp = await createOpp(client, `[test] no-partner ${Date.now()}`);
createdOpps.push(opp.id);
await client.mutation({
updateOpportunity: {
__args: { id: opp.id, data: { matchStatus: 'AUTO_MATCH' } },
id: true,
},
} as any);
let final = await getOpp(client, opp.id);
for (let i = 0; i < 20 && final.matchStatus === 'AUTO_MATCH'; i++) {
await sleep(500);
final = await getOpp(client, opp.id);
}
expect(final.matchStatus).toBe('MANUAL_MATCH');
expect(final.partner).toBeNull();
// Confirm a Note was attached.
const notes = await client.query({
noteTargets: {
__args: {
filter: { targetOpportunityId: { eq: opp.id } },
first: 10,
},
edges: {
node: {
id: true,
note: { id: true, title: true, bodyV2: { markdown: true } },
},
},
},
} as any);
const noteEdges = ((notes as any)?.noteTargets?.edges ?? []) as Array<{
node: { note: { title: string; bodyV2: { markdown: string } } };
}>;
const autoMatchNote = noteEdges.find((e) =>
e.node.note.title.toLowerCase().includes('auto-match'),
);
expect(autoMatchNote).toBeDefined();
expect(autoMatchNote!.node.note.bodyV2.markdown).toContain('No partners');
});
});
@@ -1,250 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import {
handler,
type SubmitPartnerApplicationInput,
type SubmitPartnerApplicationResult,
} from '../submit-partner-application.logic-function';
const TEST_SECRET = 'test-secret-abc123';
process.env.PARTNER_APPLICATION_SECRET = TEST_SECRET;
const client = new CoreApiClient();
const baseInput = (overrides: Partial<SubmitPartnerApplicationInput> = {}): SubmitPartnerApplicationInput => ({
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada.test@example.com',
companyName: 'Analytical Engines Ltd',
...overrides,
});
const authedEvent = (input: SubmitPartnerApplicationInput) => ({
body: input,
headers: { 'x-application-secret': TEST_SECRET },
});
const createdPartnerIds: string[] = [];
const createdPersonIds: string[] = [];
const createdCompanyIds: string[] = [];
async function cleanup(): Promise<void> {
for (const id of createdPartnerIds.splice(0)) {
await client.mutation({ destroyPartner: { __args: { id }, id: true } }).catch(() => {});
}
for (const id of createdPersonIds.splice(0)) {
await client.mutation({ destroyPerson: { __args: { id }, id: true } }).catch(() => {});
}
for (const id of createdCompanyIds.splice(0)) {
await client.mutation({ destroyCompany: { __args: { id }, id: true } }).catch(() => {});
}
}
async function trackCreated(result: SubmitPartnerApplicationResult): Promise<void> {
if (!result.ok) return;
createdPartnerIds.push(result.partnerId);
const fetched = await client.query({
partner: {
__args: { filter: { id: { eq: result.partnerId } } },
id: true,
company: { id: true },
persons: { edges: { node: { id: true } } },
},
});
const node = fetched.partner;
if (!node) return;
if (node.company) createdCompanyIds.push(node.company.id);
for (const edge of node.persons?.edges ?? []) createdPersonIds.push(edge.node.id);
}
beforeAll(async () => {
await client.query({ partners: { __args: { first: 1 }, edges: { node: { id: true } } } });
});
afterEach(async () => {
await cleanup();
});
describe('submit-partner-application handler — auth', () => {
it('returns unauthorized when the x-application-secret header is missing', async () => {
const result = await handler({ body: baseInput({ email: 'noauth@example.com' }), headers: {} });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe('unauthorized');
});
it('returns unauthorized when the x-application-secret header is wrong', async () => {
const result = await handler({
body: baseInput({ email: 'badauth@example.com' }),
headers: { 'x-application-secret': 'nope' },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe('unauthorized');
});
});
describe('submit-partner-application handler — upsert', () => {
it('creates Company, Person, and Partner on first submission and returns created: true', async () => {
const result = await handler(authedEvent(baseInput({ email: 'create.case@example.com', companyName: 'YC Agency' })));
await trackCreated(result);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.created).toBe(true);
expect(result.partnerId).toMatch(/^[0-9a-f-]{36}$/);
const partner = await client.query({
partner: {
__args: { filter: { id: { eq: result.partnerId } } },
id: true,
name: true,
slug: true,
validationStage: true,
reviewed: true,
partnerTier: true,
company: { id: true, name: true },
persons: { edges: { node: { id: true, name: { firstName: true, lastName: true }, emails: { primaryEmail: true } } } },
},
});
const node = partner.partner;
expect(node?.name).toBe('YC Agency');
expect(node?.slug).toBe('yc-agency');
expect(node?.validationStage).toBe('APPLICATION');
expect(node?.reviewed).toBe(false);
expect(node?.partnerTier).toBe('NEW');
expect(node?.company?.name).toBe('YC Agency');
expect(node?.persons?.edges).toHaveLength(1);
const personNode = node?.persons?.edges?.[0]?.node;
expect(personNode?.emails?.primaryEmail).toBe('create.case@example.com');
expect(personNode?.name?.firstName).toBe('Ada');
expect(personNode?.name?.lastName).toBe('Lovelace');
});
it('returns created: false and updates fields on resubmission for the same email', async () => {
const first = await handler(authedEvent(baseInput({ email: 'update.case@example.com', city: 'London' })));
await trackCreated(first);
expect(first.ok).toBe(true);
if (!first.ok) return;
const second = await handler(
authedEvent(
baseInput({
email: 'update.case@example.com',
city: 'Paris',
partnerScope: ['ADVISORY', 'SOLUTIONING'],
typeOfTeam: 'SOLO',
hourlyRate: 175,
}),
),
);
expect(second.ok).toBe(true);
if (!second.ok) return;
expect(second.created).toBe(false);
expect(second.partnerId).toBe(first.partnerId);
const partner = await client.query({
partner: {
__args: { filter: { id: { eq: second.partnerId } } },
id: true,
city: true,
partnerScope: true,
typeOfTeam: true,
hourlyRate: { amountMicros: true, currencyCode: true },
},
});
const node = partner.partner;
expect(node?.city).toBe('Paris');
expect(node?.partnerScope).toEqual(['ADVISORY', 'SOLUTIONING']);
expect(node?.typeOfTeam).toBe('SOLO');
expect(node?.hourlyRate).toEqual({ amountMicros: 175_000_000, currencyCode: 'USD' });
});
it('preserves staff-owned columns (validationStage, ranking, reviewed) on resubmission', async () => {
const first = await handler(authedEvent(baseInput({ email: 'staff.preserve@example.com' })));
await trackCreated(first);
expect(first.ok).toBe(true);
if (!first.ok) return;
await client.mutation({
updatePartner: {
__args: {
id: first.partnerId,
data: { validationStage: 'VALIDATED', reviewed: true, ranking: 'RATING_4' },
},
id: true,
},
});
const second = await handler(authedEvent(baseInput({ email: 'staff.preserve@example.com', city: 'Berlin' })));
expect(second.ok).toBe(true);
if (!second.ok) return;
const partner = await client.query({
partner: {
__args: { filter: { id: { eq: second.partnerId } } },
validationStage: true,
reviewed: true,
ranking: true,
city: true,
},
});
const node = partner.partner;
expect(node?.validationStage).toBe('VALIDATED');
expect(node?.reviewed).toBe(true);
expect(node?.ranking).toBe('RATING_4');
expect(node?.city).toBe('Berlin');
});
it('accepts new category values and stores applicationNotes', async () => {
const result = await handler(
authedEvent(
baseInput({
email: 'cat.test@example.com',
partnerScope: ['ADVISORY', 'SOLUTIONING'],
applicationNotes: 'Workspace https://app.twenty.com/ws · refs: Acme',
}),
),
);
expect(result.ok).toBe(true);
if (!result.ok) return;
await trackCreated(result);
const fetched = await client.query({
partner: {
__args: { filter: { id: { eq: result.partnerId } } },
partnerScope: true,
applicationNotes: true,
},
});
const node = fetched.partner;
expect(node?.partnerScope).toEqual(
expect.arrayContaining(['ADVISORY', 'SOLUTIONING']),
);
expect(node?.applicationNotes).toContain('Acme');
});
it('rejects a legacy scope value as invalid_input', async () => {
const result = await handler(
authedEvent(baseInput({ email: 'legacy@example.com', partnerScope: ['APPS'] })),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe('invalid_input');
});
it('returns ok: false on malformed input (empty email)', async () => {
const result = await handler(
authedEvent({
firstName: 'Ada',
lastName: 'Lovelace',
email: '',
companyName: 'Analytical Engines Ltd',
}),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe('invalid_input');
});
});
@@ -1,73 +0,0 @@
import { describe, expect, it } from 'vitest';
import { submitPartnerApplicationSchema } from '../submit-partner-application.logic-function';
const base = {
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.com',
companyName: 'Analytical Engines',
};
describe('submitPartnerApplicationSchema', () => {
it('accepts a minimal valid application', () => {
expect(submitPartnerApplicationSchema.safeParse(base).success).toBe(true);
});
it('accepts the new partnerScope categories', () => {
const result = submitPartnerApplicationSchema.safeParse({
...base,
partnerScope: ['ADVISORY', 'SOLUTIONING', 'HOSTING'],
});
expect(result.success).toBe(true);
});
it('rejects legacy / unknown partnerScope values', () => {
expect(
submitPartnerApplicationSchema.safeParse({ ...base, partnerScope: ['APPS'] })
.success,
).toBe(false);
});
it('rejects an unknown typeOfTeam', () => {
expect(
submitPartnerApplicationSchema.safeParse({ ...base, typeOfTeam: 'FREELANCE' })
.success,
).toBe(false);
});
it('rejects an unknown country but accepts a known one with languages', () => {
expect(
submitPartnerApplicationSchema.safeParse({ ...base, country: 'ATLANTIS' })
.success,
).toBe(false);
expect(
submitPartnerApplicationSchema.safeParse({
...base,
country: 'FRANCE',
languages: ['ENGLISH', 'FRENCH'],
}).success,
).toBe(true);
});
it('requires a non-empty email and companyName', () => {
expect(
submitPartnerApplicationSchema.safeParse({ ...base, email: '' }).success,
).toBe(false);
expect(
submitPartnerApplicationSchema.safeParse({ ...base, companyName: '' })
.success,
).toBe(false);
});
it('accepts optional notes and commercials', () => {
const result = submitPartnerApplicationSchema.safeParse({
...base,
applicationNotes: 'Looking forward to partnering.',
hourlyRate: 150,
projectBudgetMin: 5000,
skills: ['React', 'PostgreSQL'],
});
expect(result.success).toBe(true);
});
});
@@ -1,105 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
export const GET_PARTNER_BY_SLUG_LOGIC_FUNCTION_ID =
'5e3e7b88-2cf2-4f56-9a4a-46c4c1d6b0bb';
type CurrencyValue = { amountMicros: number; currencyCode: string } | null;
type LinkValue = { primaryLinkUrl: string | null } | null;
type Partner = {
id: string;
name: string | null;
slug: string | null;
introduction: string | null;
languagesSpoken: string[] | null;
deploymentExpertise: string[] | null;
partnerScope: string[] | null;
region: string[] | null;
calendarLink: LinkValue;
hourlyRate: CurrencyValue;
projectBudgetMin: CurrencyValue;
linkedin: LinkValue;
profilePicture: LinkValue;
skills: string[] | null;
city: string | null;
country: string | null;
};
type GetPartnerBySlugResult =
| { ok: true; partner: Partner }
| { ok: false; reason: 'NOT_FOUND' | string };
const handler = async (input: {
queryStringParameters?: { slug?: string };
}): Promise<GetPartnerBySlugResult> => {
const slug = input?.queryStringParameters?.slug;
if (typeof slug !== 'string' || slug.length === 0) {
return { ok: false, reason: 'Missing slug query parameter' };
}
try {
const client = new CoreApiClient();
const result = await client.query({
partners: {
__args: {
filter: {
slug: { eq: slug },
validationStage: { eq: 'VALIDATED' },
availability: { eq: 'AVAILABLE' },
},
first: 1,
},
edges: {
node: {
id: true,
name: true,
slug: true,
introduction: true,
languagesSpoken: true,
deploymentExpertise: true,
partnerScope: true,
region: true,
calendarLink: { primaryLinkUrl: true },
hourlyRate: { amountMicros: true, currencyCode: true },
projectBudgetMin: { amountMicros: true, currencyCode: true },
linkedin: { primaryLinkUrl: true },
profilePicture: { primaryLinkUrl: true },
skills: true,
city: true,
country: true,
},
},
},
} as any);
const edges = (result?.partners?.edges ?? []) as Array<{ node: Partner }>;
const partner = edges[0]?.node;
if (!partner) {
return { ok: false, reason: 'NOT_FOUND' };
}
return { ok: true, partner };
} catch (err) {
return {
ok: false,
reason: err instanceof Error ? err.message : String(err),
};
}
};
export default defineLogicFunction({
universalIdentifier: GET_PARTNER_BY_SLUG_LOGIC_FUNCTION_ID,
name: 'get-partner-by-slug',
description:
'Returns a single VALIDATED + AVAILABLE partner by slug, or NOT_FOUND.',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: '/partner-by-slug',
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -1,80 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
export const LIST_AVAILABLE_PARTNERS_LOGIC_FUNCTION_ID =
'0f91164f-f492-41e8-9bb0-481be5a3d5b9';
// CoreApiClient is codegenerated from the synced workspace schema, so the query
// selection is strictly typed. Keep the fetch in one place and derive the
// response shape from it, so the HTTP contract can never drift from what we
// actually ask the API for.
const queryAvailablePartners = (client: CoreApiClient) =>
client.query({
partners: {
__args: {
filter: {
validationStage: { eq: 'VALIDATED' },
availability: { eq: 'AVAILABLE' },
slug: { neq: '' },
},
orderBy: [{ name: 'AscNullsLast' }],
first: 100,
},
edges: {
node: {
id: true,
name: true,
slug: true,
introduction: true,
languagesSpoken: true,
deploymentExpertise: true,
partnerScope: true,
region: true,
calendarLink: { primaryLinkUrl: true },
hourlyRate: { amountMicros: true, currencyCode: true },
projectBudgetMin: { amountMicros: true, currencyCode: true },
linkedin: { primaryLinkUrl: true },
profilePicture: { primaryLinkUrl: true },
skills: true,
city: true,
country: true,
},
},
},
});
type AvailablePartner = NonNullable<
Awaited<ReturnType<typeof queryAvailablePartners>>['partners']
>['edges'][number]['node'];
type ListAvailablePartnersResult =
| { ok: true; count: number; partners: AvailablePartner[] }
| { ok: false; reason: string };
const handler = async (): Promise<ListAvailablePartnersResult> => {
try {
const client = new CoreApiClient();
const result = await queryAvailablePartners(client);
const partners = (result.partners?.edges ?? []).map((edge) => edge.node);
return { ok: true, count: partners.length, partners };
} catch (err) {
return {
ok: false,
reason: err instanceof Error ? err.message : String(err),
};
}
};
export default defineLogicFunction({
universalIdentifier: LIST_AVAILABLE_PARTNERS_LOGIC_FUNCTION_ID,
name: 'list-available-partners',
description: 'Returns all partners with validationStage=VALIDATED and availability=AVAILABLE.',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: '/partners',
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -1,105 +0,0 @@
import { DatabaseEventPayload, defineLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { ON_OPP_AUTO_MATCH_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
// Fires when an opportunity's matchStatus is set to 'AUTO_MATCH'.
// Happy path: picks the longest-idle ACTIVE+AVAILABLE partner, assigns it, flips to 'MATCHED'.
// Failure path: no partner available → flips to 'MANUAL_MATCH' and creates an audit Note.
const handler = async (payload: DatabaseEventPayload) => {
const props = payload.properties as {
after?: { id: string; matchStatus?: string; partnerId?: string | null };
before?: { matchStatus?: string };
updatedFields?: string[];
};
if (!props.updatedFields?.includes('matchStatus')) return {};
if (props.after?.matchStatus !== 'AUTO_MATCH') return {};
if (props.before?.matchStatus === 'AUTO_MATCH') return {};
if (props.after.partnerId) return {};
const client = new CoreApiClient();
const partnersResult = await client.query({
partners: {
__args: {
filter: {
validationStage: { eq: 'VALIDATED' },
availability: { eq: 'AVAILABLE' },
},
orderBy: [{ lastMatchAt: 'AscNullsFirst' }],
first: 1,
},
edges: { node: { id: true, lastMatchAt: true } },
},
} as any);
const topPartner = (partnersResult.partners as any).edges[0]?.node;
if (!topPartner) {
const noteBody =
`Auto-match attempted ${new Date().toISOString()}.\n` +
`No partners matched (status=ACTIVE, availability=AVAILABLE).\n` +
`Status moved to Manual Match — pick a partner manually or ` +
`update partner availability and retry by setting status back to Auto Match.`;
try {
const noteResult = await client.mutation({
createNote: {
__args: { data: { title: 'Auto-match failed', bodyV2: { markdown: noteBody } } },
id: true,
},
} as any);
const noteId = (noteResult as any).createNote.id as string;
await client.mutation({
createNoteTarget: {
__args: { data: { noteId, targetOpportunityId: props.after.id } },
id: true,
},
} as any);
} catch {
// Note creation is best-effort; status flip is the critical action.
}
await client.mutation({
updateOpportunity: {
__args: { id: props.after.id, data: { matchStatus: 'MANUAL_MATCH' } },
id: true,
},
} as any);
return { matched: false, reason: 'no_partner_available' };
}
await client.mutation({
updateOpportunity: {
__args: {
id: props.after.id,
data: { partnerId: topPartner.id, matchStatus: 'MATCHED' },
},
id: true,
},
} as any);
await client.mutation({
updatePartner: {
__args: {
id: topPartner.id,
data: { lastMatchAt: new Date().toISOString() },
},
id: true,
},
} as any);
return { matched: true, partnerId: topPartner.id };
};
export default defineLogicFunction({
universalIdentifier: ON_OPP_AUTO_MATCH_FN_UNIVERSAL_IDENTIFIER,
name: 'on-opportunity-auto-match',
timeoutSeconds: 10,
handler,
databaseEventTriggerSettings: {
eventName: 'opportunity.updated',
},
});
@@ -1,48 +0,0 @@
import { InstallPayload, definePostInstallLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (_payload: InstallPayload) => {
const client = new CoreApiClient();
const partnerResult = await client.mutation({
createPartner: {
__args: {
data: {
name: 'Test Partner Alpha',
validationStage: 'VALIDATED',
availability: 'AVAILABLE',
languagesSpoken: ['ENGLISH', 'FRENCH'],
deploymentExpertise: ['CLOUD', 'SELF_HOST'],
region: 'EUROPE',
},
},
id: true,
},
} as any);
const partnerId = (partnerResult.createPartner as any).id;
await client.mutation({
createPerson: {
__args: {
data: {
name: {
firstName: 'Test Partner',
lastName: 'Contact',
},
partnerId,
},
},
id: true,
},
} as any);
return { seeded: true, partnerId };
};
export default definePostInstallLogicFunction({
universalIdentifier: 'f92bad2e-5905-4757-96ee-af9869d4ca0c',
name: 'post-install',
handler,
shouldRunSynchronously: true,
});
@@ -1,273 +0,0 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { z } from 'zod';
import { slugify } from '../scripts/slugify';
export const SUBMIT_PARTNER_APPLICATION_LOGIC_FUNCTION_ID =
'7b1e2c5f-3a14-4f7d-8e91-0b5e2a3c4d76';
const PARTNER_COUNTRY_VALUES = [
'AFGHANISTAN','ALBANIA','ALGERIA','ANDORRA','ANGOLA','ANTIGUA_AND_BARBUDA','ARGENTINA','ARMENIA','AUSTRALIA','AUSTRIA','AZERBAIJAN','BAHAMAS','BAHRAIN','BANGLADESH','BARBADOS','BELARUS','BELGIUM','BELIZE','BENIN','BHUTAN','BOLIVIA','BOSNIA_AND_HERZEGOVINA','BOTSWANA','BRAZIL','BRUNEI','BULGARIA','BURKINA_FASO','BURUNDI','CAMBODIA','CAMEROON','CANADA','CAPE_VERDE','CENTRAL_AFRICAN_REPUBLIC','CHAD','CHILE','CHINA','COLOMBIA','COMOROS','CONGO','DR_CONGO','COSTA_RICA','CROATIA','CUBA','CYPRUS','CZECH_REPUBLIC','DENMARK','DJIBOUTI','DOMINICA','DOMINICAN_REPUBLIC','ECUADOR','EGYPT','EL_SALVADOR','EQUATORIAL_GUINEA','ERITREA','ESTONIA','ESWATINI','ETHIOPIA','FIJI','FINLAND','FRANCE','GABON','GAMBIA','GEORGIA','GERMANY','GHANA','GREECE','GRENADA','GUATEMALA','GUINEA','GUINEA_BISSAU','GUYANA','HAITI','HONDURAS','HUNGARY','ICELAND','INDIA','INDONESIA','IRAN','IRAQ','IRELAND','ISRAEL','ITALY','IVORY_COAST','JAMAICA','JAPAN','JORDAN','KAZAKHSTAN','KENYA','KIRIBATI','KOSOVO','KUWAIT','KYRGYZSTAN','LAOS','LATVIA','LEBANON','LESOTHO','LIBERIA','LIBYA','LIECHTENSTEIN','LITHUANIA','LUXEMBOURG','MADAGASCAR','MALAWI','MALAYSIA','MALDIVES','MALI','MALTA','MARSHALL_ISLANDS','MAURITANIA','MAURITIUS','MEXICO','MICRONESIA','MOLDOVA','MONACO','MONGOLIA','MONTENEGRO','MOROCCO','MOZAMBIQUE','MYANMAR','NAMIBIA','NAURU','NEPAL','NETHERLANDS','NEW_ZEALAND','NICARAGUA','NIGER','NIGERIA','NORTH_KOREA','NORTH_MACEDONIA','NORWAY','OMAN','PAKISTAN','PALAU','PALESTINE','PANAMA','PAPUA_NEW_GUINEA','PARAGUAY','PERU','PHILIPPINES','POLAND','PORTUGAL','QATAR','ROMANIA','RUSSIA','RWANDA','SAINT_KITTS_AND_NEVIS','SAINT_LUCIA','SAINT_VINCENT','SAMOA','SAN_MARINO','SAO_TOME_AND_PRINCIPE','SAUDI_ARABIA','SENEGAL','SERBIA','SEYCHELLES','SIERRA_LEONE','SINGAPORE','SLOVAKIA','SLOVENIA','SOLOMON_ISLANDS','SOMALIA','SOUTH_AFRICA','SOUTH_KOREA','SOUTH_SUDAN','SPAIN','SRI_LANKA','SUDAN','SURINAME','SWEDEN','SWITZERLAND','SYRIA','TAIWAN','TAJIKISTAN','TANZANIA','THAILAND','TIMOR_LESTE','TOGO','TONGA','TRINIDAD_AND_TOBAGO','TUNISIA','TURKEY','TURKMENISTAN','TUVALU','UGANDA','UKRAINE','UNITED_ARAB_EMIRATES','UNITED_KINGDOM','UNITED_STATES','URUGUAY','UZBEKISTAN','VANUATU','VATICAN','VENEZUELA','VIETNAM','YEMEN','ZAMBIA','ZIMBABWE',
] as const;
const PARTNER_LANGUAGE_VALUES = [
'ENGLISH','FRENCH','GERMAN','SPANISH','PORTUGUESE','ITALIAN','DUTCH','ARABIC','CHINESE','JAPANESE','RUSSIAN','HINDI',
] as const;
const PARTNER_SCOPE_VALUES = [
'ADVISORY','SOLUTIONING','DEVELOPMENT','HOSTING','SUPPORT',
] as const;
const PARTNER_TYPE_OF_TEAM_VALUES = ['SOLO','AGENCY'] as const;
// The request contract. zod is the single source of truth: it validates the
// incoming body at runtime and the input type is inferred from it, so the two
// can never drift. Enum-valued fields are constrained to the same option sets
// the Partner object accepts.
export const submitPartnerApplicationSchema = z.object({
firstName: z.string().trim().min(1),
lastName: z.string(),
email: z.string().trim().min(1),
companyName: z.string().trim().min(1),
domainName: z.string().optional(),
linkedin: z.string().optional(),
city: z.string().optional(),
country: z.enum(PARTNER_COUNTRY_VALUES).optional(),
languages: z.array(z.enum(PARTNER_LANGUAGE_VALUES)).optional(),
typeOfTeam: z.enum(PARTNER_TYPE_OF_TEAM_VALUES).optional(),
partnerScope: z.array(z.enum(PARTNER_SCOPE_VALUES)).optional(),
skills: z.array(z.string()).optional(),
applicationNotes: z.string().optional(),
hourlyRate: z.number().optional(),
projectBudgetMin: z.number().optional(),
calendarLink: z.string().optional(),
});
export type SubmitPartnerApplicationInput = z.infer<
typeof submitPartnerApplicationSchema
>;
export type SubmitPartnerApplicationResult =
| { ok: true; created: boolean; partnerId: string }
| { ok: false; reason: string };
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function toMicros(usd: number | undefined): { amountMicros: number; currencyCode: 'USD' } | undefined {
if (typeof usd !== 'number' || !Number.isFinite(usd) || usd < 0) return undefined;
return { amountMicros: Math.round(usd * 1_000_000), currencyCode: 'USD' };
}
function buildApplicationNotes(input: SubmitPartnerApplicationInput): string | null {
return isNonEmptyString(input.applicationNotes) ? input.applicationNotes.trim() : null;
}
// Mirrors the subset of Partner{Create,Update}Input this handler writes. The
// enum-typed columns are narrowed from the validated string inputs below.
type PartnerFieldsForUpsert = {
name: string;
linkedin?: { primaryLinkUrl: string };
city?: string;
country?: CoreSchema.PartnerCountryEnum;
languagesSpoken?: CoreSchema.PartnerLanguagesSpokenEnum[];
typeOfTeam?: CoreSchema.PartnerTypeOfTeamEnum;
partnerScope?: CoreSchema.PartnerPartnerScopeEnum[];
skills?: string[];
hourlyRate?: { amountMicros: number; currencyCode: 'USD' };
projectBudgetMin?: { amountMicros: number; currencyCode: 'USD' };
calendarLink?: { primaryLinkUrl: string };
applicationNotes?: string | null;
};
function buildPartnerFields(input: SubmitPartnerApplicationInput): PartnerFieldsForUpsert {
const fields: PartnerFieldsForUpsert = {
name: input.companyName.trim(),
};
if (isNonEmptyString(input.linkedin)) fields.linkedin = { primaryLinkUrl: input.linkedin.trim() };
if (isNonEmptyString(input.city)) fields.city = input.city.trim();
// validate() has already checked these against the allowed value sets, so
// narrowing the validated strings to their enum types here is sound.
if (input.country !== undefined) fields.country = input.country as CoreSchema.PartnerCountryEnum;
if (input.languages !== undefined && input.languages.length > 0)
fields.languagesSpoken = input.languages as CoreSchema.PartnerLanguagesSpokenEnum[];
if (input.typeOfTeam !== undefined) fields.typeOfTeam = input.typeOfTeam as CoreSchema.PartnerTypeOfTeamEnum;
if (input.partnerScope !== undefined && input.partnerScope.length > 0)
fields.partnerScope = input.partnerScope as CoreSchema.PartnerPartnerScopeEnum[];
if (input.skills !== undefined && input.skills.length > 0) fields.skills = input.skills.filter(isNonEmptyString);
const hourly = toMicros(input.hourlyRate);
if (hourly) fields.hourlyRate = hourly;
const min = toMicros(input.projectBudgetMin);
if (min) fields.projectBudgetMin = min;
if (isNonEmptyString(input.calendarLink)) fields.calendarLink = { primaryLinkUrl: input.calendarLink.trim() };
const notes = buildApplicationNotes(input);
if (notes !== null) fields.applicationNotes = notes;
return fields;
}
type SubmitPartnerApplicationEvent = {
headers?: Record<string, string | undefined>;
body?: unknown;
};
const APPLICATION_SECRET_HEADER = 'x-application-secret';
export const handler = async (
event: SubmitPartnerApplicationEvent | SubmitPartnerApplicationInput,
): Promise<SubmitPartnerApplicationResult> => {
// Accept either { body, headers } (HTTP) or a flat input object (direct call from tests).
const looksLikeEvent =
typeof event === 'object' &&
event !== null &&
('body' in event || 'headers' in event);
const headers = looksLikeEvent
? (event as SubmitPartnerApplicationEvent).headers ?? {}
: {};
const rawInput = looksLikeEvent
? (event as SubmitPartnerApplicationEvent).body
: event;
// Shared-secret guard. The Twenty SDK's isAuthRequired flag only accepts
// user-session JWTs, not workspace API keys, so we authenticate at the
// handler level via a custom header allowlisted in forwardedRequestHeaders.
const expectedSecret = process.env.PARTNER_APPLICATION_SECRET;
if (!isNonEmptyString(expectedSecret)) {
return { ok: false, reason: 'unauthorized' };
}
const providedSecret = headers[APPLICATION_SECRET_HEADER];
if (providedSecret !== expectedSecret) {
return { ok: false, reason: 'unauthorized' };
}
const parsed = submitPartnerApplicationSchema.safeParse(rawInput);
if (!parsed.success) {
return { ok: false, reason: 'invalid_input' };
}
const input = parsed.data;
try {
const client = new CoreApiClient();
const email = input.email.trim();
const partnerFields = buildPartnerFields(input);
const personLookup = await client.query({
people: {
__args: {
filter: { emails: { primaryEmail: { eq: email } } },
first: 1,
},
edges: {
node: {
id: true,
partner: { id: true, company: { id: true } },
},
},
},
});
const existingEdge = personLookup.people?.edges?.[0]?.node;
if (existingEdge && existingEdge.partner) {
const partnerId = existingEdge.partner.id;
await client.mutation({
updatePartner: {
__args: { id: partnerId, data: partnerFields },
id: true,
},
});
await client.mutation({
updatePerson: {
__args: {
id: existingEdge.id,
data: {
name: { firstName: input.firstName.trim(), lastName: input.lastName.trim() },
},
},
id: true,
},
});
return { ok: true, created: false, partnerId };
}
const companyData: CoreSchema.CompanyCreateInput = { name: input.companyName.trim() };
if (isNonEmptyString(input.domainName)) {
companyData.domainName = { primaryLinkUrl: input.domainName.trim() };
}
const companyResult = await client.mutation({
createCompany: { __args: { data: companyData }, id: true },
});
const companyId = companyResult.createCompany?.id;
if (companyId === undefined) {
throw new Error('createCompany did not return an id');
}
const partnerResult = await client.mutation({
createPartner: {
__args: {
data: {
...partnerFields,
slug: slugify(input.companyName),
validationStage: 'APPLICATION',
reviewed: false,
partnerTier: 'NEW',
companyId,
},
},
id: true,
},
});
const partnerId = partnerResult.createPartner?.id;
if (partnerId === undefined) {
throw new Error('createPartner did not return an id');
}
if (existingEdge) {
await client.mutation({
updatePerson: {
__args: {
id: existingEdge.id,
data: {
partnerId,
name: { firstName: input.firstName.trim(), lastName: input.lastName.trim() },
},
},
id: true,
},
});
} else {
await client.mutation({
createPerson: {
__args: {
data: {
name: { firstName: input.firstName.trim(), lastName: input.lastName.trim() },
emails: { primaryEmail: email },
partnerId,
companyId,
},
},
id: true,
},
});
}
return { ok: true, created: true, partnerId };
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
}
};
export default defineLogicFunction({
universalIdentifier: SUBMIT_PARTNER_APPLICATION_LOGIC_FUNCTION_ID,
name: 'submit-partner-application',
description: 'Receive a partner application from the website and idempotently upsert Partner / Person / Company.',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/partner-applications',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: [APPLICATION_SECRET_HEADER],
},
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER,
ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconTargetArrow',
position: 3,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
MATCHES_OVERVIEW_NAV_UNIVERSAL_IDENTIFIER,
MATCHES_OVERVIEW_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: MATCHES_OVERVIEW_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconLayoutKanban',
position: 1,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: MATCHES_OVERVIEW_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER,
PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconUserPlus',
position: 1,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER,
PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconQuote',
position: 3,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_DEALS_NAV_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: PARTNER_DEALS_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconHandshake',
position: 2,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,10 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
type: NavigationMenuItemType.FOLDER,
name: 'Partners',
icon: 'IconBuildingStore',
color: 'purple',
position: -1,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
PARTNERS_NAV_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: PARTNERS_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconBuildingStore',
position: 2,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,9 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
type: NavigationMenuItemType.FOLDER,
name: 'Pipeline',
icon: 'IconTargetArrow',
position: -2,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER,
VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconCircleCheck',
position: 0,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +0,0 @@
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,
});
@@ -1,79 +0,0 @@
import { FieldType, defineObject } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineObject({
universalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'partnerContent',
namePlural: 'partnerContents',
labelSingular: 'Partner Content',
labelPlural: 'Partner Content',
description: 'Marketing content involving a partner: quotes, case studies, logos',
icon: 'IconQuote',
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6',
fields: [
{
universalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6',
type: FieldType.TEXT,
name: 'name',
label: 'Name',
icon: 'IconTag',
defaultValue: "''",
},
{
universalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692',
type: FieldType.MULTI_SELECT,
name: 'contentType',
label: 'Content Type',
icon: 'IconCategory',
isNullable: true,
options: [
{ id: '07f85e5b-0d70-416b-9884-256e469ed532', value: 'CUSTOMER_QUOTE', label: 'Customer quote', position: 0, color: 'blue' },
{ id: '108b2358-d04d-4fdc-83df-a5978d39f66f', value: 'CASE_STUDY', label: 'Case study', position: 1, color: 'green' },
{ id: 'eb45f371-f93c-4c45-9c8a-f29e1a58b7e4', value: 'PARTNER_QUOTE', label: 'Partner quote', position: 2, color: 'orange' },
{ id: '3356c8a0-41cd-47c3-a293-5862138abc1a', value: 'LOGO', label: 'Logo', position: 3, color: 'purple' },
],
},
{
universalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a',
type: FieldType.SELECT,
name: 'status',
label: 'Status',
icon: 'IconProgressCheck',
defaultValue: "'WIP'",
options: [
{ id: '5d41450b-8efb-41e8-81b7-b534429ec1b4', value: 'WIP', label: 'WIP', position: 0, color: 'gray' },
{ id: '6611ae9a-4253-4638-9c47-d8bf2ec54368', value: 'INTERVIEW_SCHEDULED', label: 'Interview scheduled', position: 1, color: 'blue' },
{ id: 'ae3d91e4-96ea-4687-b71f-d2d80088f28a', value: 'UNDER_CUSTOMER_PARTNER_REVIEW', label: 'Under review', position: 2, color: 'orange' },
{ id: '91fe48a3-7950-4cdd-8c52-8d9b2cc03f0b', value: 'APPROVED', label: 'Approved', position: 3, color: 'green' },
{ id: 'fa640b71-862e-4c64-80fa-37ab8b50d254', value: 'REJECTED', label: 'Rejected', position: 4, color: 'red' },
],
},
{
universalIdentifier: 'b52d263e-423e-40b0-b82c-29214597c005',
type: FieldType.DATE_TIME,
name: 'approvalDate',
label: 'Approval Date',
icon: 'IconCalendarCheck',
isNullable: true,
},
{
universalIdentifier: 'da7e9094-e2c3-47d3-924f-a1d4d3c717ed',
type: FieldType.LINKS,
name: 'interview',
label: 'Interview',
icon: 'IconMicrophone',
isNullable: true,
},
{
universalIdentifier: 'f303369e-288c-4a48-9920-c1de0ad9a159',
type: FieldType.FILES,
name: 'documents',
label: 'Documents',
icon: 'IconPaperclip',
isNullable: true,
universalSettings: { maxNumberOfValues: 10 },
},
],
});
@@ -1,488 +0,0 @@
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: 'Categories',
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' },
{ id: 'b1000001-0000-4000-8000-0000000000a1', value: 'ADVISORY', label: 'Advisory & Discovery', position: 5, color: 'blue' },
{ id: 'b1000002-0000-4000-8000-0000000000a2', value: 'SOLUTIONING', label: 'Solutioning', position: 6, color: 'green' },
{ id: 'b1000003-0000-4000-8000-0000000000a3', value: 'DEVELOPMENT', label: 'Custom Development', position: 7, color: 'turquoise' },
{ id: 'b1000004-0000-4000-8000-0000000000a4', value: 'HOSTING', label: 'Hosting & Infrastructure', position: 8, color: 'purple' },
{ id: 'b1000005-0000-4000-8000-0000000000a5', value: 'SUPPORT', label: 'Training & Adoption', position: 9, color: 'pink' },
],
},
{
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: '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: 'a0000011-0000-4000-8000-000000000011',
type: FieldType.TEXT,
name: 'applicationNotes',
label: 'Application Notes',
icon: 'IconClipboardText',
isNullable: true,
},
{
universalIdentifier: 'a0000010-0000-4000-8000-000000000010',
type: FieldType.DATE_TIME,
name: 'lastMatchAt',
label: 'Last Match At',
icon: 'IconClock',
isNullable: true,
},
],
});
@@ -1,56 +0,0 @@
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,
},
],
});
@@ -1,55 +0,0 @@
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,
},
],
});
@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest';
import { mapLegacyScope } from '../partner-scope-map';
describe('mapLegacyScope', () => {
it('maps each legacy value to its new category', () => {
expect(mapLegacyScope(['APPS'])).toEqual(['DEVELOPMENT']);
expect(mapLegacyScope(['DATA_MODEL'])).toEqual(['SOLUTIONING']);
expect(mapLegacyScope(['DATA_MIGRATION'])).toEqual(['SOLUTIONING']);
expect(mapLegacyScope(['WORKFLOWS'])).toEqual(['SOLUTIONING']);
expect(mapLegacyScope(['HOSTING_ENVIRONMENT'])).toEqual(['HOSTING']);
});
it('de-duplicates collapsed values', () => {
expect(mapLegacyScope(['DATA_MODEL', 'DATA_MIGRATION', 'WORKFLOWS'])).toEqual([
'SOLUTIONING',
]);
});
it('passes through already-migrated values unchanged', () => {
expect(mapLegacyScope(['ADVISORY', 'HOSTING'])).toEqual(['ADVISORY', 'HOSTING']);
});
});
@@ -1,515 +0,0 @@
// Import partners, opportunities and partner quotes FROM the TFT workspace INTO
// this (local) workspace. Idempotent: re-running upserts by natural key
// (partner=slug, opportunity=tftOpportunityId, quote=name).
//
// TFT (source) is read over RAW GraphQL fetch: its custom fields/filters are not
// in the SDK's generated (local) genql schema, so CoreApiClient cannot build
// queries for them. The local (target) workspace is written via CoreApiClient,
// exactly like seed.ts. Two separate credential sets, no collision:
// TFT -> TFT_API_URL / TFT_API_KEY (raw fetch)
// local-> TWENTY_PARTNERS_API_URL / TWENTY_PARTNERS_API_KEY (CoreApiClient)
//
// The local server rate-limits API calls (~100 / 60s), so existence checks are
// batched into one `in` query per object (like seed.ts) and writes are paced.
//
// DRY-RUN BY DEFAULT: reads everything, writes nothing, and reports both what it
// WOULD upsert and the distinct TFT SELECT values it saw (flagging any value not
// covered by a local field option, which would otherwise fail a real write).
// Set IMPORT_APPLY=1 to actually write to the local workspace.
//
// TFT_API_URL=https://twentyfortwenty.twenty.com TFT_API_KEY=<tft key> \
// TWENTY_PARTNERS_API_URL=http://localhost:2020 TWENTY_PARTNERS_API_KEY=<local key> \
// [IMPORT_APPLY=1] \
// tsx src/scripts/import-from-tft.ts
import { config } from 'dotenv';
config({ path: process.env.ENV_FILE ?? '.env.local' });
import { CoreApiClient } from 'twenty-client-sdk/core';
import { mapLegacyScope } from './partner-scope-map';
import { slugify } from './slugify';
const requireEnv = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name} env var`);
return value;
};
const APPLY = process.env.IMPORT_APPLY === '1';
// Raw GraphQL against TFT. The generated CoreApiClient is bound to the LOCAL
// schema and cannot serialise TFT's custom fields/filters, so read TFT untyped.
const tftQuery = async (query: string): Promise<any> => {
const url = `${requireEnv('TFT_API_URL').replace(/\/$/, '')}/graphql`;
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${requireEnv('TFT_API_KEY')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
});
const json: any = await response.json();
if (json.errors?.length) {
throw new Error(`TFT query failed: ${JSON.stringify(json.errors)}`);
}
return json.data;
};
// TFT opportunity.stage -> our matchStatus (the 4 early stages collapse to TO_BE_MATCHED)
const STAGE_TO_MATCH_STATUS: Record<string, string> = {
INTRODUCED_TO_A_PARTNER: 'INTRODUCED_TO_A_PARTNER',
WORKING_WITH_A_PARTNER: 'WORKING_WITH_A_PARTNER',
WON: 'WON',
LOST: 'LOST',
RECONNECT_LATER: 'RECONNECT_LATER',
IDENTIFIED: 'TO_BE_MATCHED',
MET: 'TO_BE_MATCHED',
SOLUTIONING: 'TO_BE_MATCHED',
ADVANCED: 'TO_BE_MATCHED',
};
// TFT person.partnerStage -> our Partner.validationStage
const PARTNER_STAGE_TO_VALIDATION: Record<string, string> = {
APPLICATION: 'APPLICATION',
POTENTIAL_PARTNER: 'POTENTIAL',
PARTNER: 'VALIDATED',
FORMER_PARTNER: 'FORMER',
REJECTED: 'REJECTED',
};
// TFT person.partnerTimezone -> our Partner.region (MULTI_SELECT). TFT's value is a
// coarse timezone band, not a geography, so each maps to every region it plausibly
// covers. OTHER carries no signal -> no region. Region stays empty if unmapped.
const TIMEZONE_TO_REGION: Record<string, string[]> = {
AMERICAS: ['US', 'LATAM'],
EMEA: ['EUROPE', 'MENA', 'AFRICA'],
WEST_ASIA: ['MENA', 'APAC'],
EAST_ASIA_OCEANIA: ['APAC'],
OTHER: [],
};
// Local SELECT option sets, for preflight coverage checks (a TFT value not in
// these would fail a real write). Keep in sync with src/objects + src/fields.
const LOCAL_OPTIONS: Record<string, Set<string>> = {
partnerTier: new Set(['NEW', 'INTERMEDIATE', 'ADVANCED']),
partnerScope: new Set(['APPS', 'DATA_MODEL', 'DATA_MIGRATION', 'HOSTING_ENVIRONMENT', 'WORKFLOWS']),
typeOfTeam: new Set(['SOLO', 'AGENCY']),
hostingType: new Set(['CLOUD', 'SELF_HOSTING']),
subscriptionType: new Set(['PRO', 'ORG', 'ENT']),
subscriptionFrequency: new Set(['MONTHLY', 'ANNUAL']),
quoteStatus: new Set(['WIP', 'INTERVIEW_SCHEDULED', 'UNDER_CUSTOMER_PARTNER_REVIEW', 'APPROVED', 'REJECTED']),
};
const edges = (result: any, key: string): any[] =>
(result?.[key]?.edges ?? []).map((e: any) => e.node);
const uniq = (values: (string | undefined | null)[]): string[] =>
[...new Set(values.filter((v): v is string => !!v))];
// Normalize a domain for dedup. Twenty's company domain is a unique key but is
// stored with an https:// prefix, while TFT values vary, so compare on a canonical
// form (no protocol, no www, no trailing slash, lowercased).
const normDomain = (d?: string | null): string | undefined =>
d
? d.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '') || undefined
: undefined;
// Distinct non-empty values across rows, for the preflight report. Flattens array
// fields (e.g. partnerScope, typeCustom) and stringifies, so scalar and
// multi-select fields share one path. Derived from the already-fetched source
// rows — no per-loop bookkeeping needed.
const distinct = <TRow>(rows: TRow[], pick: (row: TRow) => unknown): string[] =>
[
...new Set(
rows.flatMap((row) => {
const value = pick(row);
return Array.isArray(value) ? value : value != null ? [value] : [];
}),
),
]
.map(String)
.sort();
async function main() {
console.log(`[import] mode: ${APPLY ? 'APPLY (writing to local)' : 'DRY-RUN (no writes)'}`);
const local = new CoreApiClient({
url: `${requireEnv('TWENTY_PARTNERS_API_URL').replace(/\/$/, '')}/graphql`,
headers: { Authorization: `Bearer ${requireEnv('TWENTY_PARTNERS_API_KEY')}` },
});
// Pace writes to stay under the local server's ~100 req/60s limit.
let writes = 0;
const pace = async () => {
if (APPLY && writes++ > 0) await new Promise((r) => setTimeout(r, 750));
};
// ---------------------------------------------------------------------
// 1. Read all three TFT datasets (raw fetch; not rate-limited by local).
// ---------------------------------------------------------------------
console.log('[import] fetching TFT people...');
const tftPeople = edges(
await tftQuery(`query {
people(first: 500, filter: { partnerStage: { in: ["APPLICATION","POTENTIAL_PARTNER","PARTNER","FORMER_PARTNER","REJECTED"] } }) {
edges { node {
id
name { firstName lastName }
emails { primaryEmail }
city jobTitle
linkedinLink { primaryLinkUrl }
partnerStage partnerTier partnerScope partnerTypeOfTeam partnerTimezone partnerIsAvailable partnerSkills
partnerBudgetMinimum { amountMicros currencyCode }
company { id name domainName { primaryLinkUrl } }
} }
}
}`),
'people',
);
console.log('[import] fetching TFT opportunities...');
const tftOppsAll = edges(
await tftQuery(`query {
opportunities(first: 500) {
edges { node {
id name numberOfSeats useCase hostingType subscriptionType subscriptionFrequence lostReason stage
amount { amountMicros currencyCode }
closeDate
company { id name domainName { primaryLinkUrl } }
partner { id }
} }
}
}`),
'opportunities',
);
// Only import opportunities linked to a partner. The rest is TFT's general sales
// pipeline (mostly LOST/IDENTIFIED) — noise for a partners app. Every partner
// stage (INTRODUCED/WORKING) only ever appears on partner-linked opps anyway.
const tftOpps = tftOppsAll.filter((o: any) => o.partner?.id);
console.log(`[import] opportunities: ${tftOpps.length} partner-linked of ${tftOppsAll.length} total (skipping ${tftOppsAll.length - tftOpps.length} unlinked)`);
console.log('[import] fetching TFT partner content...');
const tftContent = edges(
await tftQuery(`query {
customerContents(first: 500) {
edges { node {
id name status approvalDate typeCustom
interview { primaryLinkUrl }
partnerPerson { id }
customerCompany { id name domainName { primaryLinkUrl } }
customerPerson { id }
} }
}
}`),
'customerContents',
);
// Import all content TYPES (quotes, case studies, logos) but only records that
// involve a partner. Customer-only content (no partnerPerson) is noise for the
// partners app; a partner-linked case study/quote should show on the partner.
const contentRecords = tftContent.filter((c: any) => c.partnerPerson?.id);
console.log(`[import] TFT: ${tftPeople.length} partner-people, ${tftOpps.length} opportunities, ${tftContent.length} content records`);
console.log(`[import] partner content: ${contentRecords.length} partner-linked of ${tftContent.length} total (skipping ${tftContent.length - contentRecords.length} customer-only)`);
const personSlug = (p: any): string =>
slugify([p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner') || p.id;
// ---------------------------------------------------------------------
// 2. Batched existence lookups against local (one `in` query per object).
// ---------------------------------------------------------------------
console.log('[import] checking existing records in target workspace...');
const partnerSlugs = uniq(tftPeople.map(personSlug));
const partnerIdBySlug = new Map<string, string>(
partnerSlugs.length
? edges(
await local.query({
partners: { __args: { filter: { slug: { in: partnerSlugs } }, first: 500 }, edges: { node: { id: true, slug: true } } },
} as any),
'partners',
).map((n: any) => [n.slug, n.id])
: [],
);
// Fetch existing companies and index by BOTH name and normalized domain. Twenty
// enforces uniqueness on domain, so dedup must be domain-aware: the same company
// can arrive under different names (e.g. "Acme" vs "acme.com") across TFT people,
// opps and content, and creating a second one collides on the domain constraint.
// Page through ALL companies (not just the first 500): these dedupe maps must
// be complete, or upsertCompany would create domain-colliding duplicates for
// companies that live beyond the first page in a larger workspace.
const existingCompanies: any[] = [];
let companiesCursor: string | undefined;
for (;;) {
const page: any = await local.query({
companies: {
__args: { filter: {}, first: 200, ...(companiesCursor ? { after: companiesCursor } : {}) },
edges: { node: { id: true, name: true, domainName: { primaryLinkUrl: true } } },
pageInfo: { hasNextPage: true, endCursor: true },
},
} as any);
existingCompanies.push(...edges(page, 'companies'));
if (!page?.companies?.pageInfo?.hasNextPage) break;
companiesCursor = page.companies.pageInfo.endCursor;
}
const companyIdByName = new Map<string, string>(existingCompanies.map((n: any) => [n.name, n.id]));
const companyIdByDomain = new Map<string, string>();
for (const c of existingCompanies) {
const nd = normDomain(c.domainName?.primaryLinkUrl);
if (nd && !companyIdByDomain.has(nd)) companyIdByDomain.set(nd, c.id);
}
const oppTftIds = uniq(tftOpps.filter((o: any) => o.name).map((o: any) => o.id));
const oppIdByTftId = new Map<string, string>(
oppTftIds.length
? edges(
await local.query({
opportunities: { __args: { filter: { tftOpportunityId: { in: oppTftIds } }, first: 500 }, edges: { node: { id: true, tftOpportunityId: true } } },
} as any),
'opportunities',
).map((n: any) => [n.tftOpportunityId, n.id])
: [],
);
// Unnamed content upserts by name, so a constant fallback would make every
// unnamed record collide on one key (collapsing them on re-run). Key the
// fallback on the TFT id so each unnamed record stays distinct.
const contentName = (c: any): string => c.name || `Partner content ${c.id}`;
const contentNames = uniq(contentRecords.map(contentName));
const contentIdByName = new Map<string, string>(
contentNames.length
? edges(
await local.query({
partnerContents: { __args: { filter: { name: { in: contentNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
} as any),
'partnerContents',
).map((n: any) => [n.name, n.id])
: [],
);
// ---------------------------------------------------------------------
// 3. Upsert. Writes are APPLY-gated and paced; in dry-run we resolve new
// ids to synthetic placeholders so relation mapping still exercises.
// ---------------------------------------------------------------------
const upsertCompany = async (name?: string, domain?: string): Promise<string | undefined> => {
if (!name) return undefined;
if (companyIdByName.has(name)) return companyIdByName.get(name);
const nd = normDomain(domain);
// Same company under a different name but same domain — reuse it.
if (nd && companyIdByDomain.has(nd)) {
const existingId = companyIdByDomain.get(nd) as string;
companyIdByName.set(name, existingId);
return existingId;
}
let id = `dry:company:${name}`;
if (APPLY) {
await pace();
try {
const created: any = await local.mutation({
createCompany: {
__args: { data: { name, ...(domain ? { domainName: { primaryLinkUrl: domain } } : {}) } },
id: true,
},
} as any);
id = created.createCompany.id;
} catch (err) {
// Fallback: createCompany failed, almost certainly because the domain
// already exists (Twenty enforces a unique domain) on a company we
// didn't index. Re-find it and reuse instead of failing the import.
// `ilike` is a substring match — and the stored value carries an
// https:// prefix so we can't `eq` the normalized form — so it can
// return the wrong company ("acme.com" also matches "notacme.com" or
// "acme.com.br"). Confirm an exact normalized-domain match before reuse.
if (!nd) throw err;
await pace();
const candidates = edges(
await local.query({
companies: { __args: { filter: { domainName: { primaryLinkUrl: { ilike: `%${nd}%` } } }, first: 20 }, edges: { node: { id: true, domainName: { primaryLinkUrl: true } } } },
} as any),
'companies',
);
const match = candidates.find((c: any) => normDomain(c.domainName?.primaryLinkUrl) === nd);
if (!match?.id) throw err;
id = match.id;
console.log(`[import] company "${name}" reused existing by domain ${nd}`);
}
}
companyIdByName.set(name, id);
if (nd) companyIdByDomain.set(nd, id);
return id;
};
const budgetCurrency = (amount: any) =>
amount?.amountMicros != null ? { amountMicros: amount.amountMicros, currencyCode: amount.currencyCode ?? 'USD' } : undefined;
// Generic create/update dispatch shared by partners, opportunities and content.
// Owns pacing + APPLY-gating + the create-vs-update branch, so each loop below
// only builds its `data`. genql keys a mutation by the object name, so the
// create<Object>/update<Object> names are derived from one argument. Companies
// keep their own upsert (above) because of the domain-collision fallback.
// Returns the row id: the real id on APPLY, a synthetic dry id otherwise so the
// relation mapping in dry-run still resolves to a stable placeholder.
const upsert = async (
object: 'Partner' | 'Opportunity' | 'PartnerContent',
existingId: string | undefined,
data: Record<string, unknown>,
dryKey: string,
): Promise<string> => {
if (!APPLY) return existingId ?? `dry:${object}:${dryKey}`;
await pace();
if (existingId) {
await local.mutation({ [`update${object}`]: { __args: { id: existingId, data }, id: true } } as any);
return existingId;
}
const created: any = await local.mutation({ [`create${object}`]: { __args: { data }, id: true } } as any);
return created[`create${object}`].id;
};
// -- Partners (upsert by slug) --
console.log(`[import] upserting ${tftPeople.length} partners...`);
const localPartnerIdByTftPersonId = new Map<string, string>();
let partnersCreated = 0;
let partnersUpdated = 0;
let partnersDone = 0;
for (const p of tftPeople) {
const slug = personSlug(p);
const companyId = await upsertCompany(p.company?.name, p.company?.domainName?.primaryLinkUrl);
// Timezone band -> geographic region(s). Unmapped/OTHER -> no region.
const region = TIMEZONE_TO_REGION[p.partnerTimezone] ?? [];
// A partner scoped for hosting is, by definition, a self-host expert.
const rawScope = Array.isArray(p.partnerScope) ? p.partnerScope : [];
// Map legacy TFT categories to the validated set so the import never
// re-introduces retired values.
const scope = mapLegacyScope(rawScope);
const deploymentExpertise = rawScope.includes('HOSTING_ENVIRONMENT') ? ['SELF_HOST'] : [];
const data: Record<string, unknown> = {
name: [p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner',
slug,
validationStage: PARTNER_STAGE_TO_VALIDATION[p.partnerStage] ?? 'APPLICATION',
availability: p.partnerIsAvailable ? 'AVAILABLE' : 'UNAVAILABLE',
// TFT has no language data; default everyone to English.
languagesSpoken: ['ENGLISH'],
...(p.partnerTier ? { partnerTier: p.partnerTier } : {}),
...(scope.length ? { partnerScope: scope } : {}),
...(region.length ? { region } : {}),
...(deploymentExpertise.length ? { deploymentExpertise } : {}),
...(p.partnerTypeOfTeam ? { typeOfTeam: p.partnerTypeOfTeam } : {}),
...(Array.isArray(p.partnerSkills) && p.partnerSkills.length ? { skills: p.partnerSkills } : {}),
...(p.city ? { city: p.city } : {}),
...(budgetCurrency(p.partnerBudgetMinimum) ? { projectBudgetMin: budgetCurrency(p.partnerBudgetMinimum) } : {}),
...(p.linkedinLink?.primaryLinkUrl ? { linkedin: { primaryLinkUrl: p.linkedinLink.primaryLinkUrl } } : {}),
...(companyId && APPLY ? { companyId } : {}),
};
const existingId = partnerIdBySlug.get(slug);
const partnerId = await upsert('Partner', existingId, data, slug);
if (existingId) {
partnersUpdated++;
} else {
partnerIdBySlug.set(slug, partnerId);
partnersCreated++;
}
localPartnerIdByTftPersonId.set(p.id, partnerId);
partnersDone++;
if (partnersDone % 10 === 0 || partnersDone === tftPeople.length)
console.log(`[import] partners ${partnersDone}/${tftPeople.length} (created=${partnersCreated} updated=${partnersUpdated})`);
}
console.log(`[import] partners done: created=${partnersCreated} updated=${partnersUpdated}`);
// -- Opportunities (upsert by tftOpportunityId) --
console.log(`[import] upserting ${tftOpps.length} opportunities...`);
let oppsCreated = 0;
let oppsUpdated = 0;
let oppsPartnerLinked = 0;
let oppsDone = 0;
for (const o of tftOpps) {
if (!o.name) continue;
const companyId = await upsertCompany(o.company?.name, o.company?.domainName?.primaryLinkUrl);
const partnerId = o.partner?.id ? localPartnerIdByTftPersonId.get(o.partner.id) : undefined;
if (partnerId) oppsPartnerLinked++;
const data: Record<string, unknown> = {
name: o.name,
tftOpportunityId: o.id,
matchStatus: STAGE_TO_MATCH_STATUS[o.stage] ?? 'TO_BE_MATCHED',
...(o.numberOfSeats != null ? { numberOfSeats: o.numberOfSeats } : {}),
...(o.useCase ? { useCase: o.useCase } : {}),
...(o.hostingType ? { hostingType: o.hostingType } : {}),
...(o.subscriptionType ? { subscriptionType: o.subscriptionType } : {}),
...(o.subscriptionFrequence ? { subscriptionFrequency: o.subscriptionFrequence } : {}),
...(o.lostReason ? { lostReason: o.lostReason } : {}),
...(o.amount?.amountMicros != null ? { amount: { amountMicros: o.amount.amountMicros, currencyCode: o.amount.currencyCode ?? 'USD' } } : {}),
...(o.closeDate ? { closeDate: o.closeDate } : {}),
...(companyId && APPLY ? { companyId } : {}),
...(partnerId && APPLY ? { partnerId } : {}),
};
const existingId = oppIdByTftId.get(o.id);
await upsert('Opportunity', existingId, data, o.id);
if (existingId) oppsUpdated++;
else oppsCreated++;
oppsDone++;
if (oppsDone % 20 === 0 || oppsDone === tftOpps.length)
console.log(`[import] opportunities ${oppsDone}/${tftOpps.length} (created=${oppsCreated} updated=${oppsUpdated})`);
}
console.log(`[import] opportunities done: created=${oppsCreated} updated=${oppsUpdated} (partner-linked=${oppsPartnerLinked})`);
// -- Partner content (upsert by name) --
console.log(`[import] upserting ${contentRecords.length} content records...`);
let contentCreated = 0;
let contentUpdated = 0;
for (const c of contentRecords) {
const name = contentName(c);
const partnerId = c.partnerPerson?.id ? localPartnerIdByTftPersonId.get(c.partnerPerson.id) : undefined;
const customerCompanyId = await upsertCompany(c.customerCompany?.name, c.customerCompany?.domainName?.primaryLinkUrl);
const data: Record<string, unknown> = {
name,
...(Array.isArray(c.typeCustom) && c.typeCustom.length ? { contentType: c.typeCustom } : {}),
...(c.status ? { status: c.status } : {}),
...(c.approvalDate ? { approvalDate: c.approvalDate } : {}),
...(c.interview?.primaryLinkUrl ? { interview: { primaryLinkUrl: c.interview.primaryLinkUrl } } : {}),
...(partnerId && APPLY ? { partnerId } : {}),
...(customerCompanyId && APPLY ? { customerCompanyId } : {}),
};
const existingId = contentIdByName.get(name);
await upsert('PartnerContent', existingId, data, name);
if (existingId) contentUpdated++;
else contentCreated++;
}
console.log(`[import] partner content created=${contentCreated} updated=${contentUpdated}`);
// ---------------------------------------------------------------------
// 4. Preflight: distinct TFT values vs local option coverage. Derived
// directly from the fetched source rows (no per-loop bookkeeping).
// ---------------------------------------------------------------------
const report = (label: string, values: string[], optionKey?: string) => {
const allowed = optionKey ? LOCAL_OPTIONS[optionKey] : undefined;
const uncovered = allowed ? values.filter((v) => !allowed.has(v)) : [];
console.log(
`[preflight] ${label}: ${values.join(', ') || '(none)'}` +
(uncovered.length ? ` ⚠️ NOT IN LOCAL OPTIONS: ${uncovered.join(', ')}` : ''),
);
};
const partnerStages = distinct(tftPeople, (p: any) => p.partnerStage);
const oppStages = distinct(tftOpps, (o: any) => o.stage);
const timezones = distinct(tftPeople, (p: any) => p.partnerTimezone);
console.log('--- preflight: distinct TFT values seen ---');
report('partnerStage (-> validationStage map)', partnerStages);
report('partnerTier', distinct(tftPeople, (p: any) => p.partnerTier), 'partnerTier');
report('partnerScope', distinct(tftPeople, (p: any) => p.partnerScope), 'partnerScope');
report('typeOfTeam', distinct(tftPeople, (p: any) => p.partnerTypeOfTeam), 'typeOfTeam');
report('partnerTimezone (-> region map)', timezones);
report('opp stage (-> matchStatus map)', oppStages);
report('hostingType', distinct(tftOpps, (o: any) => o.hostingType), 'hostingType');
report('subscriptionType', distinct(tftOpps, (o: any) => o.subscriptionType), 'subscriptionType');
report('subscriptionFrequency', distinct(tftOpps, (o: any) => o.subscriptionFrequence), 'subscriptionFrequency');
report('quote status', distinct(contentRecords, (c: any) => c.status), 'quoteStatus');
report('customerContent typeCustom', distinct(contentRecords, (c: any) => c.typeCustom));
const unmappedStages = partnerStages.filter((s) => !(s in PARTNER_STAGE_TO_VALIDATION));
const unmappedOpps = oppStages.filter((s) => !(s in STAGE_TO_MATCH_STATUS));
const unmappedTz = timezones.filter((t) => !(t in TIMEZONE_TO_REGION));
if (unmappedStages.length) console.log(`[preflight] ⚠️ partnerStage not mapped: ${unmappedStages.join(', ')}`);
if (unmappedOpps.length) console.log(`[preflight] ⚠️ opp stage not mapped: ${unmappedOpps.join(', ')}`);
if (unmappedTz.length) console.log(`[preflight] ⚠️ partnerTimezone not mapped: ${unmappedTz.join(', ')}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -1,79 +0,0 @@
// Remap legacy partnerScope values to the validated categories.
//
// yarn migrate:partner-scope # dry-run against .env.local
// MIGRATE_APPLY=1 yarn migrate:partner-scope
// yarn migrate:partner-scope:prod # dry-run against .env.prod
//
// Adding the new options is done in partner.object.ts (additive). This script
// rewrites existing records old→new so the old options can later be removed.
import { config } from 'dotenv';
config({ path: process.env.ENV_FILE ?? '.env.local' });
import { CoreApiClient } from 'twenty-client-sdk/core';
import { fileURLToPath } from 'url';
import { mapLegacyScope } from './partner-scope-map';
const requireEnv = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name} env var`);
return value;
};
async function main() {
const apply = process.env.MIGRATE_APPLY === '1';
const url = `${requireEnv('TWENTY_PARTNERS_API_URL').replace(/\/$/, '')}/graphql`;
const client = new CoreApiClient({
url,
headers: { Authorization: `Bearer ${requireEnv('TWENTY_PARTNERS_API_KEY')}` },
});
console.log(`[migrate-scope] target: ${url}${apply ? 'APPLY' : 'dry-run'}`);
// Pass 1: page through ALL partners read-only and collect those that need remapping.
type Pending = { id: string; current: string[]; next: string[] };
const pending: Pending[] = [];
let after: string | null = null;
// eslint-disable-next-line no-constant-condition
while (true) {
const data: any = await client.query({
partners: {
__args: after ? { first: 50, after } : { first: 50 },
edges: { node: { id: true, partnerScope: true }, cursor: true },
pageInfo: { hasNextPage: true, endCursor: true },
},
} as any);
const edges = data.partners?.edges ?? [];
for (const edge of edges) {
const id = edge.node.id as string;
const current = (edge.node.partnerScope ?? []) as string[];
const next = mapLegacyScope(current);
const isChanged =
next.length !== current.length || next.some((v, i) => v !== current[i]);
if (!isChanged) continue;
pending.push({ id, current, next });
console.log(`[migrate-scope] ${id}: ${JSON.stringify(current)} -> ${JSON.stringify(next)}`);
}
if (!data.partners?.pageInfo?.hasNextPage) break;
after = data.partners.pageInfo.endCursor;
}
// Pass 2: apply the remapping mutations (only when MIGRATE_APPLY=1).
if (apply) {
for (const { id, next } of pending) {
await client.mutation({
updatePartner: { __args: { id, data: { partnerScope: next } }, id: true },
} as any);
}
}
console.log(`[migrate-scope] ${apply ? 'updated' : 'would update'} ${pending.length} partner(s)`);
if (!apply) console.log('[migrate-scope] re-run with MIGRATE_APPLY=1 to apply');
}
// Only run when invoked directly (so unit tests can import mapLegacyScope safely).
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
@@ -1,19 +0,0 @@
// Legacy -> validated partnerScope category mapping. Single source of truth,
// shared by the migration script (rewrites existing records) and the TFT import
// (so it never writes retired legacy values back in).
export const LEGACY_SCOPE_MAP: Record<string, string> = {
APPS: 'DEVELOPMENT',
DATA_MODEL: 'SOLUTIONING',
DATA_MIGRATION: 'SOLUTIONING',
WORKFLOWS: 'SOLUTIONING',
HOSTING_ENVIRONMENT: 'HOSTING',
};
export function mapLegacyScope(values: ReadonlyArray<string>): string[] {
const out: string[] = [];
for (const value of values) {
const mapped = LEGACY_SCOPE_MAP[value] ?? value;
if (!out.includes(mapped)) out.push(mapped);
}
return out;
}
@@ -1,59 +0,0 @@
// Hard-destroy soft-deleted records that block re-imports.
//
// Twenty SOFT-deletes (sets deletedAt); the row stays in the DB and keeps holding
// unique constraints (e.g. company domain, partner slug). But normal queries —
// including the import's existence checks — exclude soft-deleted rows. So after a
// UI "delete" or a partial import that got rolled back, re-running the import hits
// "A duplicate entry was detected" on records it cannot see. This purges those
// ghosts permanently so idempotent upserts work again.
//
// Only touches soft-deleted rows (deletedAt IS NOT NULL); active/default data is
// left untouched. One bulk destroy per object, so it is not rate-limited.
//
// yarn purge # against .env.local
// yarn purge:prod # against .env.prod
//
import { config } from 'dotenv';
config({ path: process.env.ENV_FILE ?? '.env.local' });
const requireEnv = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name} env var`);
return value;
};
// Objects the import writes to. partners + partnerContents are app custom objects;
// companies + opportunities are standard but populated by the import.
const OBJECTS = ['companies', 'partners', 'opportunities', 'partnerContents'] as const;
const gql = async (url: string, key: string, query: string): Promise<any> => {
const response = await fetch(`${url.replace(/\/$/, '')}/graphql`, {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
});
const json: any = await response.json();
if (json.errors?.length) throw new Error(JSON.stringify(json.errors));
return json.data;
};
async function main() {
const url = requireEnv('TWENTY_PARTNERS_API_URL');
const key = requireEnv('TWENTY_PARTNERS_API_KEY');
console.log(`[purge] target: ${url} — destroying soft-deleted rows only`);
for (const obj of OBJECTS) {
// destroy<Object>s is the bulk variant; capitalise + already-plural names.
const mutationName = `destroy${obj.charAt(0).toUpperCase()}${obj.slice(1)}`;
const data = await gql(url, key, `mutation { ${mutationName}(filter: { deletedAt: { is: NOT_NULL } }) { id } }`);
const destroyed = data[mutationName]?.length ?? 0;
console.log(`[purge] ${obj}: destroyed ${destroyed} soft-deleted`);
}
console.log('[purge] done');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -1,212 +0,0 @@
// Single demo seed for the twenty-partners app. Idempotent UPSERT by natural key.
// Covers: partners across ALL validationStage values, companies + people,
// opportunities across ALL 10 matchStatus values, and partner quotes across ALL
// 5 statuses (each linked to a partner + opportunity).
//
// Run from this app directory, against a running Twenty server with the app
// installed (deploy first; do NOT run `yarn test` after — its teardown wipes the app).
// Credentials from shell env or a gitignored .env.local (TWENTY_PARTNERS_API_URL/KEY).
//
// yarn twenty dev --once
// tsx src/scripts/seed.ts
import { config } from 'dotenv';
config({ path: process.env.ENV_FILE ?? '.env.local' });
import { CoreApiClient } from 'twenty-client-sdk/core';
const requireEnv = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name} env var`);
return value;
};
const CAL = 'https://calendly.com/placeholder';
const usd = (dollars: number) => ({
amountMicros: dollars * 1_000_000,
currencyCode: 'USD',
});
// Deterministic placeholder avatars; pravatar returns a stable image per `u` seed.
const avatar = (slug: string) => `https://i.pravatar.cc/300?u=${slug}`;
const linkedin = (slug: string) =>
`https://www.linkedin.com/company/${slug}`;
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;
city: string;
hourlyRateUsd: number | null;
projectBudgetMinUsd: number | null;
skills: 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. Nine Dots runs end-to-end Twenty rollouts for property managers and brokerages across Europe and MENA, with deep multi-language data models and AI-assisted lead intake.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['EUROPE', 'MENA'], languagesSpoken: ['ENGLISH', 'FRENCH', 'ARABIC'], partnerTier: 'ADVANCED', partnerScope: ['DATA_MODEL', 'WORKFLOWS', 'APPS'], typeOfTeam: 'AGENCY', country: 'FRANCE', city: 'Paris', hourlyRateUsd: 250, projectBudgetMinUsd: 15000, skills: ['Real estate', 'WhatsApp', 'Multi-language', 'Workflows', 'Integrations', 'AI'] },
{ slug: 'elevate-consulting', name: 'Elevate Consulting', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Revenue-operations partner for B2B SaaS teams scaling seed to Series C. Elevate moves teams off legacy CRMs onto Twenty with a four-week migration playbook, pipeline rebuilds, and analytics handoff.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['US', 'LATAM'], languagesSpoken: ['ENGLISH', 'SPANISH'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MIGRATION', 'DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'UNITED_STATES', city: 'Austin', hourlyRateUsd: 200, projectBudgetMinUsd: 20000, skills: ['RevOps', 'B2B SaaS', 'Data migration', 'Pipelines', 'Salesforce migration', 'HubSpot migration'] },
{ slug: 'w3villa-technologies', name: 'W3Villa Technologies', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Engineering-heavy partner running large self-hosted Twenty deployments. Specializes in hardened Kubernetes hosting, custom integrations, and 24/7 support contracts for regulated industries across APAC and the Gulf.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'MENA'], languagesSpoken: ['ENGLISH', 'HINDI'], partnerTier: 'ADVANCED', partnerScope: ['HOSTING_ENVIRONMENT', 'APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'INDIA', city: 'Bangalore', hourlyRateUsd: 120, projectBudgetMinUsd: 10000, skills: ['Self-hosting', 'Kubernetes', 'DevOps', 'Integrations', 'Workflows', 'Enterprise support'] },
{ slug: 'act-education', name: 'Act Education', validationStage: 'VALIDATED', availability: 'UNAVAILABLE', introduction: 'CRM partner for European education providers; compliance-first self-hosting on EU infrastructure with full GDPR data residency and student-record workflows.', calendarLink: CAL, deploymentExpertise: ['SELF_HOST'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'GERMAN'], partnerTier: 'NEW', partnerScope: ['HOSTING_ENVIRONMENT', 'DATA_MODEL'], typeOfTeam: 'SOLO', country: 'GERMANY', city: 'Berlin', hourlyRateUsd: 180, projectBudgetMinUsd: 8000, skills: ['Education', 'Compliance', 'Self-hosting', 'GDPR', 'Data privacy'] },
{ slug: 'netzero-systems', name: 'NetZero Systems', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'LATAM go-to-market partner for climate-tech and renewable-energy companies. Builds bilingual sales pipelines, ESG reporting, and grant-management workflows on top of Twenty.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['LATAM', 'US'], languagesSpoken: ['ENGLISH', 'SPANISH', 'PORTUGUESE'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'BRAZIL', city: 'São Paulo', hourlyRateUsd: 150, projectBudgetMinUsd: 12000, skills: ['Climate tech', 'Renewable energy', 'ESG reporting', 'Bilingual pipelines', 'LATAM go-to-market'] },
{ slug: 'meridian-craft', name: 'Meridian Craft', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'APAC implementation studio for fintech and logistics. Senior team of ex-bank engineers building high-throughput Twenty deployments across Singapore, Hong Kong, and Kuala Lumpur.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'AFRICA'], languagesSpoken: ['ENGLISH', 'CHINESE', 'MALAY'], partnerTier: 'ADVANCED', partnerScope: ['APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'SINGAPORE', city: 'Singapore', hourlyRateUsd: 300, projectBudgetMinUsd: 25000, skills: ['Fintech', 'Logistics', 'APAC', 'High throughput', 'Custom apps', 'Performance tuning'] },
{ 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', city: 'Lyon', hourlyRateUsd: null, projectBudgetMinUsd: null, skills: ['Boutique', 'Design'] },
{ 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', city: 'New York', hourlyRateUsd: null, projectBudgetMinUsd: null, skills: ['SMB', 'Quick setup'] },
{ 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', city: 'London', hourlyRateUsd: null, projectBudgetMinUsd: null, skills: ['Enterprise', 'Self-hosting'] },
{ 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', city: 'Dubai', hourlyRateUsd: null, projectBudgetMinUsd: null, skills: ['MENA', 'Arabic'] },
];
const COMPANIES = [
{ name: 'Acme Real Estate', domain: 'https://acmerealestate.example' },
{ name: 'Helix Bio', domain: 'https://helixbio.example' },
{ name: 'Sunrise Logistics', domain: 'https://sunriselogistics.example' },
];
const PERSONS = [
{ firstName: 'Camille', lastName: 'Durand', companyName: 'Acme Real Estate', email: 'camille@acmerealestate.example', city: 'Paris' },
{ firstName: 'Maya', lastName: 'Patel', companyName: 'Helix Bio', email: 'maya@helixbio.example', city: 'Boston' },
{ firstName: 'Wei', lastName: 'Chen', companyName: 'Sunrise Logistics', email: 'wei@sunriselogistics.example', city: 'Singapore' },
];
type Opp = {
name: string;
companyName: string;
matchStatus: string;
partnerSlug?: string;
numberOfSeats?: number;
hostingType?: string;
subscriptionType?: string;
subscriptionFrequency?: string;
};
// One+ opportunity for every matchStatus value (all 10 covered).
const OPPORTUNITIES: Opp[] = [
{ name: 'Acme RE — Q3 renewal', companyName: 'Acme Real Estate', matchStatus: 'TO_BE_MATCHED', numberOfSeats: 20, hostingType: 'CLOUD', subscriptionType: 'PRO', subscriptionFrequency: 'ANNUAL' },
{ name: 'Helix Bio — investor reporting', companyName: 'Helix Bio', matchStatus: 'MANUAL_MATCH', numberOfSeats: 12, hostingType: 'CLOUD', subscriptionType: 'ORG', subscriptionFrequency: 'MONTHLY' },
{ name: 'Helix Bio — pipeline review', companyName: 'Helix Bio', matchStatus: 'AUTO_MATCH', numberOfSeats: 8 },
{ name: 'Acme RE — CRM rollout', companyName: 'Acme Real Estate', matchStatus: 'MATCHED', partnerSlug: 'elevate-consulting', numberOfSeats: 30, hostingType: 'CLOUD', subscriptionType: 'ENT', subscriptionFrequency: 'ANNUAL' },
{ name: 'Sunrise — APAC fleet CRM', companyName: 'Sunrise Logistics', matchStatus: 'INTRODUCED_TO_A_PARTNER', partnerSlug: 'nine-dots-ventures', numberOfSeats: 50, hostingType: 'SELF_HOSTING', subscriptionType: 'ENT', subscriptionFrequency: 'ANNUAL' },
{ name: 'Helix Bio — clinical trials CRM', companyName: 'Helix Bio', matchStatus: 'WORKING_WITH_A_PARTNER', partnerSlug: 'netzero-systems', numberOfSeats: 25, hostingType: 'CLOUD', subscriptionType: 'ORG', subscriptionFrequency: 'MONTHLY' },
{ name: 'Helix Bio — self-host evaluation', companyName: 'Helix Bio', matchStatus: 'IMPLEMENTING', partnerSlug: 'meridian-craft', numberOfSeats: 40, hostingType: 'SELF_HOSTING', subscriptionType: 'ENT', subscriptionFrequency: 'ANNUAL' },
{ name: 'Sunrise — LATAM expansion', companyName: 'Sunrise Logistics', matchStatus: 'WON', partnerSlug: 'nine-dots-ventures', numberOfSeats: 60, hostingType: 'CLOUD', subscriptionType: 'ENT', subscriptionFrequency: 'ANNUAL' },
{ name: 'Acme RE — annual review', companyName: 'Acme Real Estate', matchStatus: 'RECONNECT_LATER', partnerSlug: 'w3villa-technologies', numberOfSeats: 15 },
{ name: 'Sunrise — vendor onboarding', companyName: 'Sunrise Logistics', matchStatus: 'LOST', numberOfSeats: 10, hostingType: 'CLOUD', subscriptionType: 'PRO', subscriptionFrequency: 'MONTHLY' },
];
type Quote = { name: string; status: string; partnerSlug: string; contentType: string[] };
const QUOTES: Quote[] = [
{ name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', contentType: ['PARTNER_QUOTE'] },
{ name: 'Helix clinical — NetZero quote', status: 'INTERVIEW_SCHEDULED', partnerSlug: 'netzero-systems', contentType: ['PARTNER_QUOTE'] },
{ name: 'Acme rollout — Elevate quote', status: 'UNDER_CUSTOMER_PARTNER_REVIEW', partnerSlug: 'elevate-consulting', contentType: ['PARTNER_QUOTE'] },
{ name: 'Sunrise LATAM — Nine Dots quote', status: 'APPROVED', partnerSlug: 'nine-dots-ventures', contentType: ['PARTNER_QUOTE'] },
{ name: 'Helix self-host — Meridian quote', status: 'REJECTED', partnerSlug: 'meridian-craft', contentType: ['CASE_STUDY'] },
];
const nodes = (r: any, key: string): any[] => (r?.[key]?.edges ?? []).map((e: any) => e.node);
async function main() {
const client = new CoreApiClient({
url: `${requireEnv('TWENTY_PARTNERS_API_URL').replace(/\/$/, '')}/graphql`,
headers: { Authorization: `Bearer ${requireEnv('TWENTY_PARTNERS_API_KEY')}` },
});
// -- Partners (upsert by slug) --
const existingPartners = nodes(
await client.query({ partners: { __args: { filter: { slug: { in: PARTNERS.map((p) => p.slug) } }, first: 100 }, edges: { node: { id: true, slug: true } } } } as any),
'partners',
);
const partnerIdBySlug = new Map<string, string>(existingPartners.map((n: any) => [n.slug, n.id]));
for (const p of PARTNERS) {
const data = {
name: p.name, slug: p.slug, validationStage: p.validationStage, availability: p.availability,
introduction: p.introduction, calendarLink: { primaryLinkUrl: p.calendarLink },
deploymentExpertise: p.deploymentExpertise, region: p.region, languagesSpoken: p.languagesSpoken,
partnerTier: p.partnerTier, partnerScope: p.partnerScope, typeOfTeam: p.typeOfTeam,
country: p.country, city: p.city,
skills: p.skills,
linkedin: { primaryLinkUrl: linkedin(p.slug) },
profilePicture: { primaryLinkUrl: avatar(p.slug) },
...(p.hourlyRateUsd != null ? { hourlyRate: usd(p.hourlyRateUsd) } : {}),
...(p.projectBudgetMinUsd != null ? { projectBudgetMin: usd(p.projectBudgetMinUsd) } : {}),
};
const id = partnerIdBySlug.get(p.slug);
if (id) {
await client.mutation({ updatePartner: { __args: { id, data }, id: true } } as any);
} else {
const r: any = await client.mutation({ createPartner: { __args: { data }, id: true } } as any);
partnerIdBySlug.set(p.slug, r.createPartner.id);
}
}
console.log(`[seed] partners: ${partnerIdBySlug.size}`);
// -- Companies (upsert by name) --
const companyIdByName = new Map<string, string>();
for (const c of COMPANIES) {
const existing = nodes(await client.query({ companies: { __args: { filter: { name: { eq: c.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'companies');
let id = existing[0]?.id;
if (!id) {
const r: any = await client.mutation({ createCompany: { __args: { data: { name: c.name, domainName: { primaryLinkUrl: c.domain } } }, id: true } } as any);
id = r.createCompany.id;
}
companyIdByName.set(c.name, id);
}
// -- People (upsert by firstName+lastName) --
for (const person of PERSONS) {
const existing = nodes(await client.query({ people: { __args: { filter: { name: { firstName: { eq: person.firstName } } }, first: 10 }, edges: { node: { id: true, name: { firstName: true, lastName: true } } } } } as any), 'people');
const match = existing.find((n: any) => n.name?.firstName === person.firstName && n.name?.lastName === person.lastName);
if (!match) {
await client.mutation({ createPerson: { __args: { data: { name: { firstName: person.firstName, lastName: person.lastName }, emails: { primaryEmail: person.email }, city: person.city, companyId: companyIdByName.get(person.companyName) } }, id: true } } as any);
}
}
// -- Opportunities (upsert by name) --
const oppIdByName = new Map<string, string>();
for (const o of OPPORTUNITIES) {
const data: Record<string, unknown> = {
name: o.name, matchStatus: o.matchStatus, companyId: companyIdByName.get(o.companyName),
...(o.partnerSlug ? { partnerId: partnerIdBySlug.get(o.partnerSlug) } : {}),
...(o.numberOfSeats != null ? { numberOfSeats: o.numberOfSeats } : {}),
...(o.hostingType ? { hostingType: o.hostingType } : {}),
...(o.subscriptionType ? { subscriptionType: o.subscriptionType } : {}),
...(o.subscriptionFrequency ? { subscriptionFrequency: o.subscriptionFrequency } : {}),
};
const existing = nodes(await client.query({ opportunities: { __args: { filter: { name: { eq: o.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'opportunities');
let id = existing[0]?.id;
if (id) {
await client.mutation({ updateOpportunity: { __args: { id, data }, id: true } } as any);
} else {
const r: any = await client.mutation({ createOpportunity: { __args: { data }, id: true } } as any);
id = r.createOpportunity.id;
}
oppIdByName.set(o.name, id);
}
console.log(`[seed] opportunities: ${oppIdByName.size}`);
// -- Partner quotes (upsert by name) --
let quoteCount = 0;
for (const q of QUOTES) {
const data = { name: q.name, status: q.status, contentType: q.contentType, partnerId: partnerIdBySlug.get(q.partnerSlug) };
const existing = nodes(await client.query({ partnerContents: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerContents');
if (existing[0]?.id) {
await client.mutation({ updatePartnerContent: { __args: { id: existing[0].id, data }, id: true } } as any);
} else {
await client.mutation({ createPartnerContent: { __args: { data }, id: true } } as any);
}
quoteCount++;
}
console.log(`[seed] partner quotes: ${quoteCount}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -1,11 +0,0 @@
// Shared slug helper. Algorithm is intentionally kept simple (no NFKD
// normalization) so that slugs produced by the import script and by the
// partner-application handler are byte-for-byte identical. The import uses
// slug as an idempotency/upsert key (partnerIdBySlug), so the algorithm must
// never change for existing data.
export const slugify = (s: string): string =>
s
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
@@ -1,116 +0,0 @@
---
name: twenty-lead-intro-call-summary
description: Turn a sales/discovery-call transcript into a faithful, structured qualification brief for Twenty's partner/CRM pipeline. Use this whenever the user has a call recording or transcript — including a meetily recordings folder or a transcripts.json — and wants to summarize, recap, qualify, or extract a brief from a prospect/discovery call. Trigger even when they don't say "brief": phrases like "summarize this call", "what did we learn from the X call", "qualify this lead from the call", "turn this transcript into something I can hand a partner", or pointing at a transcript file all count. Produces a tight deal one-pager (company, needs, implementation complexity, does-it-need-a-partner, partner-facing brief) plus an appendix (product/GTM feedback, terminology, quotes). It is a faithful extraction, not a lossy summary.
trigger: /twenty-lead-intro-call-summary
---
# twenty-lead-intro-call-summary
Turn a discovery/sales-call transcript into a **qualification brief** that (a) loses no
decision-relevant detail and (b) can be used to qualify the deal and hand it to an
implementation partner. Designed for Twenty's partner/CRM pipeline, tuned for messy,
label-less ASR transcripts (e.g. meetily exports).
The output is two parts: a tight **Part A deal one-pager** (everything the deal/partner
work needs) and a **Part B appendix** (not deal-specific: product feedback, terminology,
quotes). The structure is what prevents loss — a "summary that loses nothing" is a
contradiction, so this is a *structured extraction* against a fixed schema where every
dimension has a slot and gaps are marked rather than silently dropped.
## Step 1 — Get the transcript
The transcript is provided by the user — usually pasted text, or a path they point you at
(a `.txt`/`.vtt`/`.srt`, or a meetily recording folder / `transcripts.json`). If they give a
path, read it: a meetily `transcripts.json` is `{ "segments": [ { "text": ... } ] }`
concatenate the `text` values in order; for `.vtt`/`.srt`, drop the cue numbers and
timecodes.
**If no transcript is provided, ask for it before doing anything else.** Don't fabricate or
proceed without one.
For very long transcripts, read the whole thing before writing — coverage is the point.
## Step 2 — Produce the brief
Follow these rules and fill this exact schema. Why each rule matters is noted, because
faithful extraction depends on judgment, not rote form-filling.
**Rules**
- Extract ONLY what is in the transcript. Never invent or assume. If a field isn't covered,
write "Not discussed." (Visible gaps beat confident fabrication — a missing field is a
signal to ask on the next call.)
- Separate stated FACTS from your INFERENCES; mark any inference "(inferred)". (Downstream
matching trusts the facts; don't contaminate them with guesses.)
- Preserve specifics verbatim: numbers, dates, names + roles, tool/CRM names, prices,
budgets, exact requirements. Don't round or paraphrase numbers. (Specifics are where
nuance and matching signal live; paraphrase kills them.)
- Use the customer's own words for needs and objections; quote pivotal lines.
- Don't smooth over contradictions or vagueness — note them. (A flagged contradiction is
more useful than a falsely tidy summary.)
- If the transcript has NO speaker labels, infer from context who is the vendor (Twenty)
and who is the prospect, and attribute accordingly. Names get garbled by speech-to-text —
flag any uncertain name with "(uncertain)" and never invent a name.
- Keep PART A tight — it's the one-pager the deal/partner work runs on. Push everything not
specific to this deal (product/website feedback, terminology, supporting quotes) to
PART B. State each fact once; never repeat it across sections.
**Output (use these exact headers)**
```
== PART A — DEAL ONE-PAGER ==
1. ONE-LINE SUMMARY
2. COMPANY — name, what they do, size/employees, HQ + countries of operation, industry
3. PEOPLE ON THE CALL — name, role/title, side (Twenty vs prospect); infer roles if
unlabeled and flag uncertain names
4. CURRENT SITUATION — what CRM/tools they use today; specific pains
5. WHY THEY'RE INTERESTED IN TWENTY
6. WHAT THEY WANT — bulleted needs/requirements, verbatim where possible
7. IMPLEMENTATION COMPLEXITY (for partner matching)
- Deployment: cloud / self-host / both / unclear (+ the evidence)
- Data model: custom objects, multi-tenant, row-level security, migrations
- Integrations / custom apps needed
- Workflows / automation needs
- Scale: number of seats/users
- Region + language the partner would need to cover
8. COMMERCIALS — budget or prices discussed, plan tier (Pro/Org/Enterprise), seat count,
deal value, who pays
9. TIMELINE & DECISION — key dates, decision-makers, urgency, decision process
10. OBJECTIONS / RISKS / FEARS — including anything that could kill the deal
11. ALTERNATIVES — competitors or other options they're weighing
12. DOES THIS DEAL NEED A PARTNER? — yes / no / maybe + why; and if yes, what kind
(scope, region, language, seniority/tier)
13. NEXT STEPS / OPEN QUESTIONS / FOLLOW-UPS
14. PARTNER-FACING BRIEF — a 2-4 sentence narrative a partner can skim to decide yes/no,
drawn only from PART A
== PART B — APPENDIX (not deal-specific) ==
15. PRODUCT / WEBSITE / GTM FEEDBACK — any feedback on the product, pricing page, website
wording, onboarding, or trial; capture even if off-topic for qualification
16. TERMINOLOGY / DOMAIN-LANGUAGE NOTES — words used on the call that mean different things
to each side or carry domain-specific meaning (e.g. "partner", "donor", jargon)
17. KEY VERBATIM QUOTES — 3-8 direct quotes that capture intent, needs, or objections
```
## Step 3 — Output and save
Print the brief in the conversation. Then offer to save it as Markdown — default to a
sibling of the source (e.g. next to the recording) or, for Twenty work, the
`partners-experience/research/` folder, named `YYYY-MM-DD-<company>-call-summary.md`. Don't
write the file unless the user wants it saved.
## How the fields map to the Partner model (for matching)
Section 7 + 12 are the matching axes: `Deployment → deploymentExpertise`, the scope needs →
`partnerScope`, `Region + language` → partner region/languages, scale → capacity, the
"needs a partner?" tier → `partnerTier`. Keeping these explicit is what lets the brief drop
straight into the opportunity→partner handover flow.
## Notes
- This is tuned for Twenty discovery calls, but the schema generalizes — swap the vendor
framing in section 5/12 if reused elsewhere.
- The worked reference example lives at
`partners-experience/research/2026-05-21-tsf-call-summary-final.md` (and the prompt alone
at `partners-experience/research/call-summary-prompt.md`).
@@ -1,36 +0,0 @@
---
name: twenty-partner-design-doc
description: Use when turning a qualified Twenty lead — a call-summary brief plus any client braindump/docs — into an implementation design doc a partner can scope and quote from. Trigger when pointed at a lead folder (e.g. partners-experience/<LEAD>/) and asked to "draft a design doc", "translate this into Twenty terms", "scope this for a partner", or "prep the partner handoff" for a discovery-qualified prospect. Chains after twenty-lead-intro-call-summary.
trigger: /twenty-partner-design-doc
---
# twenty-partner-design-doc
Turn a qualified lead's materials into a **design doc**: a translation of the customer's needs into **Twenty terms** that an implementation **partner reads to scope and quote** the work.
**The doctrine — what to produce, the 12-section structure, the rules, the verification process, and the common mistakes — lives in `design-doc-doctrine.md` in this folder. Read it and follow it.** This file is only the Claude Code wrapper: how to gather inputs, which tools to use for each step, and where to save.
## Inputs
- A lead folder (e.g. `partners-experience/<LEAD>/`) containing a `twenty-lead-intro-call-summary` output plus any braindump / docs / notes.
- If there is a raw transcript but no brief, run **twenty-lead-intro-call-summary** first — this skill chains after it.
- Read everything. Convert `.docx` with `textutil -convert txt "<file>" -output /tmp/out.txt` (macOS) or an equivalent extractor.
## Steps
1. **Gather** — read all source materials in full. Coverage is the point.
2. **Extract needs, grounded** — facts vs inferences (`(inf.)`); never invent. Per the doctrine.
3. **Draft** the doc in the doctrine's 12-section structure, applying every rule in the doctrine.
4. **Verify load-bearing claims live** — use **WebFetch** against the Twenty doc map in the doctrine's Verification section before asserting any capability. Build the §11 appendix as you go.
5. **Reconcile discrepancies** — sources that disagree (call vs braindump; a name differing across/within sources) get flagged both ways, never silently resolved.
6. **Resolve ❓ with the operator** — after a full v1 draft, use **AskUserQuestion** to ask the Twenty team member the unknowns a Twenty insider can answer; leave customer-facing unknowns as ❓. **If running autonomously** (no operator — a subagent/batch run), skip the questions and leave every unknown as ❓ in the body and §11.
7. **Self-check, then save** — scan the output for: an em dash; a bare `~`; first-person voice outside customer quotes; local file paths; a header that isn't the four-field table; **any flag that isn't one of the four canonical emoji-and-text pairs** (`🔮 inf.`, `**❓ open**`, `**⚠️ heavy**`, `**🛑 blocker**`) — a stray 🟥, 🚩, 🚨, ✅, or a naked emoji without its text label is wrong; **a Data-model table missing the `Source` column** (`client` / `inf.`); **a section that just says "X was not named" / "no automations named" / a "left out on purpose" list** — cut it, unknowns go in Open questions; **any bare `§N` reference** instead of a functional anchor link `[§N](#n-section-slug)`; **renumbering gaps** (e.g. cut a section but kept the old numbers around it); **a section that is mostly paragraphs where bullets or a table would do** — exception: Open questions stays a numbered list; **build / runtime / SDK mechanics that don't change the quote** (Docker version, OAuth flavour, auto-system relations, env-var names, CI/CD workflow detail); a point repeated across sections instead of a `[§N](...)` cross-reference; a leftover glossary / domain-language section; any capability claim stated as fact without a References source. Fix, then save to the lead folder as `YYYY-MM-DD-<lead>-design-doc.md`.
## Worked example
Reference: `partners-experience/TSF/2026-05-26-tsf-design-doc.md` shows the target **coverage, flag discipline, and §11 verification appendix**. It predates the current concision / table-header / no-glossary / no-em-dash rules, so where its formatting differs, **follow this doctrine over the example.**
## Notes
- Chain: **twenty-lead-intro-call-summary → twenty-partner-design-doc**; the output feeds the opportunity→partner handover (`designDocStatus` / `designDocUrl`).
- **Phase 2:** `design-doc-doctrine.md` is written to be portable. A future `defineSkill` in this app (a sibling `*.skill.ts` in `src/skills/`) would import it as its `content`, driving an in-product agent — with a verify logic-function tool replacing the WebFetch step, and a Workflow Action triggering it when sales toggles `partnerEligible`. Keep doctrine changes in that file so both the Claude Code skill and the `defineSkill` stay in sync.
@@ -1,164 +0,0 @@
# Design-doc doctrine: translating a lead into Twenty terms
**Portable doctrine.** This file is tool-agnostic. It defines *what* a Twenty partner design doc is, its structure, the rules, and how to verify it. Two consumers use it: the Claude Code skill `twenty-partner-design-doc` (see `SKILL.md` in this folder), and, in a later phase, the `content` of a Twenty `defineSkill` driving an in-product agent. Keep it free of any one tool's mechanics (no file paths, no tool names).
## Purpose
Produce a **design doc** that translates a qualified lead's needs into **Twenty terms** (data model, permissions, integrations, hosting, plus whatever else the client named) so an implementation **partner can scope and quote** the work.
**Core principle:** identify *all* the work with **no blindspots**, **ground every claim** in the source or in live Twenty docs, and **present options rather than prescribe**. The partner quotes off this doc, so a confident-but-wrong capability claim or a hidden requirement is the worst failure.
**Stay on the client's outcome.** Every line must change what the partner *builds* or what the client *receives*. The doc is a design, not a meeting record: a requirement's backstory (why the vendor or a third party does or doesn't satisfy it) is not a build input. Capture the **consequence**, cut the backstory.
The doc is **partner-facing** and may be forwarded verbatim. It is a *suggestion to make scoping easier*, not a spec that constrains how the partner builds.
## Output structure
**Header: a compact table, not a stack of bold lines.** Four fields only:
| Field | Value |
|---|---|
| Lead | name (one-line description of who they are) |
| Date | YYYY-MM-DD |
| Author | Twenty (partnerships) |
| Status | one short line (e.g. "Draft for partner review") |
Keep the header to those four fields. The Status line stays short: a load-bearing caveat (e.g. "data model is an inference pending discovery") goes in the "What this is" callout, not in the header cell. Do not list source materials, internal timelines, or who-promised-what: not load-bearing, not customer-safe (the doc is forwarded verbatim).
After the table, one **"What this is"** callout framing the doc as a partner-scoping suggestion across the full surface, *not* an MVP.
**Flag legend (emoji + short text label, used together so they're scannable and unambiguous):**
- `🔮 inf.` modelling inference (yours; the partner should confirm with the client). Used inline, often, unbolded.
- **❓ open** open question to resolve before quoting.
- **⚠️ heavy** product-constrained or needs special design / has a real cost.
- **🛑 blocker** dealbreaker-grade. Doesn't quote without resolution.
Use these four pairings only. Don't invent new flags, swap the emoji (🟥 / 🚩 / 🚨 / ✅), or drop the text label and use the emoji alone.
### Required sections (always present)
Number sequentially in the final doc, with no gaps:
- **Context**: the 30-second read: who they are, what they want, deployment requirement, scale, language/region.
- **Data model in Twenty terms**: the core. Present objects as a **table, one row per object**: `Object | Std/Custom | Source | Represents | Key fields | Core relations`. The **Source** column is `client` (object/concept grounded in client speech) or `inf.` (you coined it). Inline, tag inferred field names and relations `🔮 inf.`. Spell out SELECT option sets. **Model the relationships, not just the fields**: who introduced/sourced a record (e.g. an ambassador to Opportunity `sourcedBy` link), parent/child, ownership carry as much scoping signal as the attributes. State product constraints **only when they change the build or quote**, and inline (no formula fields → reporting ratios need a logic-function-maintained stored field; custom objects auto-get attachments/notes/tasks/timeline → relationship tracking is free). Where a customer term collides with a Twenty term (their "partner" = a donor), note the mapping inline as a small "term collisions" bullet list above the table; do **not** add a glossary section for it.
- **Roles, permissions & RLS**: map named roles to Twenty's object / field / row-level model; answer "do we need RLS?" against verified, plan-gated capability.
- **Hosting & compliance**: cloud vs self-host, data-residency requirement (verify), GDPR. Flag contradictions.
- **Suggested phasing**: "(the partner's call, not Twenty's)" layers, labelled a suggestion.
- **Open questions / blindspot-killers**: the list a partner must resolve before pricing. **This is the one explicit exception to "tables over bullets": always render as a numbered list**, so the partner can speak them as items 1, 2, 3 with the client. Each item names the decision the question gates and links back to the body section with a functional cross-ref.
- **References & verification**: a table mapping each load-bearing claim to its source doc, plus an explicit list of what the docs could NOT confirm (the unresolved **❓ open** items).
### Conditional sections (include only when grounded in client speech)
If the client didn't name the topic, **omit the section entirely**. Do not include a section just to say "X was not named" — that's filler. Unknowns belong in Open questions, not in their own section.
- **Views & navigation**: include when the data model has enough scope to warrant a surface conversation. Render as a **tight table** with columns `Surface | Shows | Audience`, one row per surface (an object or a filtered subset) the workspace should expose day-to-day. **Do not** add a `Type` column (table / kanban / page layout): the view type is the partner's call, not a scoping decision. If the client explicitly named pipelines or layouts, capture them in the `Shows` cell. Keep the table to the surfaces that genuinely matter; supplement with a one-line follow-up bullet only when an open question about a cross-cutting surface needs flagging (e.g. an unscoped admin view).
- **Automations**: only if the client named processes to automate. Give Workflow-or-logic-function for each; name the automation and its trigger.
- **Integrations**: only when the client explicitly names an external system to connect (Gmail, Slack, WhatsApp, etc.). Direction, indicative mechanism (not prescriptive), data flow, risks. If you spot a likely integration that wasn't named, raise it as a single **❓ open** in Open questions, not as a whole section.
- **Reporting & analytics**: only if the client named reporting needs. Map them to native Dashboards; flag gaps with paths.
Number whatever you include sequentially. A doc with Context, Data model, Integrations (Gmail named), Permissions, Hosting, Phasing, Open questions, References is §1 through §8. Don't leave gaps.
Scale each section to its content. **Coverage of surface area matters more than depth per item.**
## Rules (and why each matters)
- **Be concise: maximum signal per word.** Say a lot in few words. Cut throat-clearing, scene-setting, hedges, and feature-tour prose; prefer a table or a tight clause to a paragraph. Length is not coverage: a short doc that names every requirement beats a long one that pads each. A hesitant buyer reads a focused doc; a bloated one reads as cost.
- **Bullets and tables over paragraphs, with one exception.** Default to bullets; reach for a table whenever rows share structure (objects, views, plans, paths). Use prose only for a nuance no bullet or table cell can carry. The Open questions section is the deliberate exception: always a numbered list, so the partner can read item 1, 2, 3 aloud.
- **Business decisions over technical mechanics.** Scope is what the partner *builds* and what the client *receives*: data sensitivity, who-sees-what, plan choice, hosting choice, integration surface, cost drivers. Cut SDK / runtime / build-tool internals that don't change the quote (Docker version, OAuth flavour, auto-system relations, env-var names, version-control workflow). The partner reads References for the docs that cover those.
- **Fact vs inference is the primary visibility split.** Plain prose = stated by the client. `🔮 inf.` tag inline = your modelling guess, every time it appears. The Data-model table carries a **Source** column (`client` / `inf.`). A partner skimming the doc must be able to see at a glance which lines they need to confirm with the client.
- **Section cross-references are functional anchor links.** Every `§N` reference must be a markdown anchor link: `[§N](#n-section-slug)`. The slug follows GitHub Flavored Markdown auto-anchoring (lowercase; spaces → hyphens; punctuation dropped; `&` removed leaving a double hyphen). A bare `§N` is unreadable to a partner skimming the doc, who just sees numbers with no way to jump.
- **No "left out" / "not named" placeholders.** The doc speaks only about content grounded in the source. A bullet that says *"sessions/programmes/schools left out on purpose"* or a section that says *"no reporting was named"* is filler: cut it. If you want to flag the absence, write it as a question in Open questions ("Are sessions/programmes first-class objects?") that gates a specific decision; otherwise, silence.
- **Never repeat yourself.** State each fact, constraint, or claim once, in its home section; elsewhere link to it (functional `[§N](...)`) rather than restate. Open questions and References are deliberate roll-ups: there, give the pointer and the decision the item gates, not a re-explanation of the body. Repetition is the main source of bloat, and two copies of a claim drift out of sync.
- **Scope altitude: name the decision, defer the mechanics.** A design doc scopes the work; it is not the technical implementation spec. State the *decision* and its *cost or scope consequence*; leave the implementation nitty-gritty (specific env-var names, runtime internals, isolation models, SDK function signatures) to the later technical phase. Example: "production automations need a sandboxed/serverless logic-function backend, an infra cost that belongs in the platform workstream" carries the quote signal; the exact `LOGIC_FUNCTION_TYPE` / `LAMBDA` / region/role/key settings do not belong in a scoping doc. Deep mechanics inflate length and date fast without changing the quote.
- **Ground everything; tag inferences `🔮 inf.`; never grow scope.** The partner quotes off this, so an invented field or requirement inflates the quote or sets a false expectation. If the *concept* is from the source but the *field name* is yours, that is an inference: tag it. Values lifted from the source (the customer's own category list becoming SELECT options) are *grounded*; only names/fields you coin are inferences.
- **Record the design consequence, not the backstory.** State a requirement and the **client's path** that follows from it; do not litigate *why* it's true. When a requirement traces to vendor-internal or third-party detail (corporate structure, legal domicile, ownership, internal commercial arrangements, who-confirms-what-with-whom), keep only the consequence: it is a commercial matter, not a build input, however much airtime it got on the call. An open item the client is waiting on is recorded as the **decision it gates** (a **❓ open** in Open questions), not as a narrative of the vendor's situation.
- **Database discipline: reuse and extend standard objects; add a custom object only at a genuine wall.** Company / Person / Opportunity plus the built-in Notes / Tasks / Timeline cover most CRM needs; every new object multiplies build and maintenance. A **human actor is a Person with a role flag before it is a new object**: create an object only when it needs its own pipeline or reporting. Name the wall when you add one.
- **When the brief is thin, under-reach the inferred model; don't fill the gap.** A blurry situation (no discovery call, sparse notes) is a reason to model the *fewest, most certain* objects and leave the rest as **❓ open** open questions, not to compensate with an elaborate inferred domain. An over-detailed inference reads as scope and cost the customer never asked for and can scare a hesitant buyer off. Lead with standard objects plus the one or two custom objects the domain unmistakably needs; everything else is a question to confirm, not a row in the table. Say plainly, up front, that the model is a minimal starting sketch to validate.
- **Present build approaches; don't prescribe.** An automation can be a no-code Workflow *or* a logic function in an app: say both. Prescribing one penalizes a partner who would do the other. The doc identifies the *need*, not the *build*.
- **Every problem carries a path; never a dead-end flag.** If you flag a constraint (e.g. dashboards can't share externally), pair it with at least one solution (CSV export, a front-component, a public site on the API) or a question that resolves it. A flag with no path is useless to someone pricing the work.
- **Flag what an approach can't satisfy.** The doc's value is surfacing walls and limits per requirement so the partner prices around them, not picking the one true solution.
- **Partner-facing voice.** Say **"Twenty," never first person** ("we / our / ours"). **No local file paths** in the output: cite shareable `docs.twenty.com` URLs only. (Customer quotes that contain "we/our" are fine: they are quotes.)
- **No characterisations or asides; only requirements and capabilities.** The doc is customer-forwardable, so keep out the source chat's off-hand remarks: characterisations of the buyer (budget, temperament, sophistication), named comparisons to competing vendors, and internal partnerships notes (deadlines, who promised what). If price-sensitivity or a competitor displacement genuinely shapes the build, state it neutrally as a requirement (e.g. cost is a selection criterion), never as a quote or judgement.
- **Verify before asserting capability.** Any "Twenty can / can't / has / lacks X" that moves the quote must be verified live (see Verification). **Undocumented ≠ impossible.**
## Formatting
- One line per paragraph: **no mid-sentence hard wraps** (they render as broken lines).
- **Never use em dashes (the long dash).** Restructure the sentence, or use a colon, comma, parentheses, or a period instead.
- **Never a bare `~`** for "approximately": GitHub markdown pairs `~...~` into strikethrough. Write "around" / "about".
- **Flags are the four emoji + text pairs only** (`🔮 inf.`, `**❓ open**`, `**⚠️ heavy**`, `**🛑 blocker**`). Don't swap the emoji or drop the text label.
- **Section cross-references are functional anchor links** (`[§N](#n-section-slug)`), never bare `§N`.
- Mark unverified capability claims `**❓ open**`, never as fact.
## Verification
Any statement of the form **"Twenty can / can't / has / lacks X"** that changes the partner's quote MUST be verified **live** before it is stated as fact. Model training is stale on a fast-moving product; the worst failure is a confident, authoritative-sounding claim that is wrong.
**Source hierarchy (what to trust, in order):**
1. **Live docs** (`docs.twenty.com`): primary truth. For the highest-stakes claims, read the page's primary text rather than trust a summary.
2. **Established Twenty SDK build patterns** (hands-on): build-level facts the customer docs omit (no formula fields; custom objects auto-get attachments/notes/tasks/timeline; two-file relations).
3. **The Twenty operator** (a Twenty team member): best for "is it shipped / internal / undocumented."
4. **Model training**: never the sole basis for a high-stakes claim.
**Right doc layer (the trap):** capabilities live in two layers, so check the right one.
- **Product capabilities** (what the CRM does for the *customer*): the **user guide + pricing page**. Covers roles / row-level permissions, dashboards, plans, hosting, data residency.
- **App capabilities** (what an *app* can define): the **developer/extend** docs. Covers field types, fields, logic functions, views, page layouts.
Checking only the app layer is how "row-level not supported" (wrong) happens: row-level is a **product feature on the Organization plan**. The converse also holds: SDK build patterns *are* sufficient to assert **build-layer** facts even when the customer docs are silent (e.g. auto system relations), so do not demote a well-established build fact to **❓ open**.
**Always verify (the load-bearing checklist), live, every run:**
1. Field types & constraints (e.g. no formula/computed fields).
2. Standard-object extension & relabeling (add fields ✓; relabel / edit built-in SELECT options?).
3. Roles & permissions: object / field / row-level, and plan-gating (which tier).
4. Dashboards & reporting: chart/widget types, beta status, export / external-sharing limits.
5. Automation surfaces: Workflows vs logic functions, what each can do.
6. Integration mechanisms: webhooks, HTTP triggers, scheduled functions, connections.
7. Hosting & deployment: cloud plans & regions / **EU data residency**, self-host availability & requirements.
Plus: any other capability claim the draft makes that carries a **⚠️ heavy** or **❓ open** flag.
**Fallback chain:**
- Docs confirm → state it as fact; record the source in References.
- Docs silent or ambiguous → ask the operator (if available).
- Operator unavailable or unsure → render it as a **❓ open** open question. **Never assert.** When you fetched a page and it was simply *silent*, record that as "docs silent (URL)" rather than leaving the claim unsourced.
**References appendix format:** a `Claim (§) | Verified against` table, then an explicit "**Could not be confirmed in public docs (check with Twenty directly):**" list. Unverified items stay **❓ open** in the body too, never silently promoted to fact. Cite the **human-readable (non-`.md`) URL** here: the `.md` twin is for *your* fetch, not for the partner (a `.md` link renders as raw markdown in a browser).
**Where to verify (Twenty doc map):** docs base = `https://docs.twenty.com/`. Fetch **`<path>.md`** for the clean markdown twin (the form to prefer). Paths:
- Product / user-guide: `user-guide/dashboards/overview` · `user-guide/dashboards/capabilities/widgets` · `user-guide/permissions-access/how-tos/permissions-faq` · `user-guide/data-model/overview` · `user-guide/data-model/capabilities/fields` · `user-guide/data-migration/how-tos/export-your-data`
- Developer / extend: `developers/extend/apps/data/objects` · `developers/extend/apps/data/extending-objects` · `developers/extend/apps/data/relations` · `developers/extend/apps/logic/logic-functions` · `developers/extend/apps/logic/connections` · `developers/extend/apps/layout/views` · `developers/extend/apps/layout/page-layouts` · `developers/extend/apps/config/roles`
- Self-host: `developers/self-host/self-host`
- Pricing & plans: `https://twenty.com/pricing` (**marketing page, no `.md`**; fetch as HTML).
If a `.md` 404s, drop the suffix or re-derive from the docs index: the map can go stale.
## Common mistakes
| Mistake | Reality / fix |
|---|---|
| "Twenty isn't a BI tool" / "can't do row-level" | Stale training. Twenty has Dashboards; row-level is on the Organization plan. **Verify live.** |
| Checked only the app-SDK doc for a product capability | Row-level lives in the product/pricing layer. **Verify the right layer.** |
| Added fields not in the source | Scope growth, wrong quote. Ground every field; tag inferences `🔮 inf.`. |
| Made a human actor (e.g. ambassador) its own object by default | A human is a Person + role flag first; an object only if it needs its own pipeline/reporting. |
| Flagged an automation as "Workflow" | Prescribes the build, penalizes app-builders. Present both. |
| Flagged a limit with no fix | Dead-end flag. Pair every problem with a path. |
| Spelled out runtime/env-var mechanics (`LOGIC_FUNCTION_TYPE` / `LAMBDA`, region/role/key, Docker version, OAuth flavour, CI/CD workflow detail) | Wrong altitude. Name the decision + its cost; defer the mechanics to the technical phase. |
| Same point restated across sections | State it once in its home section; cross-reference with a functional `[§N](...)` link. |
| Padded prose / feature-tour narration | Maximum signal per word. State the content; cut the scene-setting. |
| Added a domain-language map / glossary section | Removed. Note a genuine term collision inline in Data model; no standalone glossary. |
| First-person "not ours" in a partner doc | Say "Twenty." The doc is forwarded verbatim. |
| Local file paths in the output | Mean nothing to a partner. Cite `docs.twenty.com` only. |
| Data-model section as prose; fields modelled but not relationships | Use a per-object field **table**; model the links, not just attributes. |
| Hard-wrapped mid-sentence / used `~` / used an em dash | Broken lines / accidental strikethrough / banned dash. One line per paragraph; "around" not `~`; colon or comma, never an em dash. |
| Used an emoji other than the four canonical (🟥 / 🚩 / 🚨 / ✅), or used the emoji without the text label | Stick to `🔮 inf.`, `**❓ open**`, `**⚠️ heavy**`, `**🛑 blocker**`. The text label disambiguates. |
| Section is mostly paragraphs | Default to bullets and tables; prose only when a nuance can't fit a list. Exception: Open questions stays a numbered list. |
| Included build / runtime / SDK mechanics that don't move the quote | Wrong altitude. Business decisions, scope consequences, and References only; mechanics belong in the later technical phase. |
| Data-model table missing a Source column | Partner can't see at a glance which rows the client confirmed vs which are your inferences. Add `client` / `inf.`. |
| Cross-references are bare `§N` instead of functional links | The partner sees disconnected numbers and can't navigate. Use `[§N](#n-section-slug)` everywhere. |
| Included a section that just says "X was not named" / "no automations named" / a "left out on purpose" list | Cut the whole section / bullet. Unknowns go in Open questions; the doc speaks only about grounded content. |
| Renumbered with gaps (e.g. cut Reporting but kept §5-§11 numbering as §5, §6, §8) | Renumber sequentially, no gaps. A partner doesn't know which sections were omitted. |
| Views section prescribed the view type (table / kanban / page layout) | The partner picks the view type. List **what to surface** (objects, subsets, who they're for), not **how**. |
| Views section rendered as a bullet list | Render as a `Surface \| Shows \| Audience` table; supplement with a follow-up bullet only for a cross-cutting open question. |
| Wrote up the vendor's corporate status / legal domicile / ownership / sign-off | Backstory, not a build input. Record only the **consequence**: requirement → the client's path; gate the open item as **❓ open** in Open questions. |
| Filled a thin brief with an elaborate inferred model | Over-reach scares a hesitant buyer with unrequested scope. Model the few certain objects; flag the rest as **❓ open**; say it's a minimal sketch. |
| Added an Integrations section with connectors the customer never named | Integrations is conditional, not default. Omit it absent a named system; at most flag one **❓ open**. |
| Kept buyer characterisations / competitor asides / internal timelines | Not customer-safe. State needs neutrally; cut the rest. |
| Listed source materials / who-promised-what in the header, or stacked it as bold lines | Header is a four-field table. Not customer-safe content goes nowhere. |

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