Compare commits

..
Author SHA1 Message Date
sonarly-bot 52f09c76b6 fix(server): stop diffing removed rolePermissionFlag.flag
https://sonarly.com/issue/39742?type=bug

App install/sync fails for apps declaring role permission flags because migration update actions include `rolePermissionFlag.update.flag`, which TypeORM rejects after the 2.7 cutover removed that property from active entity metadata.

Fix: Implemented a targeted server-side fix in the migration diff layer so post-2.7 installs/syncs stop generating invalid `rolePermissionFlag.update.flag` payloads.

What changed:
1) `packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts`
- In the `rolePermissionFlag` property configuration, changed:
  - `flag.toCompare` from `true` to `false`.

Why this fixes the bug:
- The update payload is built from comparable properties. Marking `flag` as non-comparable prevents the diff engine from producing `update.flag`, which is the field TypeORM rejects after the 2.7 cutover removed/hidden that column from active metadata.

2) `packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap`
- Updated snapshot so `rolePermissionFlag.propertiesToCompare` no longer includes `"flag"`.

I also checked recent history for an existing exact fix (`git log --all --since='30 days ago'` + candidate commit inspection). There is related work (`d13cc7c3497`), but this branch still has `rolePermissionFlag.flag` set as comparable, so the exact root-cause fix was not present here.

Authored by Sonarly by autonomous analysis (run 45294).
2026-05-22 11:50:06 +00:00
2509 changed files with 55590 additions and 100747 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
@@ -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: ./
-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

+1 -1
View File
@@ -28,7 +28,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",
+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 +1,6 @@
{
"name": "twenty-partners",
"version": "0.3.3",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -14,23 +14,14 @@
"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"
"import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts"
},
"dependencies": {
"twenty-client-sdk": "2.4.0",
"twenty-sdk": "2.4.0",
"zod": "^4.1.11"
"twenty-sdk": "2.4.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
@@ -10,12 +10,4 @@ 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,
},
},
});
@@ -3,7 +3,7 @@ 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 PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd';
export const ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '379b11d5-44d5-476b-ba7d-31d5f515c9b4';
export const ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER = '7a34da39-a8e1-44c7-88b4-91ceaa064920';
export const PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '3fe15ab5-e38b-4914-af17-2270b210aeb2';
@@ -35,7 +35,7 @@ export const ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER = '37944f52-cbe5-4814-a1
// 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_QUOTES_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b891-6201149510b3';
export const PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER = '13e2334a-6b1e-4080-8c74-d11109990cc1';
export const VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '6aed30c6-d80f-4ac6-aab0-db5bc59e5c4b';
export const PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
export const PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
@@ -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,
},
});
@@ -0,0 +1,22 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_QUOTE_OPPORTUNITY_FIELD_ID = '36684f3e-f01a-4270-8f34-78966747dd64';
export const PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID = '4857f339-a051-4cdb-bd8a-6219b933d1ce';
export default defineField({
universalIdentifier: PARTNER_QUOTE_OPPORTUNITY_FIELD_ID,
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'opportunity',
label: 'Opportunity',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'opportunityId',
},
});
@@ -0,0 +1,22 @@
import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_QUOTE_PARTNER_FIELD_ID = '1774738e-96a1-43e2-aca4-d3c7cc794c50';
export const PARTNER_QUOTES_ON_PARTNER_FIELD_ID = 'ac7995f1-7ccf-4607-827b-0788eeacaee0';
export default defineField({
universalIdentifier: PARTNER_QUOTE_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTES_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});
@@ -0,0 +1,18 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID, PARTNER_QUOTE_OPPORTUNITY_FIELD_ID } from './partner-quote-opportunity.field';
export default defineField({
universalIdentifier: PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerQuotes',
label: 'Partner Quotes',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTE_OPPORTUNITY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,18 @@
import { FieldType, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_QUOTES_ON_PARTNER_FIELD_ID, PARTNER_QUOTE_PARTNER_FIELD_ID } from './partner-quote-partner.field';
export default defineField({
universalIdentifier: PARTNER_QUOTES_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partnerQuotes',
label: 'Partner Quotes',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTE_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -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: '[email protected]',
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: '[email protected]' }), 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: '[email protected]' }),
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: '[email protected]', 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('[email protected]');
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: '[email protected]', city: 'London' })));
await trackCreated(first);
expect(first.ok).toBe(true);
if (!first.ok) return;
const second = await handler(
authedEvent(
baseInput({
email: '[email protected]',
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: '[email protected]' })));
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: '[email protected]', 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: '[email protected]',
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: '[email protected]', 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: '[email protected]',
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,107 +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;
projectBudgetTypical: 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 },
projectBudgetTypical: { 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,
},
});
@@ -4,58 +4,53 @@ 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' },
},
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 },
projectBudgetTypical: { 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 Partner = {
id: string;
name: string | null;
slug: string | null;
introduction: string | null;
languagesSpoken: string[] | null;
deploymentExpertise: string[] | null;
region: string[] | null;
calendarLink: { primaryLinkUrl: string | null } | null;
};
type ListAvailablePartnersResult =
| { ok: true; count: number; partners: AvailablePartner[] }
| { ok: true; count: number; partners: Partner[] }
| { 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);
const result = await client.query({
partners: {
__args: {
filter: {
validationStage: { eq: 'VALIDATED' },
availability: { eq: 'AVAILABLE' },
},
orderBy: [{ name: 'AscNullsLast' }],
first: 100,
},
edges: {
node: {
id: true,
name: true,
slug: true,
introduction: true,
languagesSpoken: true,
deploymentExpertise: true,
region: true,
calendarLink: { primaryLinkUrl: true },
},
},
},
} as any);
const partners = (
(result?.partners?.edges ?? []) as Array<{ node: Partner }>
).map((edge) => edge.node);
return { ok: true, count: partners.length, partners };
} catch (err) {
@@ -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 +1,15 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER,
PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER,
PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER,
universalIdentifier: PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconQuote',
icon: 'IconFileDollar',
position: 3,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier: PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +1,15 @@
import { FieldType, defineObject } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_QUOTE_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',
universalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'partnerQuote',
namePlural: 'partnerQuotes',
labelSingular: 'Partner Quote',
labelPlural: 'Partner Quotes',
description: 'A quote a partner submitted for a customer deal',
icon: 'IconFileDollar',
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6',
fields: [
@@ -21,20 +21,6 @@ export default defineObject({
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,
@@ -59,21 +45,21 @@ export default defineObject({
isNullable: true,
},
{
universalIdentifier: 'da7e9094-e2c3-47d3-924f-a1d4d3c717ed',
type: FieldType.LINKS,
name: 'interview',
label: 'Interview',
icon: 'IconMicrophone',
universalIdentifier: '2cbe67e3-24ec-421b-bab5-50f3306c2391',
type: FieldType.CURRENCY,
name: 'amount',
label: 'Amount',
icon: 'IconCoin',
isNullable: true,
},
{
universalIdentifier: 'f303369e-288c-4a48-9920-c1de0ad9a159',
type: FieldType.FILES,
name: 'documents',
label: 'Documents',
name: 'quoteFile',
label: 'Quote File',
icon: 'IconPaperclip',
isNullable: true,
universalSettings: { maxNumberOfValues: 10 },
universalSettings: { maxNumberOfValues: 1 },
},
],
});
@@ -84,7 +84,7 @@ export default defineObject({
universalIdentifier: '500021ad-ca42-4fd3-8727-392dd26b722a',
type: FieldType.MULTI_SELECT,
name: 'partnerScope',
label: 'Categories',
label: 'Partner Scope',
icon: 'IconListCheck',
isNullable: true,
options: [
@@ -93,11 +93,6 @@ export default defineObject({
{ 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' },
],
},
{
@@ -476,14 +471,6 @@ export default defineObject({
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,
@@ -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']);
});
});
@@ -23,13 +23,10 @@
// tsx src/scripts/import-from-tft.ts
import { config } from 'dotenv';
config({ path: process.env.ENV_FILE ?? '.env.local' });
config({ path: '.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`);
@@ -79,17 +76,6 @@ const PARTNER_STAGE_TO_VALIDATION: Record<string, string> = {
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>> = {
@@ -102,36 +88,19 @@ const LOCAL_OPTIONS: Record<string, Set<string>> = {
quoteStatus: new Set(['WIP', 'INTERVIEW_SCHEDULED', 'UNDER_CUSTOMER_PARTNER_REVIEW', 'APPROVED', 'REJECTED']),
};
const slugify = (s: string): string =>
s
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const edges = (result: any, key: string): any[] =>
(result?.[key]?.edges ?? []).map((e: any) => e.node);
const uniq = (values: (string | undefined | null)[]): string[] =>
[...new Set(values.filter((v): v is string => !!v))];
// Normalize a domain for dedup. Twenty's company domain is a unique key but is
// stored with an https:// prefix, while TFT values vary, so compare on a canonical
// form (no protocol, no www, no trailing slash, lowercased).
const normDomain = (d?: string | null): string | undefined =>
d
? d.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '') || undefined
: undefined;
// Distinct non-empty values across rows, for the preflight report. Flattens array
// fields (e.g. partnerScope, typeCustom) and stringifies, so scalar and
// multi-select fields share one path. Derived from the already-fetched source
// rows — no per-loop bookkeeping needed.
const distinct = <TRow>(rows: TRow[], pick: (row: TRow) => unknown): string[] =>
[
...new Set(
rows.flatMap((row) => {
const value = pick(row);
return Array.isArray(value) ? value : value != null ? [value] : [];
}),
),
]
.map(String)
.sort();
async function main() {
console.log(`[import] mode: ${APPLY ? 'APPLY (writing to local)' : 'DRY-RUN (no writes)'}`);
const local = new CoreApiClient({
@@ -145,10 +114,26 @@ async function main() {
if (APPLY && writes++ > 0) await new Promise((r) => setTimeout(r, 750));
};
// Distinct TFT values seen, for the preflight coverage report.
const seen: Record<string, Set<string>> = {
partnerStage: new Set(),
partnerTier: new Set(),
partnerScope: new Set(),
typeOfTeam: new Set(),
oppStage: new Set(),
hostingType: new Set(),
subscriptionType: new Set(),
subscriptionFrequency: new Set(),
quoteStatus: new Set(),
typeCustom: new Set(),
};
const note = (bucket: string, value?: string | null) => {
if (value) seen[bucket].add(value);
};
// ---------------------------------------------------------------------
// 1. Read all three TFT datasets (raw fetch; not rate-limited by local).
// ---------------------------------------------------------------------
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"] } }) {
@@ -158,7 +143,7 @@ async function main() {
emails { primaryEmail }
city jobTitle
linkedinLink { primaryLinkUrl }
partnerStage partnerTier partnerScope partnerTypeOfTeam partnerTimezone partnerIsAvailable partnerSkills
partnerStage partnerTier partnerScope partnerTypeOfTeam partnerIsAvailable partnerSkills
partnerBudgetMinimum { amountMicros currencyCode }
partnerBudgetAverage { amountMicros currencyCode }
company { id name domainName { primaryLinkUrl } }
@@ -167,8 +152,7 @@ async function main() {
}`),
'people',
);
console.log('[import] fetching TFT opportunities...');
const tftOppsAll = edges(
const tftOpps = edges(
await tftQuery(`query {
opportunities(first: 500) {
edges { node {
@@ -182,32 +166,16 @@ async function main() {
}`),
'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 }
} }
edges { node { id name status approvalDate typeCustom partnerPerson { 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 quotes = tftContent.filter((c: any) => Array.isArray(c.typeCustom) && c.typeCustom.includes('PARTNER_QUOTE'));
console.log(`[import] TFT: ${tftPeople.length} partner-people, ${tftOpps.length} opportunities, ${quotes.length} partner quotes`);
const personSlug = (p: any): string =>
slugify([p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner') || p.id;
@@ -215,7 +183,6 @@ async function main() {
// ---------------------------------------------------------------------
// 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
@@ -228,33 +195,17 @@ async function main() {
: [],
);
// 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 companyNames = uniq([...tftPeople.map((p: any) => p.company?.name), ...tftOpps.map((o: any) => o.company?.name)]);
const companyIdByName = new Map<string, string>(
companyNames.length
? edges(
await local.query({
companies: { __args: { filter: { name: { in: companyNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
} as any),
'companies',
).map((n: any) => [n.name, n.id])
: [],
);
const oppTftIds = uniq(tftOpps.filter((o: any) => o.name).map((o: any) => o.id));
const oppIdByTftId = new Map<string, string>(
@@ -268,18 +219,14 @@ async function main() {
: [],
);
// 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
const quoteNames = uniq(quotes.map((q: any) => q.name || 'Partner quote'));
const quoteIdByName = new Map<string, string>(
quoteNames.length
? edges(
await local.query({
partnerContents: { __args: { filter: { name: { in: contentNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
partnerQuotes: { __args: { filter: { name: { in: quoteNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
} as any),
'partnerContents',
'partnerQuotes',
).map((n: any) => [n.name, n.id])
: [],
);
@@ -291,105 +238,43 @@ async function main() {
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}`);
}
const created: any = await local.mutation({
createCompany: {
__args: { data: { name, ...(domain ? { domainName: { primaryLinkUrl: domain } } : {}) } },
id: true,
},
} as any);
id = created.createCompany.id;
}
companyIdByName.set(name, id);
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) {
note('partnerStage', p.partnerStage);
note('partnerTier', p.partnerTier);
(Array.isArray(p.partnerScope) ? p.partnerScope : []).forEach((s: string) => note('partnerScope', s));
note('typeOfTeam', p.partnerTypeOfTeam);
const slug = personSlug(p);
const companyId = await upsertCompany(p.company?.name, p.company?.domainName?.primaryLinkUrl);
// 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 } : {}),
...(Array.isArray(p.partnerScope) && p.partnerScope.length ? { partnerScope: p.partnerScope } : {}),
...(p.partnerTypeOfTeam ? { typeOfTeam: p.partnerTypeOfTeam } : {}),
...(Array.isArray(p.partnerSkills) && p.partnerSkills.length ? { skills: p.partnerSkills } : {}),
...(p.city ? { city: p.city } : {}),
@@ -399,29 +284,39 @@ async function main() {
...(companyId && APPLY ? { companyId } : {}),
};
const existingId = partnerIdBySlug.get(slug);
const partnerId = await upsert('Partner', existingId, data, slug);
if (existingId) {
let partnerId = partnerIdBySlug.get(slug);
if (partnerId) {
if (APPLY) {
await pace();
await local.mutation({ updatePartner: { __args: { id: partnerId, data }, id: true } } as any);
}
partnersUpdated++;
} else {
partnerIdBySlug.set(slug, partnerId);
if (APPLY) {
await pace();
const created: any = await local.mutation({ createPartner: { __args: { data }, id: true } } as any);
partnerId = created.createPartner.id;
} else {
partnerId = `dry:partner:${slug}`;
}
partnerIdBySlug.set(slug, partnerId as string);
partnersCreated++;
}
localPartnerIdByTftPersonId.set(p.id, partnerId);
partnersDone++;
if (partnersDone % 10 === 0 || partnersDone === tftPeople.length)
console.log(`[import] partners ${partnersDone}/${tftPeople.length} (created=${partnersCreated} updated=${partnersUpdated})`);
localPartnerIdByTftPersonId.set(p.id, partnerId as string);
}
console.log(`[import] partners done: created=${partnersCreated} updated=${partnersUpdated}`);
console.log(`[import] partners 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;
note('oppStage', o.stage);
note('hostingType', o.hostingType);
note('subscriptionType', o.subscriptionType);
note('subscriptionFrequency', o.subscriptionFrequence);
const companyId = await upsertCompany(o.company?.name, o.company?.domainName?.primaryLinkUrl);
const partnerId = o.partner?.id ? localPartnerIdByTftPersonId.get(o.partner.id) : undefined;
if (partnerId) oppsPartnerLinked++;
@@ -443,44 +338,58 @@ async function main() {
};
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})`);
if (existingId) {
if (APPLY) {
await pace();
await local.mutation({ updateOpportunity: { __args: { id: existingId, data }, id: true } } as any);
}
oppsUpdated++;
} else {
if (APPLY) {
await pace();
await local.mutation({ createOpportunity: { __args: { data }, id: true } } as any);
}
oppsCreated++;
}
}
console.log(`[import] opportunities done: created=${oppsCreated} updated=${oppsUpdated} (partner-linked=${oppsPartnerLinked})`);
console.log(`[import] opportunities 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);
// -- Partner quotes (upsert by name) --
let quotesCreated = 0;
let quotesUpdated = 0;
tftContent.forEach((c: any) => (Array.isArray(c.typeCustom) ? c.typeCustom : []).forEach((t: string) => note('typeCustom', t)));
for (const q of quotes) {
note('quoteStatus', q.status);
const name = q.name || 'Partner quote';
const partnerId = q.partnerPerson?.id ? localPartnerIdByTftPersonId.get(q.partnerPerson.id) : undefined;
const data: Record<string, unknown> = {
name,
...(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 } } : {}),
...(q.status ? { status: q.status } : {}),
...(q.approvalDate ? { approvalDate: q.approvalDate } : {}),
...(partnerId && APPLY ? { partnerId } : {}),
...(customerCompanyId && APPLY ? { customerCompanyId } : {}),
};
const existingId = contentIdByName.get(name);
await upsert('PartnerContent', existingId, data, name);
if (existingId) contentUpdated++;
else contentCreated++;
const existingId = quoteIdByName.get(name);
if (existingId) {
if (APPLY) {
await pace();
await local.mutation({ updatePartnerQuote: { __args: { id: existingId, data }, id: true } } as any);
}
quotesUpdated++;
} else {
if (APPLY) {
await pace();
await local.mutation({ createPartnerQuote: { __args: { data }, id: true } } as any);
}
quotesCreated++;
}
}
console.log(`[import] partner content created=${contentCreated} updated=${contentUpdated}`);
console.log(`[import] partner quotes created=${quotesCreated} updated=${quotesUpdated}`);
// ---------------------------------------------------------------------
// 4. Preflight: distinct TFT values vs local option coverage. Derived
// directly from the fetched source rows (no per-loop bookkeeping).
// 4. Preflight: distinct TFT values vs local option coverage.
// ---------------------------------------------------------------------
const report = (label: string, values: string[], optionKey?: string) => {
const report = (label: string, bucket: string, optionKey?: string) => {
const values = [...seen[bucket]].sort();
const allowed = optionKey ? LOCAL_OPTIONS[optionKey] : undefined;
const uncovered = allowed ? values.filter((v) => !allowed.has(v)) : [];
console.log(
@@ -488,27 +397,21 @@ async function main() {
(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));
report('partnerStage (-> validationStage map)', 'partnerStage');
report('partnerTier', 'partnerTier', 'partnerTier');
report('partnerScope', 'partnerScope', 'partnerScope');
report('typeOfTeam', 'typeOfTeam', 'typeOfTeam');
report('opp stage (-> matchStatus map)', 'oppStage');
report('hostingType', 'hostingType', 'hostingType');
report('subscriptionType', 'subscriptionType', 'subscriptionType');
report('subscriptionFrequency', 'subscriptionFrequency', 'subscriptionFrequency');
report('quote status', 'quoteStatus', 'quoteStatus');
report('customerContent typeCustom', 'typeCustom');
const unmappedStages = [...seen.partnerStage].filter((s) => !(s in PARTNER_STAGE_TO_VALIDATION));
const unmappedOpps = [...seen.oppStage].filter((s) => !(s in STAGE_TO_MATCH_STATUS));
if (unmappedStages.length) console.log(`[preflight] ⚠️ partnerStage not mapped: ${unmappedStages.join(', ')}`);
if (unmappedOpps.length) console.log(`[preflight] ⚠️ opp stage not mapped: ${unmappedOpps.join(', ')}`);
if (unmappedTz.length) console.log(`[preflight] ⚠️ partnerTimezone not mapped: ${unmappedTz.join(', ')}`);
}
main().catch((err) => {
@@ -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);
});
@@ -11,7 +11,7 @@
// tsx src/scripts/seed.ts
import { config } from 'dotenv';
config({ path: process.env.ENV_FILE ?? '.env.local' });
config({ path: '.env.local' });
import { CoreApiClient } from 'twenty-client-sdk/core';
@@ -22,14 +22,6 @@ const requireEnv = (name: string): string => {
};
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;
@@ -45,24 +37,19 @@ type Partner = {
partnerScope: string[];
typeOfTeam: string;
country: string;
city: string;
hourlyRateUsd: number | null;
projectBudgetMinUsd: number | null;
projectBudgetTypicalUsd: 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, projectBudgetTypicalUsd: 80000, 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, projectBudgetTypicalUsd: 100000, 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, projectBudgetTypicalUsd: 60000, 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, projectBudgetTypicalUsd: 40000, 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, projectBudgetTypicalUsd: 50000, 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, projectBudgetTypicalUsd: 120000, 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, projectBudgetTypicalUsd: 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, projectBudgetTypicalUsd: 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, projectBudgetTypicalUsd: 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, projectBudgetTypicalUsd: null, skills: ['MENA', 'Arabic'] },
{ slug: 'nine-dots-ventures', name: 'Nine Dots Ventures', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Boutique CRM implementer for real-estate workflows and WhatsApp automation.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['EUROPE', 'MENA'], languagesSpoken: ['ENGLISH', 'FRENCH', 'ARABIC'], partnerTier: 'ADVANCED', partnerScope: ['DATA_MODEL', 'WORKFLOWS', 'APPS'], typeOfTeam: 'AGENCY', country: 'FRANCE' },
{ slug: 'elevate-consulting', name: 'Elevate Consulting', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Revenue-operations partner for B2B SaaS teams scaling seed to Series C.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['US', 'LATAM'], languagesSpoken: ['ENGLISH', 'SPANISH'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MIGRATION', 'DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'UNITED_STATES' },
{ slug: 'w3villa-technologies', name: 'W3Villa Technologies', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Engineering-heavy partner running large self-hosted Twenty deployments.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'MENA'], languagesSpoken: ['ENGLISH', 'HINDI'], partnerTier: 'ADVANCED', partnerScope: ['HOSTING_ENVIRONMENT', 'APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'INDIA' },
{ slug: 'act-education', name: 'Act Education', validationStage: 'VALIDATED', availability: 'UNAVAILABLE', introduction: 'CRM partner for European education providers; compliance-first self-hosting.', calendarLink: CAL, deploymentExpertise: ['SELF_HOST'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'GERMAN'], partnerTier: 'NEW', partnerScope: ['HOSTING_ENVIRONMENT', 'DATA_MODEL'], typeOfTeam: 'SOLO', country: 'GERMANY' },
{ slug: 'netzero-systems', name: 'NetZero Systems', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'LATAM go-to-market partner for climate-tech and renewable-energy companies.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['LATAM', 'US'], languagesSpoken: ['ENGLISH', 'SPANISH', 'PORTUGUESE'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'BRAZIL' },
{ slug: 'meridian-craft', name: 'Meridian Craft', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'APAC implementation studio for fintech and logistics; English + Chinese.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'AFRICA'], languagesSpoken: ['ENGLISH', 'CHINESE', 'MALAY'], partnerTier: 'ADVANCED', partnerScope: ['APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'SINGAPORE' },
{ slug: 'applicant-studio', name: 'Applicant Studio', validationStage: 'APPLICATION', availability: 'UNAVAILABLE', introduction: 'New applicant; awaiting first review.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'FRENCH'], partnerTier: 'NEW', partnerScope: ['DATA_MODEL'], typeOfTeam: 'SOLO', country: 'FRANCE' },
{ slug: 'rising-crm', name: 'Rising CRM', validationStage: 'POTENTIAL', availability: 'AVAILABLE', introduction: 'Promising applicant in evaluation.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['US'], languagesSpoken: ['ENGLISH'], partnerTier: 'NEW', partnerScope: ['WORKFLOWS', 'APPS'], typeOfTeam: 'AGENCY', country: 'UNITED_STATES' },
{ slug: 'legacy-partners', name: 'Legacy Partners', validationStage: 'FORMER', availability: 'UNAVAILABLE', introduction: 'Former partner; no longer active in the program.', calendarLink: CAL, deploymentExpertise: ['SELF_HOST'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'GERMAN'], partnerTier: 'INTERMEDIATE', partnerScope: ['HOSTING_ENVIRONMENT'], typeOfTeam: 'AGENCY', country: 'UNITED_KINGDOM' },
{ slug: 'declined-co', name: 'Declined Co', validationStage: 'REJECTED', availability: 'UNAVAILABLE', introduction: 'Application rejected after review.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['MENA'], languagesSpoken: ['ENGLISH', 'ARABIC'], partnerTier: 'NEW', partnerScope: ['APPS'], typeOfTeam: 'SOLO', country: 'UNITED_ARAB_EMIRATES' },
];
const COMPANIES = [
@@ -102,13 +89,13 @@ const OPPORTUNITIES: Opp[] = [
{ 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[] };
type Quote = { name: string; status: string; partnerSlug: string; oppName: string };
const QUOTES: Quote[] = [
{ name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', 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'] },
{ name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — APAC fleet CRM' },
{ name: 'Helix clinical — NetZero quote', status: 'INTERVIEW_SCHEDULED', partnerSlug: 'netzero-systems', oppName: 'Helix Bio — clinical trials CRM' },
{ name: 'Acme rollout — Elevate quote', status: 'UNDER_CUSTOMER_PARTNER_REVIEW', partnerSlug: 'elevate-consulting', oppName: 'Acme RE — CRM rollout' },
{ name: 'Sunrise LATAM — Nine Dots quote', status: 'APPROVED', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — LATAM expansion' },
{ name: 'Helix self-host — Meridian quote', status: 'REJECTED', partnerSlug: 'meridian-craft', oppName: 'Helix Bio — self-host evaluation' },
];
const nodes = (r: any, key: string): any[] => (r?.[key]?.edges ?? []).map((e: any) => e.node);
@@ -131,13 +118,7 @@ async function main() {
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) } : {}),
...(p.projectBudgetTypicalUsd != null ? { projectBudgetTypical: usd(p.projectBudgetTypicalUsd) } : {}),
country: p.country,
};
const id = partnerIdBySlug.get(p.slug);
if (id) {
@@ -196,12 +177,12 @@ async function main() {
// -- 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');
const data = { name: q.name, status: q.status, partnerId: partnerIdBySlug.get(q.partnerSlug), opportunityId: oppIdByName.get(q.oppName) };
const existing = nodes(await client.query({ partnerQuotes: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerQuotes');
if (existing[0]?.id) {
await client.mutation({ updatePartnerContent: { __args: { id: existing[0].id, data }, id: true } } as any);
await client.mutation({ updatePartnerQuote: { __args: { id: existing[0].id, data }, id: true } } as any);
} else {
await client.mutation({ createPartnerContent: { __args: { data }, id: true } } as any);
await client.mutation({ createPartnerQuote: { __args: { data }, id: true } } as any);
}
quoteCount++;
}
@@ -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. |
@@ -1,21 +1,21 @@
import { ViewType, defineView } from 'twenty-sdk/define';
import {
PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
// Index view for partner content.
// Index view for partner quotes.
export default defineView({
universalIdentifier: PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Partner content',
icon: 'IconQuote',
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
universalIdentifier: PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Partner quotes',
icon: 'IconFileDollar',
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
fields: [
{ universalIdentifier: 'ad7b3702-b552-4355-afec-1e1e96d9f3df', fieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6', position: 0, isVisible: true },
{ universalIdentifier: 'a9bf3eaa-ec27-4a0a-8df2-e18c8f4239a7', fieldMetadataUniversalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692', position: 1, isVisible: true },
{ universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 2, isVisible: true },
{ universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 1, isVisible: true },
{ universalIdentifier: '62bb1536-539c-4fb0-95bf-440c5c5da89f', fieldMetadataUniversalIdentifier: '2cbe67e3-24ec-421b-bab5-50f3306c2391', position: 2, isVisible: true },
{ universalIdentifier: 'fbd1f953-1dd2-4d0f-a239-148a0688fbff', fieldMetadataUniversalIdentifier: 'b52d263e-423e-40b0-b82c-29214597c005', position: 3, isVisible: true },
],
});
@@ -1,15 +0,0 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
// Pure unit tests — no server required, no globalSetup.
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
include: ['src/**/*.test.ts'],
},
});
@@ -4086,7 +4086,6 @@ __metadata:
typescript: "npm:^5.9.3"
vite-tsconfig-paths: "npm:^4.2.1"
vitest: "npm:^3.1.1"
zod: "npm:^4.1.11"
languageName: unknown
linkType: soft
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
"version": "2.9.0",
"version": "2.7.0",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
@@ -42,14 +42,14 @@
"dependencies": {
"@genql/cli": "^3.0.3",
"@genql/runtime": "^2.10.0",
"esbuild": "^0.28.0",
"esbuild": "^0.25.0",
"graphql": "^16.8.1"
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"tsc-alias": "^1.8.16",
"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",
@@ -247,7 +247,7 @@ type FieldPermission {
type RolePermissionFlag {
id: UUID!
roleId: UUID!
flag: String!
flag: PermissionFlagType!
}
type ApiKeyForRole {
@@ -441,7 +441,6 @@ type LogicFunction {
description: String
runtime: String!
timeoutSeconds: Float!
executionMode: LogicFunctionExecutionMode!
sourceHandlerPath: String!
handlerName: String!
cronTriggerSettings: JSON
@@ -455,11 +454,6 @@ type LogicFunction {
updatedAt: DateTime!
}
enum LogicFunctionExecutionMode {
LIVE
PREBUILT
}
type StandardOverrides {
label: String
description: String
@@ -529,7 +523,6 @@ type IndexField {
id: UUID!
fieldMetadataId: UUID!
order: Float!
subFieldName: String
createdAt: DateTime!
updatedAt: DateTime!
}
@@ -1106,7 +1099,7 @@ type PageLayoutWidgetCanvasPosition {
layoutMode: PageLayoutTabLayoutMode!
}
union WidgetConfiguration = AggregateChartConfiguration | StandaloneRichTextConfiguration | PieChartConfiguration | LineChartConfiguration | IframeConfiguration | BarChartConfiguration | CalendarConfiguration | FrontComponentConfiguration | EmailsConfiguration | EmailThreadConfiguration | FieldConfiguration | FieldRichTextConfiguration | FieldsConfiguration | FilesConfiguration | NotesConfiguration | TasksConfiguration | TimelineConfiguration | ViewConfiguration | RecordTableConfiguration | WorkflowConfiguration | WorkflowRunConfiguration | WorkflowVersionConfiguration
union WidgetConfiguration = AggregateChartConfiguration | StandaloneRichTextConfiguration | PieChartConfiguration | LineChartConfiguration | IframeConfiguration | GaugeChartConfiguration | BarChartConfiguration | CalendarConfiguration | FrontComponentConfiguration | EmailsConfiguration | EmailThreadConfiguration | FieldConfiguration | FieldRichTextConfiguration | FieldsConfiguration | FilesConfiguration | NotesConfiguration | TasksConfiguration | TimelineConfiguration | ViewConfiguration | RecordTableConfiguration | WorkflowConfiguration | WorkflowRunConfiguration | WorkflowVersionConfiguration
type AggregateChartConfiguration {
configurationType: WidgetConfigurationType!
@@ -1126,6 +1119,7 @@ type AggregateChartConfiguration {
enum WidgetConfigurationType {
AGGREGATE_CHART
GAUGE_CHART
PIE_CHART
BAR_CHART
LINE_CHART
@@ -1244,6 +1238,18 @@ type IframeConfiguration {
url: String
}
type GaugeChartConfiguration {
configurationType: WidgetConfigurationType!
aggregateFieldMetadataId: UUID!
aggregateOperation: AggregateOperations!
displayDataLabel: Boolean
color: String
description: String
filter: JSON
timezone: String
firstDayOfTheWeek: Int
}
type BarChartConfiguration {
configurationType: WidgetConfigurationType!
aggregateFieldMetadataId: UUID!
@@ -1308,7 +1314,6 @@ type FieldConfiguration {
configurationType: WidgetConfigurationType!
fieldMetadataId: String!
fieldDisplayMode: FieldDisplayMode!
viewId: String
}
"""Display mode for field configuration widgets"""
@@ -1317,7 +1322,6 @@ enum FieldDisplayMode {
EDITOR
FIELD
VIEW
TABLE
}
type FieldRichTextConfiguration {
@@ -1439,39 +1443,6 @@ type Analytics {
success: Boolean!
}
type VerificationRecord {
type: String!
key: String!
value: String!
priority: Float
}
type EmailingDomain {
id: UUID!
createdAt: DateTime!
updatedAt: DateTime!
domain: String!
driver: EmailingDomainDriver!
status: EmailingDomainStatus!
verificationRecords: [VerificationRecord!]
verifiedAt: DateTime
}
enum EmailingDomainDriver {
AWS_SES
}
enum EmailingDomainStatus {
PENDING
VERIFIED
FAILED
TEMPORARY_FAILURE
}
type SendEmailViaDomainOutput {
messageId: String!
}
type ApprovedAccessDomain {
id: UUID!
domain: String!
@@ -1782,11 +1753,10 @@ enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED
IS_MARKETPLACE_SETTING_TAB_VISIBLE
IS_PUBLIC_DOMAIN_ENABLED
IS_EMAILING_DOMAIN_ENABLED
IS_EMAIL_GROUP_ENABLED
IS_JUNCTION_RELATIONS_ENABLED
IS_REST_METADATA_API_NEW_FORMAT_DIRECT
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED
IS_SETTINGS_DISCOVERY_HERO_ENABLED
}
type WorkspaceUrls {
@@ -2400,6 +2370,35 @@ type PublicDomain {
createdAt: DateTime!
}
type VerificationRecord {
type: String!
key: String!
value: String!
priority: Float
}
type EmailingDomain {
id: UUID!
createdAt: DateTime!
updatedAt: DateTime!
domain: String!
driver: EmailingDomainDriver!
status: EmailingDomainStatus!
verificationRecords: [VerificationRecord!]
verifiedAt: DateTime
}
enum EmailingDomainDriver {
AWS_SES
}
enum EmailingDomainStatus {
PENDING
VERIFIED
FAILED
TEMPORARY_FAILURE
}
type AutocompleteResult {
text: String!
placeId: String!
@@ -2477,18 +2476,6 @@ type ImapSmtpCaldavConnectionSuccess {
connectedAccountId: String!
}
type Webhook {
id: UUID!
targetUrl: String!
operations: [String!]!
description: String
secret: String!
applicationId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
deletedAt: DateTime
}
type ToolIndexEntry {
name: String!
description: String!
@@ -2707,12 +2694,6 @@ type AgentTurn {
createdAt: DateTime!
}
type WorkspaceAiStats {
conversationsCount: Int!
skillsCount: Int!
toolsCount: Int!
}
type CalendarChannel {
id: UUID!
handle: String!
@@ -2921,6 +2902,18 @@ type MinimalMetadata {
collectionHashes: [CollectionHash!]!
}
type Webhook {
id: UUID!
targetUrl: String!
operations: [String!]!
description: String
secret: String!
applicationId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
deletedAt: DateTime
}
type Query {
navigationMenuItems: [NavigationMenuItem!]!
navigationMenuItem(id: UUID!): NavigationMenuItem
@@ -2950,7 +2943,6 @@ type Query {
getPageLayoutTab(id: String!): PageLayoutTab!
getPageLayouts(objectMetadataId: String, pageLayoutType: PageLayoutType): [PageLayout!]!
getPageLayout(id: String!): PageLayout
getEmailingDomains: [EmailingDomain!]!
applicationConnectionProviders(applicationId: UUID!): [ApplicationConnectionProvider!]!
getPageLayoutWidgets(pageLayoutTabId: String!): [PageLayoutWidget!]!
getPageLayoutWidget(id: String!): PageLayoutWidget!
@@ -2990,8 +2982,6 @@ type Query {
getRoles: [Role!]!
getToolIndex: [ToolIndexEntry!]!
getToolInputSchema(toolName: String!): JSON
webhooks: [Webhook!]!
webhook(id: UUID!): Webhook
field(
"""The id of the record to find."""
id: UUID!
@@ -3009,8 +2999,9 @@ type Query {
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
webhooks: [Webhook!]!
webhook(id: UUID!): Webhook
minimalMetadata: MinimalMetadata!
findWorkspaceAiStats: WorkspaceAiStats!
chatThreads: [AgentChatThread!]!
chatThread(id: UUID!): AgentChatThread!
chatMessages(threadId: UUID!): [AgentMessage!]!
@@ -3046,6 +3037,7 @@ type Query {
getAddressDetails(placeId: String!, token: String!): PlaceDetailsResult!
getUsageAnalytics(input: UsageAnalyticsInput): UsageAnalytics!
findManyPublicDomains: [PublicDomain!]!
getEmailingDomains: [EmailingDomain!]!
findManyMarketplaceApps: [MarketplaceApp!]!
findMarketplaceAppDetail(universalIdentifier: String!): MarketplaceAppDetail!
}
@@ -3200,10 +3192,6 @@ type Mutation {
resetPageLayoutToDefault(id: String!): PageLayout!
resetPageLayoutWidgetToDefault(id: String!): PageLayoutWidget!
resetPageLayoutTabToDefault(id: String!): PageLayoutTab!
createEmailingDomain(domain: String!, driver: EmailingDomainDriver!): EmailingDomain!
deleteEmailingDomain(id: String!): Boolean!
verifyEmailingDomain(id: String!): EmailingDomain!
sendEmailViaEmailingDomain(input: SendEmailViaDomainInput!): SendEmailViaDomainOutput!
updateOneApplicationVariable(key: String!, value: String!, applicationId: UUID!): Boolean!
createPageLayoutWidget(input: CreatePageLayoutWidgetInput!): PageLayoutWidget!
updatePageLayoutWidget(id: String!, input: UpdatePageLayoutWidgetInput!): PageLayoutWidget!
@@ -3221,8 +3209,6 @@ type Mutation {
createOneObject(input: CreateOneObjectInput!): Object!
deleteOneObject(input: DeleteOneObjectInput!): Object!
updateOneObject(input: UpdateOneObjectInput!): Object!
createOneIndex(input: CreateOneIndexInput!): Index!
deleteOneIndex(input: DeleteOneIndexInput!): Index!
createOneAgent(input: CreateAgentInput!): Agent!
updateOneAgent(input: UpdateAgentInput!): Agent!
deleteOneAgent(input: AgentIdInput!): Agent!
@@ -3236,9 +3222,6 @@ type Mutation {
upsertRowLevelPermissionPredicates(input: UpsertRowLevelPermissionPredicatesInput!): UpsertRowLevelPermissionPredicatesResult!
assignRoleToAgent(agentId: UUID!, roleId: UUID!): Boolean!
removeRoleFromAgent(agentId: UUID!): Boolean!
createWebhook(input: CreateWebhookInput!): Webhook!
updateWebhook(input: UpdateWebhookInput!): Webhook!
deleteWebhook(id: UUID!): Webhook!
createOneField(input: CreateOneFieldMetadataInput!): Field!
updateOneField(input: UpdateOneFieldMetadataInput!): Field!
deleteOneField(input: DeleteOneFieldInput!): Field!
@@ -3255,6 +3238,9 @@ type Mutation {
deleteEmailGroupChannel(id: UUID!): MessageChannel!
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
createWebhook(input: CreateWebhookInput!): Webhook!
updateWebhook(input: UpdateWebhookInput!): Webhook!
deleteWebhook(id: UUID!): Webhook!
createChatThread: AgentChatThread!
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
stopAgentChatStream(threadId: UUID!): Boolean!
@@ -3284,7 +3270,6 @@ type Mutation {
authorizeApp(clientId: String!, codeChallenge: String, redirectUrl: String!, state: String, scope: String): AuthorizeApp!
renewToken(appToken: String!): AuthTokens!
generateApiKeyToken(apiKeyId: UUID!, expiresAt: String!): ApiKeyToken!
generatePlaygroundToken: AuthToken!
emailPasswordResetLink(email: String!, workspaceId: UUID): EmailPasswordResetLink!
updatePasswordViaResetToken(passwordResetToken: String!, newPassword: String!): InvalidatePassword!
createApplicationRegistration(input: CreateApplicationRegistrationInput!): CreateApplicationRegistration!
@@ -3325,6 +3310,9 @@ type Mutation {
updatePublicDomain(domain: String!, applicationId: String): PublicDomain!
deletePublicDomain(domain: String!): Boolean!
checkPublicDomainValidRecords(domain: String!): DomainValidRecords
createEmailingDomain(domain: String!, driver: EmailingDomainDriver!): EmailingDomain!
deleteEmailingDomain(id: String!): Boolean!
verifyEmailingDomain(id: String!): EmailingDomain!
createOneAppToken(input: CreateOneAppTokenInput!): AppToken!
installMarketplaceApp(universalIdentifier: String!, version: String): Boolean! @deprecated(reason: "Use installApplication instead")
installApplication(universalIdentifier: String!, version: String): Application!
@@ -3768,18 +3756,6 @@ input GridPositionInput {
columnSpan: Float!
}
input SendEmailViaDomainInput {
emailingDomainId: String!
to: [String!]!
cc: [String!]
bcc: [String!]
subject: String!
text: String!
html: String
from: String!
replyTo: [String!]
}
input CreatePageLayoutWidgetInput {
pageLayoutTabId: UUID!
title: String!
@@ -3949,27 +3925,6 @@ input UpdateObjectPayload {
isSearchable: Boolean
}
input CreateOneIndexInput {
"""The custom index to create"""
index: CreateIndexInput!
}
input CreateIndexInput {
objectMetadataId: UUID!
fields: [CreateIndexFieldInput!]!
indexType: IndexType! = BTREE
}
input CreateIndexFieldInput {
fieldMetadataId: UUID!
subFieldName: String
}
input DeleteOneIndexInput {
"""The id of the custom index to delete."""
id: UUID!
}
input CreateAgentInput {
name: String
label: String!
@@ -4050,7 +4005,7 @@ input ObjectPermissionInput {
input UpsertPermissionFlagsInput {
roleId: UUID!
permissionFlagKeys: [String!]!
permissionFlagKeys: [PermissionFlagType!]!
}
input UpsertFieldPermissionsInput {
@@ -4092,29 +4047,6 @@ input RowLevelPermissionPredicateGroupInput {
positionInRowLevelPermissionPredicateGroup: Float
}
input CreateWebhookInput {
id: UUID
targetUrl: String!
operations: [String!]!
description: String
secret: String
}
input UpdateWebhookInput {
"""The id of the webhook to update"""
id: UUID!
"""The webhook fields to update"""
update: UpdateWebhookInputUpdates!
}
input UpdateWebhookInputUpdates {
targetUrl: String
operations: [String!]
description: String
secret: String
}
input CreateOneFieldMetadataInput {
"""The record to create"""
field: CreateFieldInput!
@@ -4252,6 +4184,29 @@ input UpdateCalendarChannelInputUpdates {
isSyncEnabled: Boolean
}
input CreateWebhookInput {
id: UUID
targetUrl: String!
operations: [String!]!
description: String
secret: String
}
input UpdateWebhookInput {
"""The id of the webhook to update"""
id: UUID!
"""The webhook fields to update"""
update: UpdateWebhookInputUpdates!
}
input UpdateWebhookInputUpdates {
targetUrl: String
operations: [String!]
description: String
secret: String
}
input FileAttachmentInput {
id: UUID!
filename: String!
@@ -193,7 +193,7 @@ export interface FieldPermission {
export interface RolePermissionFlag {
id: Scalars['UUID']
roleId: Scalars['UUID']
flag: Scalars['String']
flag: PermissionFlagType
__typename: 'RolePermissionFlag'
}
@@ -326,7 +326,6 @@ export interface LogicFunction {
description?: Scalars['String']
runtime: Scalars['String']
timeoutSeconds: Scalars['Float']
executionMode: LogicFunctionExecutionMode
sourceHandlerPath: Scalars['String']
handlerName: Scalars['String']
cronTriggerSettings?: Scalars['JSON']
@@ -341,8 +340,6 @@ export interface LogicFunction {
__typename: 'LogicFunction'
}
export type LogicFunctionExecutionMode = 'LIVE' | 'PREBUILT'
export interface StandardOverrides {
label?: Scalars['String']
description?: Scalars['String']
@@ -389,7 +386,6 @@ export interface IndexField {
id: Scalars['UUID']
fieldMetadataId: Scalars['UUID']
order: Scalars['Float']
subFieldName?: Scalars['String']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'IndexField'
@@ -793,7 +789,7 @@ export interface PageLayoutWidgetCanvasPosition {
__typename: 'PageLayoutWidgetCanvasPosition'
}
export type WidgetConfiguration = (AggregateChartConfiguration | StandaloneRichTextConfiguration | PieChartConfiguration | LineChartConfiguration | IframeConfiguration | BarChartConfiguration | CalendarConfiguration | FrontComponentConfiguration | EmailsConfiguration | EmailThreadConfiguration | FieldConfiguration | FieldRichTextConfiguration | FieldsConfiguration | FilesConfiguration | NotesConfiguration | TasksConfiguration | TimelineConfiguration | ViewConfiguration | RecordTableConfiguration | WorkflowConfiguration | WorkflowRunConfiguration | WorkflowVersionConfiguration) & { __isUnion?: true }
export type WidgetConfiguration = (AggregateChartConfiguration | StandaloneRichTextConfiguration | PieChartConfiguration | LineChartConfiguration | IframeConfiguration | GaugeChartConfiguration | BarChartConfiguration | CalendarConfiguration | FrontComponentConfiguration | EmailsConfiguration | EmailThreadConfiguration | FieldConfiguration | FieldRichTextConfiguration | FieldsConfiguration | FilesConfiguration | NotesConfiguration | TasksConfiguration | TimelineConfiguration | ViewConfiguration | RecordTableConfiguration | WorkflowConfiguration | WorkflowRunConfiguration | WorkflowVersionConfiguration) & { __isUnion?: true }
export interface AggregateChartConfiguration {
configurationType: WidgetConfigurationType
@@ -812,7 +808,7 @@ export interface AggregateChartConfiguration {
__typename: 'AggregateChartConfiguration'
}
export type WidgetConfigurationType = 'AGGREGATE_CHART' | 'PIE_CHART' | 'BAR_CHART' | 'LINE_CHART' | 'IFRAME' | 'STANDALONE_RICH_TEXT' | 'VIEW' | 'FIELD' | 'FIELDS' | 'TIMELINE' | 'TASKS' | 'NOTES' | 'FILES' | 'EMAILS' | 'CALENDAR' | 'FIELD_RICH_TEXT' | 'WORKFLOW' | 'WORKFLOW_VERSION' | 'WORKFLOW_RUN' | 'FRONT_COMPONENT' | 'RECORD_TABLE' | 'EMAIL_THREAD'
export type WidgetConfigurationType = 'AGGREGATE_CHART' | 'GAUGE_CHART' | 'PIE_CHART' | 'BAR_CHART' | 'LINE_CHART' | 'IFRAME' | 'STANDALONE_RICH_TEXT' | 'VIEW' | 'FIELD' | 'FIELDS' | 'TIMELINE' | 'TASKS' | 'NOTES' | 'FILES' | 'EMAILS' | 'CALENDAR' | 'FIELD_RICH_TEXT' | 'WORKFLOW' | 'WORKFLOW_VERSION' | 'WORKFLOW_RUN' | 'FRONT_COMPONENT' | 'RECORD_TABLE' | 'EMAIL_THREAD'
export interface StandaloneRichTextConfiguration {
configurationType: WidgetConfigurationType
@@ -891,6 +887,19 @@ export interface IframeConfiguration {
__typename: 'IframeConfiguration'
}
export interface GaugeChartConfiguration {
configurationType: WidgetConfigurationType
aggregateFieldMetadataId: Scalars['UUID']
aggregateOperation: AggregateOperations
displayDataLabel?: Scalars['Boolean']
color?: Scalars['String']
description?: Scalars['String']
filter?: Scalars['JSON']
timezone?: Scalars['String']
firstDayOfTheWeek?: Scalars['Int']
__typename: 'GaugeChartConfiguration'
}
export interface BarChartConfiguration {
configurationType: WidgetConfigurationType
aggregateFieldMetadataId: Scalars['UUID']
@@ -956,13 +965,12 @@ export interface FieldConfiguration {
configurationType: WidgetConfigurationType
fieldMetadataId: Scalars['String']
fieldDisplayMode: FieldDisplayMode
viewId?: Scalars['String']
__typename: 'FieldConfiguration'
}
/** Display mode for field configuration widgets */
export type FieldDisplayMode = 'CARD' | 'EDITOR' | 'FIELD' | 'VIEW' | 'TABLE'
export type FieldDisplayMode = 'CARD' | 'EDITOR' | 'FIELD' | 'VIEW'
export interface FieldRichTextConfiguration {
configurationType: WidgetConfigurationType
@@ -1097,35 +1105,6 @@ export interface Analytics {
__typename: 'Analytics'
}
export interface VerificationRecord {
type: Scalars['String']
key: Scalars['String']
value: Scalars['String']
priority?: Scalars['Float']
__typename: 'VerificationRecord'
}
export interface EmailingDomain {
id: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
domain: Scalars['String']
driver: EmailingDomainDriver
status: EmailingDomainStatus
verificationRecords?: VerificationRecord[]
verifiedAt?: Scalars['DateTime']
__typename: 'EmailingDomain'
}
export type EmailingDomainDriver = 'AWS_SES'
export type EmailingDomainStatus = 'PENDING' | 'VERIFIED' | 'FAILED' | 'TEMPORARY_FAILURE'
export interface SendEmailViaDomainOutput {
messageId: Scalars['String']
__typename: 'SendEmailViaDomainOutput'
}
export interface ApprovedAccessDomain {
id: Scalars['UUID']
domain: Scalars['String']
@@ -1414,7 +1393,7 @@ export interface FeatureFlag {
__typename: 'FeatureFlag'
}
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED'
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT'
export interface WorkspaceUrls {
customUrl?: Scalars['String']
@@ -2070,6 +2049,30 @@ export interface PublicDomain {
__typename: 'PublicDomain'
}
export interface VerificationRecord {
type: Scalars['String']
key: Scalars['String']
value: Scalars['String']
priority?: Scalars['Float']
__typename: 'VerificationRecord'
}
export interface EmailingDomain {
id: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
domain: Scalars['String']
driver: EmailingDomainDriver
status: EmailingDomainStatus
verificationRecords?: VerificationRecord[]
verifiedAt?: Scalars['DateTime']
__typename: 'EmailingDomain'
}
export type EmailingDomainDriver = 'AWS_SES'
export type EmailingDomainStatus = 'PENDING' | 'VERIFIED' | 'FAILED' | 'TEMPORARY_FAILURE'
export interface AutocompleteResult {
text: Scalars['String']
placeId: Scalars['String']
@@ -2157,19 +2160,6 @@ export interface ImapSmtpCaldavConnectionSuccess {
__typename: 'ImapSmtpCaldavConnectionSuccess'
}
export interface Webhook {
id: Scalars['UUID']
targetUrl: Scalars['String']
operations: Scalars['String'][]
description?: Scalars['String']
secret: Scalars['String']
applicationId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
deletedAt?: Scalars['DateTime']
__typename: 'Webhook'
}
export interface ToolIndexEntry {
name: Scalars['String']
description: Scalars['String']
@@ -2413,13 +2403,6 @@ export interface AgentTurn {
__typename: 'AgentTurn'
}
export interface WorkspaceAiStats {
conversationsCount: Scalars['Int']
skillsCount: Scalars['Int']
toolsCount: Scalars['Int']
__typename: 'WorkspaceAiStats'
}
export interface CalendarChannel {
id: Scalars['UUID']
handle: Scalars['String']
@@ -2545,6 +2528,19 @@ export interface MinimalMetadata {
__typename: 'MinimalMetadata'
}
export interface Webhook {
id: Scalars['UUID']
targetUrl: Scalars['String']
operations: Scalars['String'][]
description?: Scalars['String']
secret: Scalars['String']
applicationId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
deletedAt?: Scalars['DateTime']
__typename: 'Webhook'
}
export interface Query {
navigationMenuItems: NavigationMenuItem[]
navigationMenuItem?: NavigationMenuItem
@@ -2574,7 +2570,6 @@ export interface Query {
getPageLayoutTab: PageLayoutTab
getPageLayouts: PageLayout[]
getPageLayout?: PageLayout
getEmailingDomains: EmailingDomain[]
applicationConnectionProviders: ApplicationConnectionProvider[]
getPageLayoutWidgets: PageLayoutWidget[]
getPageLayoutWidget: PageLayoutWidget
@@ -2596,8 +2591,6 @@ export interface Query {
getRoles: Role[]
getToolIndex: ToolIndexEntry[]
getToolInputSchema?: Scalars['JSON']
webhooks: Webhook[]
webhook?: Webhook
field: Field
fields: FieldConnection
getViewGroups: ViewGroup[]
@@ -2606,8 +2599,9 @@ export interface Query {
myMessageChannels: MessageChannel[]
myConnectedAccounts: ConnectedAccountPublicDTO[]
myCalendarChannels: CalendarChannel[]
webhooks: Webhook[]
webhook?: Webhook
minimalMetadata: MinimalMetadata
findWorkspaceAiStats: WorkspaceAiStats
chatThreads: AgentChatThread[]
chatThread: AgentChatThread
chatMessages: AgentMessage[]
@@ -2643,6 +2637,7 @@ export interface Query {
getAddressDetails: PlaceDetailsResult
getUsageAnalytics: UsageAnalytics
findManyPublicDomains: PublicDomain[]
getEmailingDomains: EmailingDomain[]
findManyMarketplaceApps: MarketplaceApp[]
findMarketplaceAppDetail: MarketplaceAppDetail
__typename: 'Query'
@@ -2730,10 +2725,6 @@ export interface Mutation {
resetPageLayoutToDefault: PageLayout
resetPageLayoutWidgetToDefault: PageLayoutWidget
resetPageLayoutTabToDefault: PageLayoutTab
createEmailingDomain: EmailingDomain
deleteEmailingDomain: Scalars['Boolean']
verifyEmailingDomain: EmailingDomain
sendEmailViaEmailingDomain: SendEmailViaDomainOutput
updateOneApplicationVariable: Scalars['Boolean']
createPageLayoutWidget: PageLayoutWidget
updatePageLayoutWidget: PageLayoutWidget
@@ -2751,8 +2742,6 @@ export interface Mutation {
createOneObject: Object
deleteOneObject: Object
updateOneObject: Object
createOneIndex: Index
deleteOneIndex: Index
createOneAgent: Agent
updateOneAgent: Agent
deleteOneAgent: Agent
@@ -2766,9 +2755,6 @@ export interface Mutation {
upsertRowLevelPermissionPredicates: UpsertRowLevelPermissionPredicatesResult
assignRoleToAgent: Scalars['Boolean']
removeRoleFromAgent: Scalars['Boolean']
createWebhook: Webhook
updateWebhook: Webhook
deleteWebhook: Webhook
createOneField: Field
updateOneField: Field
deleteOneField: Field
@@ -2785,6 +2771,9 @@ export interface Mutation {
deleteEmailGroupChannel: MessageChannel
deleteConnectedAccount: ConnectedAccountPublicDTO
updateCalendarChannel: CalendarChannel
createWebhook: Webhook
updateWebhook: Webhook
deleteWebhook: Webhook
createChatThread: AgentChatThread
sendChatMessage: SendChatMessageResult
stopAgentChatStream: Scalars['Boolean']
@@ -2814,7 +2803,6 @@ export interface Mutation {
authorizeApp: AuthorizeApp
renewToken: AuthTokens
generateApiKeyToken: ApiKeyToken
generatePlaygroundToken: AuthToken
emailPasswordResetLink: EmailPasswordResetLink
updatePasswordViaResetToken: InvalidatePassword
createApplicationRegistration: CreateApplicationRegistration
@@ -2855,6 +2843,9 @@ export interface Mutation {
updatePublicDomain: PublicDomain
deletePublicDomain: Scalars['Boolean']
checkPublicDomainValidRecords?: DomainValidRecords
createEmailingDomain: EmailingDomain
deleteEmailingDomain: Scalars['Boolean']
verifyEmailingDomain: EmailingDomain
createOneAppToken: AppToken
/** @deprecated Use installApplication instead */
installMarketplaceApp: Scalars['Boolean']
@@ -3207,7 +3198,6 @@ export interface LogicFunctionGenqlSelection{
description?: boolean | number
runtime?: boolean | number
timeoutSeconds?: boolean | number
executionMode?: boolean | number
sourceHandlerPath?: boolean | number
handlerName?: boolean | number
cronTriggerSettings?: boolean | number
@@ -3267,7 +3257,6 @@ export interface IndexFieldGenqlSelection{
id?: boolean | number
fieldMetadataId?: boolean | number
order?: boolean | number
subFieldName?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
@@ -3710,6 +3699,7 @@ export interface WidgetConfigurationGenqlSelection{
on_PieChartConfiguration?:PieChartConfigurationGenqlSelection,
on_LineChartConfiguration?:LineChartConfigurationGenqlSelection,
on_IframeConfiguration?:IframeConfigurationGenqlSelection,
on_GaugeChartConfiguration?:GaugeChartConfigurationGenqlSelection,
on_BarChartConfiguration?:BarChartConfigurationGenqlSelection,
on_CalendarConfiguration?:CalendarConfigurationGenqlSelection,
on_FrontComponentConfiguration?:FrontComponentConfigurationGenqlSelection,
@@ -3817,6 +3807,20 @@ export interface IframeConfigurationGenqlSelection{
__scalar?: boolean | number
}
export interface GaugeChartConfigurationGenqlSelection{
configurationType?: boolean | number
aggregateFieldMetadataId?: boolean | number
aggregateOperation?: boolean | number
displayDataLabel?: boolean | number
color?: boolean | number
description?: boolean | number
filter?: boolean | number
timezone?: boolean | number
firstDayOfTheWeek?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface BarChartConfigurationGenqlSelection{
configurationType?: boolean | number
aggregateFieldMetadataId?: boolean | number
@@ -3879,7 +3883,6 @@ export interface FieldConfigurationGenqlSelection{
configurationType?: boolean | number
fieldMetadataId?: boolean | number
fieldDisplayMode?: boolean | number
viewId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -4033,34 +4036,6 @@ export interface AnalyticsGenqlSelection{
__scalar?: boolean | number
}
export interface VerificationRecordGenqlSelection{
type?: boolean | number
key?: boolean | number
value?: boolean | number
priority?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EmailingDomainGenqlSelection{
id?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
domain?: boolean | number
driver?: boolean | number
status?: boolean | number
verificationRecords?: VerificationRecordGenqlSelection
verifiedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface SendEmailViaDomainOutputGenqlSelection{
messageId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ApprovedAccessDomainGenqlSelection{
id?: boolean | number
domain?: boolean | number
@@ -5072,6 +5047,28 @@ export interface PublicDomainGenqlSelection{
__scalar?: boolean | number
}
export interface VerificationRecordGenqlSelection{
type?: boolean | number
key?: boolean | number
value?: boolean | number
priority?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EmailingDomainGenqlSelection{
id?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
domain?: boolean | number
driver?: boolean | number
status?: boolean | number
verificationRecords?: VerificationRecordGenqlSelection
verifiedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface AutocompleteResultGenqlSelection{
text?: boolean | number
placeId?: boolean | number
@@ -5169,20 +5166,6 @@ export interface ImapSmtpCaldavConnectionSuccessGenqlSelection{
__scalar?: boolean | number
}
export interface WebhookGenqlSelection{
id?: boolean | number
targetUrl?: boolean | number
operations?: boolean | number
description?: boolean | number
secret?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
deletedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ToolIndexEntryGenqlSelection{
name?: boolean | number
description?: boolean | number
@@ -5451,14 +5434,6 @@ export interface AgentTurnGenqlSelection{
__scalar?: boolean | number
}
export interface WorkspaceAiStatsGenqlSelection{
conversationsCount?: boolean | number
skillsCount?: boolean | number
toolsCount?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface CalendarChannelGenqlSelection{
id?: boolean | number
handle?: boolean | number
@@ -5566,6 +5541,20 @@ export interface MinimalMetadataGenqlSelection{
__scalar?: boolean | number
}
export interface WebhookGenqlSelection{
id?: boolean | number
targetUrl?: boolean | number
operations?: boolean | number
description?: boolean | number
secret?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
deletedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface QueryGenqlSelection{
navigationMenuItems?: NavigationMenuItemGenqlSelection
navigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
@@ -5595,7 +5584,6 @@ export interface QueryGenqlSelection{
getPageLayoutTab?: (PageLayoutTabGenqlSelection & { __args: {id: Scalars['String']} })
getPageLayouts?: (PageLayoutGenqlSelection & { __args?: {objectMetadataId?: (Scalars['String'] | null), pageLayoutType?: (PageLayoutType | null)} })
getPageLayout?: (PageLayoutGenqlSelection & { __args: {id: Scalars['String']} })
getEmailingDomains?: EmailingDomainGenqlSelection
applicationConnectionProviders?: (ApplicationConnectionProviderGenqlSelection & { __args: {applicationId: Scalars['UUID']} })
getPageLayoutWidgets?: (PageLayoutWidgetGenqlSelection & { __args: {pageLayoutTabId: Scalars['String']} })
getPageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String']} })
@@ -5629,8 +5617,6 @@ export interface QueryGenqlSelection{
getRoles?: RoleGenqlSelection
getToolIndex?: ToolIndexEntryGenqlSelection
getToolInputSchema?: { __args: {toolName: Scalars['String']} }
webhooks?: WebhookGenqlSelection
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
field?: (FieldGenqlSelection & { __args: {
/** The id of the record to find. */
id: Scalars['UUID']} })
@@ -5645,8 +5631,9 @@ export interface QueryGenqlSelection{
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
webhooks?: WebhookGenqlSelection
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
minimalMetadata?: MinimalMetadataGenqlSelection
findWorkspaceAiStats?: WorkspaceAiStatsGenqlSelection
chatThreads?: AgentChatThreadGenqlSelection
chatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} })
chatMessages?: (AgentMessageGenqlSelection & { __args: {threadId: Scalars['UUID']} })
@@ -5682,6 +5669,7 @@ export interface QueryGenqlSelection{
getAddressDetails?: (PlaceDetailsResultGenqlSelection & { __args: {placeId: Scalars['String'], token: Scalars['String']} })
getUsageAnalytics?: (UsageAnalyticsGenqlSelection & { __args?: {input?: (UsageAnalyticsInput | null)} })
findManyPublicDomains?: PublicDomainGenqlSelection
getEmailingDomains?: EmailingDomainGenqlSelection
findManyMarketplaceApps?: MarketplaceAppGenqlSelection
findMarketplaceAppDetail?: (MarketplaceAppDetailGenqlSelection & { __args: {universalIdentifier: Scalars['String']} })
__typename?: boolean | number
@@ -5790,10 +5778,6 @@ export interface MutationGenqlSelection{
resetPageLayoutToDefault?: (PageLayoutGenqlSelection & { __args: {id: Scalars['String']} })
resetPageLayoutWidgetToDefault?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String']} })
resetPageLayoutTabToDefault?: (PageLayoutTabGenqlSelection & { __args: {id: Scalars['String']} })
createEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {domain: Scalars['String'], driver: EmailingDomainDriver} })
deleteEmailingDomain?: { __args: {id: Scalars['String']} }
verifyEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {id: Scalars['String']} })
sendEmailViaEmailingDomain?: (SendEmailViaDomainOutputGenqlSelection & { __args: {input: SendEmailViaDomainInput} })
updateOneApplicationVariable?: { __args: {key: Scalars['String'], value: Scalars['String'], applicationId: Scalars['UUID']} }
createPageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {input: CreatePageLayoutWidgetInput} })
updatePageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String'], input: UpdatePageLayoutWidgetInput} })
@@ -5811,8 +5795,6 @@ export interface MutationGenqlSelection{
createOneObject?: (ObjectGenqlSelection & { __args: {input: CreateOneObjectInput} })
deleteOneObject?: (ObjectGenqlSelection & { __args: {input: DeleteOneObjectInput} })
updateOneObject?: (ObjectGenqlSelection & { __args: {input: UpdateOneObjectInput} })
createOneIndex?: (IndexGenqlSelection & { __args: {input: CreateOneIndexInput} })
deleteOneIndex?: (IndexGenqlSelection & { __args: {input: DeleteOneIndexInput} })
createOneAgent?: (AgentGenqlSelection & { __args: {input: CreateAgentInput} })
updateOneAgent?: (AgentGenqlSelection & { __args: {input: UpdateAgentInput} })
deleteOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
@@ -5826,9 +5808,6 @@ export interface MutationGenqlSelection{
upsertRowLevelPermissionPredicates?: (UpsertRowLevelPermissionPredicatesResultGenqlSelection & { __args: {input: UpsertRowLevelPermissionPredicatesInput} })
assignRoleToAgent?: { __args: {agentId: Scalars['UUID'], roleId: Scalars['UUID']} }
removeRoleFromAgent?: { __args: {agentId: Scalars['UUID']} }
createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} })
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
deleteWebhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
createOneField?: (FieldGenqlSelection & { __args: {input: CreateOneFieldMetadataInput} })
updateOneField?: (FieldGenqlSelection & { __args: {input: UpdateOneFieldMetadataInput} })
deleteOneField?: (FieldGenqlSelection & { __args: {input: DeleteOneFieldInput} })
@@ -5845,6 +5824,9 @@ export interface MutationGenqlSelection{
deleteEmailGroupChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} })
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
deleteWebhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
createChatThread?: AgentChatThreadGenqlSelection
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} })
stopAgentChatStream?: { __args: {threadId: Scalars['UUID']} }
@@ -5874,7 +5856,6 @@ export interface MutationGenqlSelection{
authorizeApp?: (AuthorizeAppGenqlSelection & { __args: {clientId: Scalars['String'], codeChallenge?: (Scalars['String'] | null), redirectUrl: Scalars['String'], state?: (Scalars['String'] | null), scope?: (Scalars['String'] | null)} })
renewToken?: (AuthTokensGenqlSelection & { __args: {appToken: Scalars['String']} })
generateApiKeyToken?: (ApiKeyTokenGenqlSelection & { __args: {apiKeyId: Scalars['UUID'], expiresAt: Scalars['String']} })
generatePlaygroundToken?: AuthTokenGenqlSelection
emailPasswordResetLink?: (EmailPasswordResetLinkGenqlSelection & { __args: {email: Scalars['String'], workspaceId?: (Scalars['UUID'] | null)} })
updatePasswordViaResetToken?: (InvalidatePasswordGenqlSelection & { __args: {passwordResetToken: Scalars['String'], newPassword: Scalars['String']} })
createApplicationRegistration?: (CreateApplicationRegistrationGenqlSelection & { __args: {input: CreateApplicationRegistrationInput} })
@@ -5915,6 +5896,9 @@ export interface MutationGenqlSelection{
updatePublicDomain?: (PublicDomainGenqlSelection & { __args: {domain: Scalars['String'], applicationId?: (Scalars['String'] | null)} })
deletePublicDomain?: { __args: {domain: Scalars['String']} }
checkPublicDomainValidRecords?: (DomainValidRecordsGenqlSelection & { __args: {domain: Scalars['String']} })
createEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {domain: Scalars['String'], driver: EmailingDomainDriver} })
deleteEmailingDomain?: { __args: {id: Scalars['String']} }
verifyEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {id: Scalars['String']} })
createOneAppToken?: (AppTokenGenqlSelection & { __args: {input: CreateOneAppTokenInput} })
/** @deprecated Use installApplication instead */
installMarketplaceApp?: { __args: {universalIdentifier: Scalars['String'], version?: (Scalars['String'] | null)} }
@@ -6092,8 +6076,6 @@ export interface UpdatePageLayoutWidgetWithIdInput {id: Scalars['UUID'],pageLayo
export interface GridPositionInput {row: Scalars['Float'],column: Scalars['Float'],rowSpan: Scalars['Float'],columnSpan: Scalars['Float']}
export interface SendEmailViaDomainInput {emailingDomainId: Scalars['String'],to: Scalars['String'][],cc?: (Scalars['String'][] | null),bcc?: (Scalars['String'][] | null),subject: Scalars['String'],text: Scalars['String'],html?: (Scalars['String'] | null),from: Scalars['String'],replyTo?: (Scalars['String'][] | null)}
export interface CreatePageLayoutWidgetInput {pageLayoutTabId: Scalars['UUID'],title: Scalars['String'],type: WidgetType,objectMetadataId?: (Scalars['UUID'] | null),gridPosition: GridPositionInput,position?: (Scalars['JSON'] | null),configuration: Scalars['JSON']}
export interface UpdatePageLayoutWidgetInput {pageLayoutTabId?: (Scalars['UUID'] | null),title?: (Scalars['String'] | null),type?: (WidgetType | null),objectMetadataId?: (Scalars['UUID'] | null),gridPosition?: (GridPositionInput | null),position?: (Scalars['JSON'] | null),configuration?: (Scalars['JSON'] | null),conditionalDisplay?: (Scalars['JSON'] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null)}
@@ -6144,18 +6126,6 @@ id: Scalars['UUID']}
export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null),isSearchable?: (Scalars['Boolean'] | null)}
export interface CreateOneIndexInput {
/** The custom index to create */
index: CreateIndexInput}
export interface CreateIndexInput {objectMetadataId: Scalars['UUID'],fields: CreateIndexFieldInput[],indexType: IndexType}
export interface CreateIndexFieldInput {fieldMetadataId: Scalars['UUID'],subFieldName?: (Scalars['String'] | null)}
export interface DeleteOneIndexInput {
/** The id of the custom index to delete. */
id: Scalars['UUID']}
export interface CreateAgentInput {name?: (Scalars['String'] | null),label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),prompt: Scalars['String'],modelId: Scalars['String'],roleId?: (Scalars['UUID'] | null),responseFormat?: (Scalars['JSON'] | null),modelConfiguration?: (Scalars['JSON'] | null),evaluationInputs?: (Scalars['String'][] | null)}
export interface UpdateAgentInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),prompt?: (Scalars['String'] | null),modelId?: (Scalars['String'] | null),roleId?: (Scalars['UUID'] | null),responseFormat?: (Scalars['JSON'] | null),modelConfiguration?: (Scalars['JSON'] | null),evaluationInputs?: (Scalars['String'][] | null)}
@@ -6172,7 +6142,7 @@ export interface UpsertObjectPermissionsInput {roleId: Scalars['UUID'],objectPer
export interface ObjectPermissionInput {objectMetadataId: Scalars['UUID'],canReadObjectRecords?: (Scalars['Boolean'] | null),canUpdateObjectRecords?: (Scalars['Boolean'] | null),canSoftDeleteObjectRecords?: (Scalars['Boolean'] | null),canDestroyObjectRecords?: (Scalars['Boolean'] | null)}
export interface UpsertPermissionFlagsInput {roleId: Scalars['UUID'],permissionFlagKeys: Scalars['String'][]}
export interface UpsertPermissionFlagsInput {roleId: Scalars['UUID'],permissionFlagKeys: PermissionFlagType[]}
export interface UpsertFieldPermissionsInput {roleId: Scalars['UUID'],fieldPermissions: FieldPermissionInput[]}
@@ -6184,16 +6154,6 @@ export interface RowLevelPermissionPredicateInput {id?: (Scalars['UUID'] | null)
export interface RowLevelPermissionPredicateGroupInput {id?: (Scalars['UUID'] | null),objectMetadataId: Scalars['UUID'],parentRowLevelPermissionPredicateGroupId?: (Scalars['UUID'] | null),logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator,positionInRowLevelPermissionPredicateGroup?: (Scalars['Float'] | null)}
export interface CreateWebhookInput {id?: (Scalars['UUID'] | null),targetUrl: Scalars['String'],operations: Scalars['String'][],description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
export interface UpdateWebhookInput {
/** The id of the webhook to update */
id: Scalars['UUID'],
/** The webhook fields to update */
update: UpdateWebhookInputUpdates}
export interface UpdateWebhookInputUpdates {targetUrl?: (Scalars['String'] | null),operations?: (Scalars['String'][] | null),description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
export interface CreateOneFieldMetadataInput {
/** The record to create */
field: CreateFieldInput}
@@ -6246,6 +6206,16 @@ export interface UpdateCalendarChannelInput {id: Scalars['UUID'],update: UpdateC
export interface UpdateCalendarChannelInputUpdates {visibility?: (CalendarChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (CalendarChannelContactAutoCreationPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null)}
export interface CreateWebhookInput {id?: (Scalars['UUID'] | null),targetUrl: Scalars['String'],operations: Scalars['String'][],description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
export interface UpdateWebhookInput {
/** The id of the webhook to update */
id: Scalars['UUID'],
/** The webhook fields to update */
update: UpdateWebhookInputUpdates}
export interface UpdateWebhookInputUpdates {targetUrl?: (Scalars['String'] | null),operations?: (Scalars['String'][] | null),description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
export interface FileAttachmentInput {id: Scalars['UUID'],filename: Scalars['String']}
export interface CreateSkillInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content: Scalars['String']}
@@ -6719,7 +6689,7 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const WidgetConfiguration_possibleTypes: string[] = ['AggregateChartConfiguration','StandaloneRichTextConfiguration','PieChartConfiguration','LineChartConfiguration','IframeConfiguration','BarChartConfiguration','CalendarConfiguration','FrontComponentConfiguration','EmailsConfiguration','EmailThreadConfiguration','FieldConfiguration','FieldRichTextConfiguration','FieldsConfiguration','FilesConfiguration','NotesConfiguration','TasksConfiguration','TimelineConfiguration','ViewConfiguration','RecordTableConfiguration','WorkflowConfiguration','WorkflowRunConfiguration','WorkflowVersionConfiguration']
const WidgetConfiguration_possibleTypes: string[] = ['AggregateChartConfiguration','StandaloneRichTextConfiguration','PieChartConfiguration','LineChartConfiguration','IframeConfiguration','GaugeChartConfiguration','BarChartConfiguration','CalendarConfiguration','FrontComponentConfiguration','EmailsConfiguration','EmailThreadConfiguration','FieldConfiguration','FieldRichTextConfiguration','FieldsConfiguration','FilesConfiguration','NotesConfiguration','TasksConfiguration','TimelineConfiguration','ViewConfiguration','RecordTableConfiguration','WorkflowConfiguration','WorkflowRunConfiguration','WorkflowVersionConfiguration']
export const isWidgetConfiguration = (obj?: { __typename?: any } | null): obj is WidgetConfiguration => {
if (!obj?.__typename) throw new Error('__typename is missing in "isWidgetConfiguration"')
return WidgetConfiguration_possibleTypes.includes(obj.__typename)
@@ -6767,6 +6737,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const GaugeChartConfiguration_possibleTypes: string[] = ['GaugeChartConfiguration']
export const isGaugeChartConfiguration = (obj?: { __typename?: any } | null): obj is GaugeChartConfiguration => {
if (!obj?.__typename) throw new Error('__typename is missing in "isGaugeChartConfiguration"')
return GaugeChartConfiguration_possibleTypes.includes(obj.__typename)
}
const BarChartConfiguration_possibleTypes: string[] = ['BarChartConfiguration']
export const isBarChartConfiguration = (obj?: { __typename?: any } | null): obj is BarChartConfiguration => {
if (!obj?.__typename) throw new Error('__typename is missing in "isBarChartConfiguration"')
@@ -6959,30 +6937,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const VerificationRecord_possibleTypes: string[] = ['VerificationRecord']
export const isVerificationRecord = (obj?: { __typename?: any } | null): obj is VerificationRecord => {
if (!obj?.__typename) throw new Error('__typename is missing in "isVerificationRecord"')
return VerificationRecord_possibleTypes.includes(obj.__typename)
}
const EmailingDomain_possibleTypes: string[] = ['EmailingDomain']
export const isEmailingDomain = (obj?: { __typename?: any } | null): obj is EmailingDomain => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEmailingDomain"')
return EmailingDomain_possibleTypes.includes(obj.__typename)
}
const SendEmailViaDomainOutput_possibleTypes: string[] = ['SendEmailViaDomainOutput']
export const isSendEmailViaDomainOutput = (obj?: { __typename?: any } | null): obj is SendEmailViaDomainOutput => {
if (!obj?.__typename) throw new Error('__typename is missing in "isSendEmailViaDomainOutput"')
return SendEmailViaDomainOutput_possibleTypes.includes(obj.__typename)
}
const ApprovedAccessDomain_possibleTypes: string[] = ['ApprovedAccessDomain']
export const isApprovedAccessDomain = (obj?: { __typename?: any } | null): obj is ApprovedAccessDomain => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApprovedAccessDomain"')
@@ -7887,6 +7841,22 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const VerificationRecord_possibleTypes: string[] = ['VerificationRecord']
export const isVerificationRecord = (obj?: { __typename?: any } | null): obj is VerificationRecord => {
if (!obj?.__typename) throw new Error('__typename is missing in "isVerificationRecord"')
return VerificationRecord_possibleTypes.includes(obj.__typename)
}
const EmailingDomain_possibleTypes: string[] = ['EmailingDomain']
export const isEmailingDomain = (obj?: { __typename?: any } | null): obj is EmailingDomain => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEmailingDomain"')
return EmailingDomain_possibleTypes.includes(obj.__typename)
}
const AutocompleteResult_possibleTypes: string[] = ['AutocompleteResult']
export const isAutocompleteResult = (obj?: { __typename?: any } | null): obj is AutocompleteResult => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAutocompleteResult"')
@@ -7967,14 +7937,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const Webhook_possibleTypes: string[] = ['Webhook']
export const isWebhook = (obj?: { __typename?: any } | null): obj is Webhook => {
if (!obj?.__typename) throw new Error('__typename is missing in "isWebhook"')
return Webhook_possibleTypes.includes(obj.__typename)
}
const ToolIndexEntry_possibleTypes: string[] = ['ToolIndexEntry']
export const isToolIndexEntry = (obj?: { __typename?: any } | null): obj is ToolIndexEntry => {
if (!obj?.__typename) throw new Error('__typename is missing in "isToolIndexEntry"')
@@ -8175,14 +8137,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const WorkspaceAiStats_possibleTypes: string[] = ['WorkspaceAiStats']
export const isWorkspaceAiStats = (obj?: { __typename?: any } | null): obj is WorkspaceAiStats => {
if (!obj?.__typename) throw new Error('__typename is missing in "isWorkspaceAiStats"')
return WorkspaceAiStats_possibleTypes.includes(obj.__typename)
}
const CalendarChannel_possibleTypes: string[] = ['CalendarChannel']
export const isCalendarChannel = (obj?: { __typename?: any } | null): obj is CalendarChannel => {
if (!obj?.__typename) throw new Error('__typename is missing in "isCalendarChannel"')
@@ -8247,6 +8201,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const Webhook_possibleTypes: string[] = ['Webhook']
export const isWebhook = (obj?: { __typename?: any } | null): obj is Webhook => {
if (!obj?.__typename) throw new Error('__typename is missing in "isWebhook"')
return Webhook_possibleTypes.includes(obj.__typename)
}
const Query_possibleTypes: string[] = ['Query']
export const isQuery = (obj?: { __typename?: any } | null): obj is Query => {
if (!obj?.__typename) throw new Error('__typename is missing in "isQuery"')
@@ -8426,11 +8388,6 @@ export const enumCommandMenuItemAvailabilityType = {
FALLBACK: 'FALLBACK' as const
}
export const enumLogicFunctionExecutionMode = {
LIVE: 'LIVE' as const,
PREBUILT: 'PREBUILT' as const
}
export const enumFieldMetadataType = {
ACTOR: 'ACTOR' as const,
ADDRESS: 'ADDRESS' as const,
@@ -8585,6 +8542,7 @@ export const enumPageLayoutTabLayoutMode = {
export const enumWidgetConfigurationType = {
AGGREGATE_CHART: 'AGGREGATE_CHART' as const,
GAUGE_CHART: 'GAUGE_CHART' as const,
PIE_CHART: 'PIE_CHART' as const,
BAR_CHART: 'BAR_CHART' as const,
LINE_CHART: 'LINE_CHART' as const,
@@ -8651,8 +8609,7 @@ export const enumFieldDisplayMode = {
CARD: 'CARD' as const,
EDITOR: 'EDITOR' as const,
FIELD: 'FIELD' as const,
VIEW: 'VIEW' as const,
TABLE: 'TABLE' as const
VIEW: 'VIEW' as const
}
export const enumPageLayoutType = {
@@ -8662,17 +8619,6 @@ export const enumPageLayoutType = {
STANDALONE_PAGE: 'STANDALONE_PAGE' as const
}
export const enumEmailingDomainDriver = {
AWS_SES: 'AWS_SES' as const
}
export const enumEmailingDomainStatus = {
PENDING: 'PENDING' as const,
VERIFIED: 'VERIFIED' as const,
FAILED: 'FAILED' as const,
TEMPORARY_FAILURE: 'TEMPORARY_FAILURE' as const
}
export const enumBillingPlanKey = {
PRO: 'PRO' as const,
ENTERPRISE: 'ENTERPRISE' as const
@@ -8739,11 +8685,10 @@ export const enumFeatureFlagKey = {
IS_JSON_FILTER_ENABLED: 'IS_JSON_FILTER_ENABLED' as const,
IS_MARKETPLACE_SETTING_TAB_VISIBLE: 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' as const,
IS_PUBLIC_DOMAIN_ENABLED: 'IS_PUBLIC_DOMAIN_ENABLED' as const,
IS_EMAILING_DOMAIN_ENABLED: 'IS_EMAILING_DOMAIN_ENABLED' as const,
IS_EMAIL_GROUP_ENABLED: 'IS_EMAIL_GROUP_ENABLED' as const,
IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const,
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const,
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED: 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' as const,
IS_SETTINGS_DISCOVERY_HERO_ENABLED: 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' as const
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const
}
export const enumIdentityProviderType = {
@@ -8787,6 +8732,17 @@ export const enumBillingEntitlementKey = {
AUDIT_LOGS: 'AUDIT_LOGS' as const
}
export const enumEmailingDomainDriver = {
AWS_SES: 'AWS_SES' as const
}
export const enumEmailingDomainStatus = {
PENDING: 'PENDING' as const,
VERIFIED: 'VERIFIED' as const,
FAILED: 'FAILED' as const,
TEMPORARY_FAILURE: 'TEMPORARY_FAILURE' as const
}
export const enumCalendarChannelSyncStatus = {
NOT_SYNCED: 'NOT_SYNCED' as const,
ONGOING: 'ONGOING' as const,
File diff suppressed because it is too large Load Diff
@@ -40,7 +40,6 @@ export default defineField({
- When defining fields **inline inside `defineObject()`**, you do **not** need `objectUniversalIdentifier` — it's inherited from the parent object.
- `defineField()` is the only way to add fields to objects you didn't create with `defineObject()`.
- File location is up to you. The convention is `src/fields/<name>.field.ts`, but the SDK detects fields anywhere in `src/`.
- To add a tab to a standard page layout (e.g. the Task or Company detail page), use [`definePageLayoutTab`](/developers/extend/apps/layout/page-layouts#definepagelayouttab) with `STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS` from `twenty-sdk/define`.
## Adding a relation to an existing object
@@ -44,53 +44,8 @@ A Twenty app's **data layer** is the data your app *adds* to a workspace — the
| **Object** | A new custom record type (e.g. PostCard, Invoice) with its own fields | `defineObject()` |
| **Field** | A column on an object. Standalone fields can extend objects you didn't create (e.g. add `loyaltyTier` to Company) | `defineField()` |
| **Relation** | A bidirectional link between two objects — both sides declared as fields | `defineField()` with `FieldType.RELATION` |
| **Index** | A database index to speed up a recurring query on one of your objects | `defineIndex()` |
The SDK detects these via AST analysis at build time, so file organization is up to you — the convention is `src/objects/`, `src/fields/`, and `src/indexes/`. Stable `universalIdentifier` UUIDs tie everything together across deploys.
## Indexes (optional)
Apps can ship indexes alongside their objects to keep recurring queries fast. The most common case is a status or foreign-key column that you read frequently.
```ts src/indexes/post-card-status.index.ts
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
```
### Unique indexes
`defineIndex` accepts `isUnique: true` for both single- and multi-column uniqueness. This is the recommended primitive — `defineField({ isUnique: true })` is deprecated and will be removed in a future release.
```ts
defineIndex({
universalIdentifier: '…',
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
isUnique: true,
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
});
```
### Other constraints
- Partial `WHERE` clauses stay under admin control — apps can't declare them.
- Each object is capped at 10 custom indexes (the framework's own indexes don't count).
Order the `fields` array the way Postgres should use it — leftmost column first, like a phone book. Indexes are not free: every write to the table updates them. Add one only when you have a query that needs it.
The SDK detects these via AST analysis at build time, so file organization is up to you — the convention is `src/objects/` and `src/fields/`. Stable `universalIdentifier` UUIDs tie everything together across deploys.
<Note>
Looking for **Application Config** or **Roles & Permissions**? Those describe the app itself rather than the data it adds — they live under [Config](/developers/extend/apps/config/overview). Looking for **Connections** (Linear, GitHub, Slack OAuth)? Those exist to be called *from* logic functions and live under [Logic](/developers/extend/apps/logic/connections).
@@ -84,11 +84,11 @@ export default defineCommandMenuItem({
The `conditionalAvailabilityExpression` field lets you control when a command is visible based on the current page context. Import typed variables and operators from `twenty-sdk` to build expressions:
```ts src/command-menu-items/bulk-update.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
import {
defineCommandMenuItem,
objectPermissions,
everyEquals,
} from 'twenty-sdk/define';
} from 'twenty-sdk/front-component';
export default defineCommandMenuItem({
universalIdentifier: '...',
@@ -103,10 +103,6 @@ export default defineCommandMenuItem({
});
```
<Note>
`RECORD_SELECTION` already implies a non-empty selection — use `numberOfSelectedRecords` only for specific counts (e.g. `>= 2`).
</Note>
### Context variables
These represent the current state of the page:
@@ -196,94 +196,6 @@ export default defineFrontComponent({
});
```
## Calling a logic function
Front components run browser-side in a sandboxed Web Worker, while [logic functions](/developers/extend/apps/logic/logic-functions) run server-side. There is no direct in-process call between the two — instead, a front component reaches a logic function over HTTP.
A logic function declared with `httpRouteTriggerSettings` is exposed under the `/s/` endpoint at `${TWENTY_API_URL}/s<path>`. Your front component calls that route with `fetch`, authenticating with the `TWENTY_APP_ACCESS_TOKEN` that Twenty injects into the worker.
A small reusable helper keeps the call sites clean:
```ts src/shared/call-app-route.ts
export async function callAppRoute(
path: string,
body: Record<string, unknown>,
): Promise<unknown> {
const apiUrl = process.env.TWENTY_API_URL ?? '';
const token = process.env.TWENTY_APP_ACCESS_TOKEN;
const res = await fetch(`${apiUrl}/s${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`Logic function failed (${res.status})`);
}
return res.json();
}
```
A headless front component can run the call on mount via the `Command` component, then unmount automatically:
```tsx src/front-components/sync-prs.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/command';
import { callAppRoute } from 'src/shared/call-app-route';
const SyncPrs = () => {
const execute = async () => {
await callAppRoute('/github/fetch-prs', {
owner: 'twentyhq',
repo: 'twenty',
});
};
return <Command execute={execute} />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'sync-prs',
description: 'Triggers the fetch-prs logic function',
isHeadless: true,
component: SyncPrs,
});
```
The `path` passed to `callAppRoute` must match the logic function's `httpRouteTriggerSettings.path` (the `/s` prefix is added by the helper):
```ts src/logic-functions/fetch-prs.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { owner, repo } = (event.body ?? {}) as { owner: string; repo: string };
// ...fetch from GitHub and persist records...
return { ok: true };
};
export default defineLogicFunction({
universalIdentifier: '...',
name: 'fetch-prs',
handler,
httpRouteTriggerSettings: {
path: '/github/fetch-prs',
httpMethod: 'POST',
isAuthRequired: true,
},
});
```
<Note>
`TWENTY_API_URL` and `TWENTY_APP_ACCESS_TOKEN` are injected automatically — see [Application variables](#application-variables). Because secret application variables are never exposed to front components, keep API keys and other sensitive logic in the logic function, not in the front component.
</Note>
## Accessing runtime context
Inside your component, use SDK hooks to access the current user, record, and component instance:
@@ -423,8 +335,8 @@ export default defineFrontComponent({
Use `useSelectedRecordIds()` to handle multiple selected records. This is useful for bulk operations:
```tsx src/front-components/bulk-export.tsx
import { defineFrontComponent, numberOfSelectedRecords } from 'twenty-sdk/define';
import { useSelectedRecordIds } from 'twenty-sdk/front-component';
import { defineFrontComponent } from 'twenty-sdk/define';
import { useSelectedRecordIds, numberOfSelectedRecords } from 'twenty-sdk/front-component';
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-sdk/clients';
@@ -65,15 +65,16 @@ Use this when you only want to **add** a tab to an existing layout — for examp
import {
definePageLayoutTab,
PageLayoutTabLayoutMode,
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
const COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'20202020-ab01-4001-8001-c0aba11c0100';
export default definePageLayoutTab({
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
pageLayoutUniversalIdentifier:
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.companyRecordPage
.universalIdentifier,
COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
title: 'Hello World',
position: 1000,
icon: 'IconWorld',
@@ -96,34 +97,6 @@ export default definePageLayoutTab({
### Key points
- `pageLayoutUniversalIdentifier` is **required** and must point to a page layout that already exists at install time — either a standard Twenty layout or one defined by your own app. Cross-app references to layouts owned by another installed app are not supported today. When the parent layout is missing, installation fails with a clear validation error.
- For standard Twenty layouts, import identifiers from `twenty-sdk/define`:
```ts
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.companyRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.personRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.opportunityRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.noteRecordPage.universalIdentifier
// …
```
Each layout entry also exposes its `tabs` and their `widgets`, so you can reference any level:
```ts
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.tabs.home.universalIdentifier
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.tabs.home.widgets.fields.universalIdentifier
```
A short alias `STANDARD_PAGE_LAYOUT` is also available:
```ts
import { STANDARD_PAGE_LAYOUT } from 'twenty-sdk/define';
STANDARD_PAGE_LAYOUT.companyRecordPage.universalIdentifier;
```
- `widgets` are scoped to this tab only — they reference [front components](/developers/extend/apps/layout/front-components), views, etc. exactly like widgets defined inline in `definePageLayout`.
- `position` controls ordering against existing tabs on the targeted layout. Pick a value that places your tab where you want it relative to built-in tabs.
- Use this instead of `definePageLayout` when you only want to add to an existing layout. Use `definePageLayout` when you own the entire layout.
@@ -70,7 +70,7 @@ filters: [
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `BOOLEAN` | `IS` |
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `UUID` | `IS` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Field types with similar names can use entirely different operands — `SELECT` and `MULTI_SELECT` being a common case.
@@ -53,10 +53,6 @@ export default defineLogicFunction({
Available trigger types:
- **httpRoute**: Exposes your function on an HTTP path and method **under the `/s/` endpoint**:
> e.g. `path: '/post-card/create'` is callable at `https://your-twenty-server.com/s/post-card/create`
<Note>
To invoke a route-triggered logic function from a (headless) front component, see [Calling a logic function](/developers/extend/apps/layout/front-components#calling-a-logic-function).
</Note>
- **cron**: Runs your function on a schedule using a CRON expression.
- **databaseEvent**: Runs on workspace object lifecycle events. When the event operation is `updated`, specific fields to listen to can be specified in the `updatedFields` array. If left undefined or empty, any update will trigger the function.
> e.g. `person.updated`, `*.created`, `company.*`
@@ -0,0 +1,49 @@
---
title: Other methods
icon: "cloud"
---
<Warning>
This document is maintained by the community. It might contain issues.
</Warning>
## Kubernetes via Terraform and Manifests
Community-led documentation for Kubernetes deployment is available [here](https://github.com/twentyhq/twenty/tree/main/packages/twenty-docker/k8s)
### Coolify
Deploy Twenty on servers using Coolify. (official image on Coolify will be available soon)
[Coolify documentation](https://coolify.io/docs/get-started/introduction)
### EasyPanel
Deploy Twenty on EasyPanel with the community maintained template below.
[Deploy on EasyPanel](https://easypanel.io/docs/templates/twenty)
### Elest.io
Deploy Twenty on servers with Elest.io using link below.
[Deploy on Elest.io](https://elest.io/open-source/twenty)
### Twenty on Railway
Deploy Twenty on Railway with the community maintained template below.
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nAL3hA)
### Twenty on Sealos
Deploy Twenty on Sealos with the community maintained template below.
[![Deploy on Sealos](https://sealos.io/Deploy-on-Sealos.svg)](https://sealos.io/products/app-store/twenty)
## Others
Please feel free to Open a PR to add more Cloud Provider options.
@@ -16,9 +16,10 @@ Each key carries a `publicKey` (kept indefinitely so it can verify previously is
### Rotate the current key
Set `SIGNING_KEY_ROTATION_DAYS` to opt in: a daily cron then issues a new current key once the existing one is older than that threshold. Previous keys are *not* revoked, so tokens signed under them keep verifying. Leave the variable unset to disable auto-rotation.
- **Manual** — **Settings → Admin Panel → Signing keys → Revoke** on the current row. Revoking wipes its encrypted private material and demotes it; the next sign call automatically mints a fresh ES256 keypair as the new current. Tokens signed under any other (non-revoked) `kid` keep verifying until they expire.
- **Enterprise (automatic)** — a daily cron (`'15 3 * * *'` UTC) issues a new current key once the existing one has been current for `SIGNING_KEY_ROTATION_DAYS` (default `90`). The previous key is *not* revoked, so tokens signed under it keep verifying. Register it once with `yarn command:prod cron:register:all`.
<Note>Auto-rotation ships in v2.6+.</Note>
<Note>The Enterprise cron and `SIGNING_KEY_ROTATION_DAYS` ship in v2.6+.</Note>
### Revoke a key (leak / emergency only)
@@ -21,5 +21,8 @@ Twenty can be self-hosted on your own infrastructure, giving you full control ov
<Card title="Docker Compose" icon="docker" href="/developers/self-host/capabilities/docker-compose">
Quick setup with Docker
</Card>
<Card title="Cloud Providers" icon="cloud" href="/developers/self-host/capabilities/cloud-providers">
Deploy on AWS, GCP, or Azure
</Card>
</CardGroup>
+18
View File
@@ -449,6 +449,7 @@
"developers/self-host/capabilities/docker-compose",
"developers/self-host/capabilities/setup",
"developers/self-host/capabilities/upgrade-guide",
"developers/self-host/capabilities/cloud-providers",
"developers/self-host/capabilities/troubleshooting"
]
},
@@ -881,6 +882,7 @@
"l/fr/developers/self-host/capabilities/docker-compose",
"l/fr/developers/self-host/capabilities/setup",
"l/fr/developers/self-host/capabilities/upgrade-guide",
"l/fr/developers/self-host/capabilities/cloud-providers",
"l/fr/developers/self-host/capabilities/troubleshooting"
]
},
@@ -1313,6 +1315,7 @@
"l/ar/developers/self-host/capabilities/docker-compose",
"l/ar/developers/self-host/capabilities/setup",
"l/ar/developers/self-host/capabilities/upgrade-guide",
"l/ar/developers/self-host/capabilities/cloud-providers",
"l/ar/developers/self-host/capabilities/troubleshooting"
]
},
@@ -1745,6 +1748,7 @@
"l/cs/developers/self-host/capabilities/docker-compose",
"l/cs/developers/self-host/capabilities/setup",
"l/cs/developers/self-host/capabilities/upgrade-guide",
"l/cs/developers/self-host/capabilities/cloud-providers",
"l/cs/developers/self-host/capabilities/troubleshooting"
]
},
@@ -2177,6 +2181,7 @@
"l/de/developers/self-host/capabilities/docker-compose",
"l/de/developers/self-host/capabilities/setup",
"l/de/developers/self-host/capabilities/upgrade-guide",
"l/de/developers/self-host/capabilities/cloud-providers",
"l/de/developers/self-host/capabilities/troubleshooting"
]
},
@@ -2609,6 +2614,7 @@
"l/es/developers/self-host/capabilities/docker-compose",
"l/es/developers/self-host/capabilities/setup",
"l/es/developers/self-host/capabilities/upgrade-guide",
"l/es/developers/self-host/capabilities/cloud-providers",
"l/es/developers/self-host/capabilities/troubleshooting"
]
},
@@ -3041,6 +3047,7 @@
"l/it/developers/self-host/capabilities/docker-compose",
"l/it/developers/self-host/capabilities/setup",
"l/it/developers/self-host/capabilities/upgrade-guide",
"l/it/developers/self-host/capabilities/cloud-providers",
"l/it/developers/self-host/capabilities/troubleshooting"
]
},
@@ -3473,6 +3480,7 @@
"l/ja/developers/self-host/capabilities/docker-compose",
"l/ja/developers/self-host/capabilities/setup",
"l/ja/developers/self-host/capabilities/upgrade-guide",
"l/ja/developers/self-host/capabilities/cloud-providers",
"l/ja/developers/self-host/capabilities/troubleshooting"
]
},
@@ -3905,6 +3913,7 @@
"l/ko/developers/self-host/capabilities/docker-compose",
"l/ko/developers/self-host/capabilities/setup",
"l/ko/developers/self-host/capabilities/upgrade-guide",
"l/ko/developers/self-host/capabilities/cloud-providers",
"l/ko/developers/self-host/capabilities/troubleshooting"
]
},
@@ -4337,6 +4346,7 @@
"l/pt/developers/self-host/capabilities/docker-compose",
"l/pt/developers/self-host/capabilities/setup",
"l/pt/developers/self-host/capabilities/upgrade-guide",
"l/pt/developers/self-host/capabilities/cloud-providers",
"l/pt/developers/self-host/capabilities/troubleshooting"
]
},
@@ -4769,6 +4779,7 @@
"l/ro/developers/self-host/capabilities/docker-compose",
"l/ro/developers/self-host/capabilities/setup",
"l/ro/developers/self-host/capabilities/upgrade-guide",
"l/ro/developers/self-host/capabilities/cloud-providers",
"l/ro/developers/self-host/capabilities/troubleshooting"
]
},
@@ -5201,6 +5212,7 @@
"l/ru/developers/self-host/capabilities/docker-compose",
"l/ru/developers/self-host/capabilities/setup",
"l/ru/developers/self-host/capabilities/upgrade-guide",
"l/ru/developers/self-host/capabilities/cloud-providers",
"l/ru/developers/self-host/capabilities/troubleshooting"
]
},
@@ -5633,6 +5645,7 @@
"l/tr/developers/self-host/capabilities/docker-compose",
"l/tr/developers/self-host/capabilities/setup",
"l/tr/developers/self-host/capabilities/upgrade-guide",
"l/tr/developers/self-host/capabilities/cloud-providers",
"l/tr/developers/self-host/capabilities/troubleshooting"
]
},
@@ -6065,6 +6078,7 @@
"l/zh/developers/self-host/capabilities/docker-compose",
"l/zh/developers/self-host/capabilities/setup",
"l/zh/developers/self-host/capabilities/upgrade-guide",
"l/zh/developers/self-host/capabilities/cloud-providers",
"l/zh/developers/self-host/capabilities/troubleshooting"
]
},
@@ -6182,6 +6196,10 @@
"source": "/developers/self-hosting/upgrade-guide",
"destination": "/developers/self-host/capabilities/upgrade-guide"
},
{
"source": "/developers/self-hosting/cloud-providers",
"destination": "/developers/self-host/capabilities/cloud-providers"
},
{
"source": "/developers/self-hosting/troubleshooting",
"destination": "/developers/self-host/capabilities/troubleshooting"
@@ -306,14 +306,14 @@ export default defineFrontComponent({
يتيح لك الحقل `conditionalAvailabilityExpression` التحكّم في وقت ظهور الأمر بناءً على سياق الصفحة الحالي. استورد متغيّرات ومشغّلات مضبوطة الأنواع من `twenty-sdk` لبناء التعابير:
```tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import {
defineFrontComponent,
pageType,
numberOfSelectedRecords,
objectPermissions,
everyEquals,
isDefined,
} from 'twenty-sdk/define';
} from 'twenty-sdk/front-component';
export default defineFrontComponent({
universalIdentifier: '...',
@@ -0,0 +1,46 @@
---
title: طرق أخرى
icon: cloud
---
<Warning>
هذا المستند يُحافظ عليه من قبل المجتمع. قد يحتوي على مشكلات.
</Warning>
## Kubernetes عبر Terraform والمخططات
يتوفر توثيق يقوده المجتمع لعملية نشر Kubernetes [هنا](https://github.com/twentyhq/twenty/tree/main/packages/twenty-docker/k8s)
### Coolify
نشر Twenty على الخوادم باستخدام Coolify. (الصورة الرسمية على Coolify ستكون متاحة قريبًا)
[توثيق Coolify](https://coolify.io/docs/get-started/introduction)
### EasyPanel
نشر Twenty على EasyPanel مع القالب الذي يُحافظ عليه المجتمع أدناه.
[نشر على EasyPanel](https://easypanel.io/docs/templates/twenty)
### Elest.io
نشر Twenty على الخوادم باستخدام Elest.io عبر الرابط التالي.
[نشر على Elest.io](https://elest.io/open-source/twenty)
### Twenty على Railway
نشر Twenty على Railway مع القالب الذي يُحافظ عليه المجتمع أدناه.
[![نشر على Railway](https://railway.com/button.svg)](https://railway.com/deploy/nAL3hA)
### Twenty على Sealos
انشر Twenty على Sealos باستخدام القالب الذي تتم صيانته من قِبل المجتمع أدناه.
[![نشر على Sealos](https://sealos.io/Deploy-on-Sealos.svg)](https://sealos.io/products/app-store/twenty)
## أخرى
لا تتردد في فتح طلب سحب لإضافة المزيد من خيارات موفّري السحابة.
@@ -23,4 +23,7 @@ description: قم بنشر Twenty وإدارته على البنية التحت
<Card title="Docker Compose" icon="docker" href="/l/ar/developers/self-host/capabilities/docker-compose">
إعداد سريع باستخدام Docker
</Card>
<Card title="مزودو الخدمات السحابية" icon="cloud" href="/l/ar/developers/self-host/capabilities/cloud-providers">
انشر على AWS أو GCP أو Azure
</Card>
</CardGroup>
@@ -306,14 +306,14 @@ Přidání pole `command` do `defineFrontComponent` zaregistruje komponentu v p
Pole `conditionalAvailabilityExpression` vám umožní řídit viditelnost příkazu na základě aktuálního kontextu stránky. Pro sestavení výrazů importujte typované proměnné a operátory z `twenty-sdk`:
```tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import {
defineFrontComponent,
pageType,
numberOfSelectedRecords,
objectPermissions,
everyEquals,
isDefined,
} from 'twenty-sdk/define';
} from 'twenty-sdk/front-component';
export default defineFrontComponent({
universalIdentifier: '...',
@@ -0,0 +1,46 @@
---
title: Další metody
icon: cloud
---
<Warning>
Tento dokument je udržován komunitou. Může obsahovat problémy.
</Warning>
## Kubernetes pomocí Terraform a Manifestů
Dokumentace vedená komunitou k nasazení Kubernetes je k dispozici [zde](https://github.com/twentyhq/twenty/tree/main/packages/twenty-docker/k8s)
### Coolify
Nasazení Twenty na serverech pomocí Coolify. (oficiální obrázek na Coolify bude brzy k dispozici)
[Dokumentace Coolify](https://coolify.io/docs/get-started/introduction)
### EasyPanel
Nasazení Twenty na EasyPanel s komunitně udržovanou šablonou níže.
[Nasadit na EasyPanel](https://easypanel.io/docs/templates/twenty)
### Elest.io
Nasazení Twenty na serverech pomocí Elest.io s odkazem níže.
[Nasadit na Elest.io](https://elest.io/open-source/twenty)
### Twenty na Railway
Nasazení Twenty na Railway s komunitně udržovanou šablonou níže.
[![Nasadit na Railway](https://railway.com/button.svg)](https://railway.com/deploy/nAL3hA)
### Ostatní
Nasazení Twenty na Sealos s komunitně udržovanou šablonou níže.
[![Nasadit na Sealos](https://sealos.io/Deploy-on-Sealos.svg)](https://sealos.io/products/app-store/twenty)
## Ostatní
Neváhejte otevřít PR pro přidání dalších možností poskytovatele cloudových služeb.
@@ -23,4 +23,7 @@ Twenty můžete samostatně hostovat na vlastní infrastruktuře, což vám dáv
<Card title="Docker Compose" icon="docker" href="/l/cs/developers/self-host/capabilities/docker-compose">
Rychlé nastavení s Dockerem
</Card>
<Card title="Poskytovatelé cloudu" icon="cloud" href="/l/cs/developers/self-host/capabilities/cloud-providers">
Nasaďte na AWS, GCP nebo Azure
</Card>
</CardGroup>
@@ -43,8 +43,6 @@ export default defineField({
* Der Speicherort der Datei liegt bei Ihnen. Die Konvention ist `src/fields/\<name>.field.ts`, aber das SDK erkennt Felder überall in `src/`.
* Um eine Registerkarte zu einem Standard-Seitenlayout hinzuzufügen (z. B. der Aufgaben- oder Unternehmensdetailseite), verwenden Sie [`definePageLayoutTab`](/l/de/developers/extend/apps/layout/page-layouts#definepagelayouttab) mit `STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS` aus `twenty-sdk/define`.
## Hinzufügen einer Relation zu einem bestehenden Objekt
Um ein Relationsfeld hinzuzufügen (z. B. zur Verknüpfung Ihres benutzerdefinierten Objekts mit einer Standard-`Person`), verwenden Sie `defineField()` mit `FieldType.RELATION`. Das Muster ist dasselbe wie bei Inline-Relationen, jedoch mit explizit gesetztem `objectUniversalIdentifier`. Siehe [Relations](/l/de/developers/extend/apps/data/relations) für das bidirektionale Muster.
@@ -44,53 +44,8 @@ Die **Datenebene** einer Twenty-App umfasst die Daten, die Ihre App zu einem Wor
| **Objekt** | Ein neuer benutzerdefinierter Datensatztyp (z. B. PostCard, Invoice) mit eigenen Feldern | `defineObject()` |
| **Feld** | Eine Spalte in einem Objekt. Eigenständige Felder können Objekte erweitern, die Sie nicht erstellt haben (z. B. `loyaltyTier` zu Company hinzufügen) | `defineField()` |
| **Beziehung** | Eine bidirektionale Verknüpfung zwischen zwei Objekten beide Seiten werden als Felder deklariert | `defineField()` mit `FieldType.RELATION` |
| **Indizes** | Ein Datenbankindex, um eine wiederkehrende Abfrage für eines Ihrer Objekte zu beschleunigen | `defineIndex()` |
Das SDK erkennt diese zur Build-Zeit über eine AST-Analyse, sodass die Dateiorganisation Ihnen überlassen ist die Konvention ist `src/objects/`, `src/fields/` und `src/indexes/`. Stabile `universalIdentifier`-UUIDs verknüpfen alles über Deploys hinweg.
## Indizes (optional)
Apps können Indizes gemeinsam mit ihren Objekten ausliefern, um wiederkehrende Abfragen schnell zu halten. Der häufigste Fall ist eine Status- oder Fremdschlüsselspalte, die Sie häufig lesen.
```ts src/indexes/post-card-status.index.ts
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
```
### Eindeutige Indizes
`defineIndex` akzeptiert `isUnique: true` sowohl für Einspalten- als auch Mehrspalteneindeutigkeit. Dies ist das empfohlene Primitive `defineField({ isUnique: true })` ist veraltet und wird in einer zukünftigen Version entfernt.
```ts
defineIndex({
universalIdentifier: '…',
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
isUnique: true,
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
});
```
### Andere Einschränkungen
* Partielle `WHERE`-Klauseln bleiben unter Kontrolle der Administratoren Apps können sie nicht deklarieren.
* Jedes Objekt ist auf 10 benutzerdefinierte Indizes begrenzt (die Indizes des Frameworks selbst werden nicht mitgezählt).
Ordnen Sie das `fields`-Array so an, wie Postgres es verwenden soll die ganz linke Spalte zuerst, wie in einem Telefonbuch. Indizes sind nicht kostenlos: Jeder Schreibvorgang in die Tabelle aktualisiert sie. Fügen Sie einen nur dann hinzu, wenn Sie eine Abfrage haben, die ihn benötigt.
Das SDK erkennt diese zur Build-Zeit über eine AST-Analyse, sodass die Dateiorganisation Ihnen überlassen ist die Konvention ist `src/objects/` und `src/fields/`. Stabile `universalIdentifier`-UUIDs verknüpfen alles über Deploys hinweg.
<Note>
Suchen Sie nach **Application Config** oder **Roles & Permissions**? Diese beschreiben die App selbst und nicht die Daten, die sie hinzufügt sie befinden sich unter [Config](/l/de/developers/extend/apps/config/overview). Suchen Sie nach **Connections** (Linear, GitHub, Slack OAuth)? Diese existieren, um *von* Logikfunktionen aufgerufen zu werden, und befinden sich unter [Logic](/l/de/developers/extend/apps/logic/connections).
@@ -298,8 +298,8 @@ export default defineFrontComponent({
Verwenden Sie `useSelectedRecordIds()`, um mehrere ausgewählte Datensätze zu verwalten. Dies ist nützlich für Stapelvorgänge:
```tsx src/front-components/bulk-export.tsx
import { defineFrontComponent, numberOfSelectedRecords } from 'twenty-sdk/define';
import { useSelectedRecordIds } from 'twenty-sdk/front-component';
import { defineFrontComponent } from 'twenty-sdk/define';
import { useSelectedRecordIds, numberOfSelectedRecords } from 'twenty-sdk/front-component';
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-sdk/clients';
@@ -383,11 +383,11 @@ export default defineCommandMenuItem({
Mit dem Feld `conditionalAvailabilityExpression` können Sie basierend auf dem aktuellen Seitenkontext steuern, wann ein Befehl sichtbar ist. Importieren Sie typisierte Variablen und Operatoren aus `twenty-sdk`, um Ausdrücke zu erstellen:
```ts src/command-menu-items/bulk-update.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
import {
defineCommandMenuItem,
objectPermissions,
everyEquals,
} from 'twenty-sdk/define';
} from 'twenty-sdk/front-component';
export default defineCommandMenuItem({
universalIdentifier: '...',
@@ -84,11 +84,11 @@ export default defineCommandMenuItem({
Mit dem Feld `conditionalAvailabilityExpression` können Sie basierend auf dem aktuellen Seitenkontext steuern, wann ein Befehl sichtbar ist. Importieren Sie typisierte Variablen und Operatoren aus `twenty-sdk`, um Ausdrücke zu erstellen:
```ts src/command-menu-items/bulk-update.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
import {
defineCommandMenuItem,
objectPermissions,
everyEquals,
} from 'twenty-sdk/define';
} from 'twenty-sdk/front-component';
export default defineCommandMenuItem({
universalIdentifier: '...',
@@ -103,10 +103,6 @@ export default defineCommandMenuItem({
});
```
<Note>
`RECORD_SELECTION` impliziert bereits eine nichtleere Auswahl — verwende `numberOfSelectedRecords` nur für bestimmte Zählwerte (z. B. `>= 2`).
</Note>
### Kontextvariablen
Diese repräsentieren den aktuellen Zustand der Seite:
@@ -196,94 +196,6 @@ export default defineFrontComponent({
});
```
## Aufrufen einer Logikfunktion
Front-Komponenten laufen browserseitig in einem isolierten Web Worker, während [Logikfunktionen](/l/de/developers/extend/apps/logic/logic-functions) serverseitig ausgeführt werden. Es gibt keinen direkten In-Process-Aufruf zwischen beiden stattdessen ruft eine Front-Komponente eine Logikfunktion über HTTP auf.
Eine mit `httpRouteTriggerSettings` deklarierte Logikfunktion wird unter dem `/s/`-Endpunkt unter `${TWENTY_API_URL}/s\<path>` bereitgestellt. Ihre Front-Komponente ruft diese Route mit `fetch` auf und authentifiziert sich dabei mit dem `TWENTY_APP_ACCESS_TOKEN`, das Twenty in den Worker injiziert.
Ein kleiner wiederverwendbarer Helper hält die Aufrufstellen übersichtlich:
```ts src/shared/call-app-route.ts
export async function callAppRoute(
path: string,
body: Record<string, unknown>,
): Promise<unknown> {
const apiUrl = process.env.TWENTY_API_URL ?? '';
const token = process.env.TWENTY_APP_ACCESS_TOKEN;
const res = await fetch(`${apiUrl}/s${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`Logic function failed (${res.status})`);
}
return res.json();
}
```
Eine headless Front-Komponente kann den Aufruf beim Mounten über die `Command`-Komponente ausführen und sich anschließend automatisch unmounten:
```tsx src/front-components/sync-prs.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/command';
import { callAppRoute } from 'src/shared/call-app-route';
const SyncPrs = () => {
const execute = async () => {
await callAppRoute('/github/fetch-prs', {
owner: 'twentyhq',
repo: 'twenty',
});
};
return <Command execute={execute} />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'sync-prs',
description: 'Triggers the fetch-prs logic function',
isHeadless: true,
component: SyncPrs,
});
```
Der an `callAppRoute` übergebene `path` muss dem `httpRouteTriggerSettings.path` der Logikfunktion entsprechen (das `/s`-Präfix wird vom Helper hinzugefügt):
```ts src/logic-functions/fetch-prs.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { owner, repo } = (event.body ?? {}) as { owner: string; repo: string };
// ...fetch from GitHub and persist records...
return { ok: true };
};
export default defineLogicFunction({
universalIdentifier: '...',
name: 'fetch-prs',
handler,
httpRouteTriggerSettings: {
path: '/github/fetch-prs',
httpMethod: 'POST',
isAuthRequired: true,
},
});
```
<Note>
`TWENTY_API_URL` und `TWENTY_APP_ACCESS_TOKEN` werden automatisch injiziert siehe [Anwendungsvariablen](#application-variables). Da geheime Anwendungsvariablen niemals in Front-Komponenten offengelegt werden, sollten API-Schlüssel und andere sensible Logik in der Logikfunktion verbleiben und nicht in der Front-Komponente.
</Note>
## Zugriff auf den Laufzeitkontext
Verwenden Sie innerhalb Ihrer Komponente SDK-Hooks, um auf den aktuellen Benutzer, den Datensatz und die Komponenteninstanz zuzugreifen:
@@ -423,8 +335,8 @@ export default defineFrontComponent({
Verwenden Sie `useSelectedRecordIds()`, um mehrere ausgewählte Datensätze zu verwalten. Dies ist nützlich für Stapelvorgänge:
```tsx src/front-components/bulk-export.tsx
import { defineFrontComponent, numberOfSelectedRecords } from 'twenty-sdk/define';
import { useSelectedRecordIds } from 'twenty-sdk/front-component';
import { defineFrontComponent } from 'twenty-sdk/define';
import { useSelectedRecordIds, numberOfSelectedRecords } from 'twenty-sdk/front-component';
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-sdk/clients';
@@ -65,15 +65,16 @@ Verwenden Sie dies, wenn Sie nur einen Tab zu einem vorhandenen Layout **hinzuf
import {
definePageLayoutTab,
PageLayoutTabLayoutMode,
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
const COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'20202020-ab01-4001-8001-c0aba11c0100';
export default definePageLayoutTab({
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
pageLayoutUniversalIdentifier:
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.companyRecordPage
.universalIdentifier,
COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
title: 'Hello World',
position: 1000,
icon: 'IconWorld',
@@ -96,37 +97,6 @@ export default definePageLayoutTab({
### Hauptpunkte
* `pageLayoutUniversalIdentifier` ist **erforderlich** und muss auf ein Seitenlayout verweisen, das zum Installationszeitpunkt bereits existiert entweder ein standardmäßiges Twenty-Layout oder eines, das von Ihrer eigenen App definiert wurde. App-übergreifende Verweise auf Layouts, die einer anderen installierten App gehören, werden derzeit nicht unterstützt. Wenn das übergeordnete Layout fehlt, schlägt die Installation mit einem eindeutigen Validierungsfehler fehl.
* Für Standard-Twenty-Layouts importieren Sie die Bezeichner aus `twenty-sdk/define`:
```ts
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.companyRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.personRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.opportunityRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.noteRecordPage.universalIdentifier
// …
```
Jeder Layout-Eintrag stellt außerdem seine `tabs` und deren `widgets` zur Verfügung, sodass Sie auf jede Ebene verweisen können:
```ts
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.tabs.home.universalIdentifier
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.tabs.home.widgets.fields.universalIdentifier
```
Eine kurze Alias-Variable `STANDARD_PAGE_LAYOUT` ist ebenfalls verfügbar:
```ts
import { STANDARD_PAGE_LAYOUT } from 'twenty-sdk/define';
STANDARD_PAGE_LAYOUT.companyRecordPage.universalIdentifier;
```
* `widgets` sind ausschließlich auf diesen Tab beschränkt sie verweisen auf [Frontend-Komponenten](/l/de/developers/extend/apps/layout/front-components), Ansichten usw., genau wie Widgets, die inline in `definePageLayout` definiert sind.
* `position` steuert die Reihenfolge im Zielseitenlayout relativ zu den vorhandenen Registerkarten. Wählen Sie einen Wert, der Ihre Registerkarte relativ zu integrierten Registerkarten an die gewünschte Position bringt.
* Verwenden Sie dies anstelle von `definePageLayout`, wenn Sie einem vorhandenen Layout nur etwas hinzufügen möchten. Verwenden Sie `definePageLayout`, wenn Sie das gesamte Layout besitzen.
@@ -70,7 +70,7 @@ filters: [
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `BOOLEAN` | `IS` |
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `UUID` | `IS` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Feldtypen mit ähnlichen Namen können völlig unterschiedliche Operanden verwenden `SELECT` und `MULTI_SELECT` sind ein häufiges Beispiel.
@@ -53,10 +53,6 @@ export default defineLogicFunction({
Verfügbare Trigger-Typen:
* **httpRoute**: Stellt Ihre Funktion unter einem HTTP-Pfad und einer Methode **unter dem Endpunkt `/s/`** bereit:
> z. B. `path: '/post-card/create'` ist unter `https://your-twenty-server.com/s/post-card/create` aufrufbar
<Note>
Um eine routenausgelöste Logikfunktion von einer (headless) Front-Komponente aus aufzurufen, siehe [Aufrufen einer Logikfunktion](/l/de/developers/extend/apps/layout/front-components#calling-a-logic-function).
</Note>
* **cron**: Führt Ihre Funktion nach Zeitplan mithilfe eines CRON-Ausdrucks aus.
* **databaseEvent**: Wird bei Lebenszyklusereignissen von Workspace-Objekten ausgeführt. Wenn die Ereignisoperation `updated` ist, können bestimmte zu überwachende Felder im Array `updatedFields` angegeben werden. Wenn das Array undefiniert oder leer ist, löst jede Aktualisierung die Funktion aus.
> z. B. `person.updated`, `*.created`, `company.*`
@@ -0,0 +1,46 @@
---
title: Weitere Methoden
icon: cloud
---
<Warning>
Dieses Dokument wird von der Community gepflegt. Es könnte Probleme enthalten.
</Warning>
## Kubernetes über Terraform und Manifeste
Die von der Community gepflegte Dokumentation zur Kubernetes-Bereitstellung ist [hier](https://github.com/twentyhq/twenty/tree/main/packages/twenty-docker/k8s) verfügbar.
### Coolify
Stellen Sie Twenty auf Servern mit Coolify bereit. (offizielles Bild auf Coolify wird bald verfügbar sein)
[Coolify-Dokumentation](https://coolify.io/docs/get-started/introduction)
### EasyPanel
Stellen Sie Twenty auf EasyPanel mit der untenstehenden, von der Community gepflegten Vorlage bereit.
[Bereitstellung auf EasyPanel](https://easypanel.io/docs/templates/twenty)
### Elest.io
Stellen Sie Twenty auf Servern mit Elest.io über den untenstehenden Link bereit.
[Bereitstellung auf Elest.io](https://elest.io/open-source/twenty)
### Twenty auf Railway
Stellen Sie Twenty auf Railway mit der untenstehenden, von der Community gepflegten Vorlage bereit.
[![Bereitstellung auf Railway](https://railway.com/button.svg)](https://railway.com/deploy/nAL3hA)
### Twenty auf Sealos
Stellen Sie Twenty auf Sealos mit der untenstehenden, von der Community gepflegten Vorlage bereit.
[![Bereitstellung auf Sealos](https://sealos.io/Deploy-on-Sealos.svg)](https://sealos.io/products/app-store/twenty)
## Andere
Bitte zögern Sie nicht, einen PR zu öffnen, um mehr Optionen für Cloud-Anbieter hinzuzufügen.
@@ -16,9 +16,10 @@ Jeder Schlüssel enthält einen `publicKey` (unbefristet aufbewahrt, damit er zu
### Aktuellen Schlüssel rotieren
Setzen Sie `SIGNING_KEY_ROTATION_DAYS`, um die Funktion zu aktivieren: Ein täglicher Cronjob erstellt dann einen neuen aktuellen Schlüssel, sobald der bestehende Schlüssel älter als dieser Schwellenwert ist. Frühere Schlüssel werden *nicht* widerrufen, daher werden Tokens, die darunter signiert wurden, weiterhin verifiziert. Lassen Sie die Variable ungesetzt, um die automatische Rotation zu deaktivieren.
* **Manuell** — **Settings → Admin Panel → Signing keys → Revoke** in der aktuellen Zeile. Das Widerrufen löscht das verschlüsselte private Material und stuft es herab; der nächste Signieraufruf erstellt automatisch ein neues ES256-Schlüsselpaar als neuen aktuellen Schlüssel. Tokens, die unter einem anderen (nicht widerrufenen) `kid` signiert wurden, werden weiterhin verifiziert, bis sie ablaufen.
* **Enterprise (automatisch)** — ein täglicher Cron-Job (`'15 3 * * *'` UTC) erstellt einen neuen aktuellen Schlüssel, sobald der vorhandene Schlüssel seit `SIGNING_KEY_ROTATION_DAYS` (Standard `90`) aktuell ist. Der vorherige Schlüssel wird *nicht* widerrufen, daher werden Tokens, die darunter signiert wurden, weiterhin verifiziert. Registriere ihn einmalig mit `yarn command:prod cron:register:all`.
<Note>Die automatische Rotation ist ab Version v2.6 verfügbar.</Note>
<Note>Der Enterprise-Cron-Job und `SIGNING_KEY_ROTATION_DAYS` sind ab v2.6+ enthalten.</Note>
### Einen Schlüssel widerrufen (nur bei Leak / Notfall)
@@ -23,4 +23,7 @@ Twenty kann auf Ihrer eigenen Infrastruktur im Selbsthosting betrieben werden, s
<Card title="Docker Compose" icon="docker" href="/l/de/developers/self-host/capabilities/docker-compose">
Schnelle Einrichtung mit Docker
</Card>
<Card title="Cloud-Anbieter" icon="cloud" href="/l/de/developers/self-host/capabilities/cloud-providers">
Auf AWS, GCP oder Azure bereitstellen
</Card>
</CardGroup>
@@ -101,10 +101,6 @@ Machen Sie ein Feld einzigartig, um sicherzustellen, dass sich keine verschieden
Wenn beim Einstellen der Einzigartigkeit ein Fehler auftritt, überprüfen Sie auf doppelte Werte in Ihren Daten (einschließlich gelöschter Datensätze).
## Indizes (Erweitert)
Datenbankindizes werden automatisch verwaltet eigene hinzuzufügen ist selten erforderlich und kann leicht schiefgehen. Wenn der Erweiterte Modus aktiviert ist, hat jedes Objekt unter `Einstellungen → Datenmodell → <object>` einen Abschnitt **Indizes** für die Fälle, in denen du weißt, dass du einen brauchst.
## Beste Praktiken zur Feldkonfiguration
### Benennungskonventionen und Einschränkungen
@@ -0,0 +1,45 @@
---
title: Otros métodos
---
<Warning>
Este documento es mantenido por la comunidad. Podría contener problemas.
</Warning>
## Kubernetes vía Terraform y Manifests
Documentación comunitaria para la implementación de Kubernetes está disponible [aquí](https://github.com/twentyhq/twenty/tree/main/packages/twenty-docker/k8s)
### Coolify
Despliega Twenty en servidores usando Coolify. (la imagen oficial en Coolify estará disponible pronto)
[Documentación de Coolify](https://coolify.io/docs/get-started/introduction)
### EasyPanel
Despliega Twenty en EasyPanel con la plantilla mantenida por la comunidad a continuación.
[Desplegar en EasyPanel](https://easypanel.io/docs/templates/twenty)
### Elest.io
Despliega Twenty en servidores con Elest.io utilizando el enlace a continuación.
[Desplegar en Elest.io](https://elest.io/open-source/twenty)
### Twenty en Railway
Despliega Twenty en Railway con la plantilla mantenida por la comunidad a continuación.
[![Desplegar en Railway](https://railway.com/button.svg)](https://railway.com/deploy/nAL3hA)
### Twenty en Sealos
Despliega Twenty en Sealos con la plantilla mantenida por la comunidad a continuación.
[![Desplegar en Sealos](https://sealos.io/Deploy-on-Sealos.svg)](https://sealos.io/products/app-store/twenty)
## Otros
No dudes en abrir un PR para añadir más opciones de proveedores de la nube.
@@ -23,4 +23,8 @@ Puedes autoalojar Twenty en tu propia infraestructura, lo que te brinda control
<Card title="Docker Compose" icon="docker" href="/l/es/developers/self-host/capabilities/docker-compose">
Configuración rápida con Docker
</Card>
<Card title="Proveedores de la nube" icon="cloud" href="/l/es/developers/self-host/capabilities/cloud-providers">
Despliega en AWS, GCP o Azure
</Card>
</CardGroup>
@@ -0,0 +1,45 @@
---
title: Autres méthodes
---
<Warning>
Ce document est maintenu par la communauté. Il pourrait contenir des problèmes.
</Warning>
## Kubernetes via Terraform et Manifests
La documentation communautaire pour le déploiement de Kubernetes est disponible [ici](https://github.com/twentyhq/twenty/tree/main/packages/twenty-docker/k8s)
### Coolify
Déployer Twenty sur les serveurs avec Coolify. (l'image officielle sur Coolify sera bientôt disponible)
[Documentation Coolify](https://coolify.io/docs/get-started/introduction)
### EasyPanel
Déployez Twenty sur EasyPanel avec le modèle maintenu par la communauté ci-dessous.
[Déployer sur EasyPanel](https://easypanel.io/docs/templates/twenty)
### Elest.io
Déployez Twenty sur les serveurs avec Elest.io en utilisant le lien ci-dessous.
[Déployer sur Elest.io](https://elest.io/open-source/twenty)
### Twenty sur Railway
Déployez Twenty sur Railway avec le modèle maintenu par la communauté ci-dessous.
[![Déployer sur Railway](https://railway.com/button.svg)](https://railway.com/deploy/nAL3hA)
### Twenty sur Sealos
Déployez Twenty sur Sealos avec le modèle maintenu par la communauté ci-dessous.
[![Déployer sur Sealos](https://sealos.io/Deploy-on-Sealos.svg)](https://sealos.io/products/app-store/twenty)
## Autres
N'hésitez pas à ouvrir une PR pour ajouter d'autres options de fournisseur cloud.
@@ -23,4 +23,8 @@ Twenty peut être auto-hébergé sur votre propre infrastructure, vous offrant u
<Card title="Docker Compose" icon="docker" href="/l/fr/developers/self-host/capabilities/docker-compose">
Configuration rapide avec Docker
</Card>
<Card title="Fournisseurs cloud" icon="cloud" href="/l/fr/developers/self-host/capabilities/cloud-providers">
Déployez sur AWS, GCP ou Azure
</Card>
</CardGroup>
@@ -306,14 +306,14 @@ Aggiungere un campo `command` a `defineFrontComponent` registra il componente ne
Il campo `conditionalAvailabilityExpression` consente di controllare quando un comando è visibile in base al contesto della pagina corrente. Importa variabili tipizzate e operatori da `twenty-sdk` per costruire espressioni:
```tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import {
defineFrontComponent,
pageType,
numberOfSelectedRecords,
objectPermissions,
everyEquals,
isDefined,
} from 'twenty-sdk/define';
} from 'twenty-sdk/front-component';
export default defineFrontComponent({
universalIdentifier: '...',
@@ -0,0 +1,46 @@
---
title: Altri metodi
icon: cloud
---
<Warning>
Questo documento è mantenuto dalla comunità. Potrebbe contenere problemi.
</Warning>
## Kubernetes tramite Terraform e Manifest
La documentazione guidata dalla comunità per la distribuzione su Kubernetes è disponibile [qui](https://github.com/twentyhq/twenty/tree/main/packages/twenty-docker/k8s)
### Coolify
Distribuisci Twenty sui server utilizzando Coolify. (L'immagine ufficiale su Coolify sarà disponibile a breve)
[Documentazione di Coolify](https://coolify.io/docs/get-started/introduction)
### EasyPanel
Distribuisci Twenty su EasyPanel con il modello mantenuto dalla comunità di seguito.
[Distribuisci su EasyPanel](https://easypanel.io/docs/templates/twenty)
### Elest.io
Distribuisci Twenty sui server con Elest.io utilizzando il link sottostante.
[Distribuisci su Elest.io](https://elest.io/open-source/twenty)
### Twenty su Railway
Distribuisci Twenty su Railway con il modello mantenuto dalla comunità di seguito.
[![Distribuisci su Railway](https://railway.com/button.svg)](https://railway.com/deploy/nAL3hA)
### Twenty su Sealos
Distribuisci Twenty su Sealos con il modello mantenuto dalla comunità di seguito.
[![Distribuisci su Sealos](https://sealos.io/Deploy-on-Sealos.svg)](https://sealos.io/products/app-store/twenty)
## Altro
Sentiti libero di aprire un PR per aggiungere più opzioni di provider Cloud.
@@ -23,4 +23,7 @@ Twenty può essere auto-ospitato sulla tua infrastruttura, offrendoti il pieno c
<Card title="Docker Compose" icon="docker" href="/l/it/developers/self-host/capabilities/docker-compose">
Configurazione rapida con Docker
</Card>
<Card title="Provider cloud" icon="cloud" href="/l/it/developers/self-host/capabilities/cloud-providers">
Distribuisci su AWS, GCP o Azure
</Card>
</CardGroup>
@@ -0,0 +1,46 @@
---
title: その他の方法
---
<Warning>
このドキュメントはコミュニティによって管理されています。 問題を含む可能性があります。
問題を含む可能性があります。
</Warning>
## Terraformとマニフェストを通したKubernetes
Kubernetesデプロイメントに関するコミュニティ主導のドキュメントは[こちら](https://github.com/twentyhq/twenty/tree/main/packages/twenty-docker/k8s)です。
### Coolify
Coolifyを使用してサーバーにTwentyをデプロイします。 (Coolify上の公式イメージは近日公開予定です) (Coolify上の公式イメージは近日公開予定です)
[Coolifyのドキュメント](https://coolify.io/docs/get-started/introduction)
### EasyPanel
以下のコミュニティ維持テンプレートを使用して、EasyPanelにTwentyをデプロイします。
以下のコミュニティ維持テンプレートを使用して、EasyPanelにTwentyをデプロイします。
### Elest.io
以下のリンクを使用して、Elest.ioにTwentyをサーバー上にデプロイします。
[Elest.ioにデプロイする](https://elest.io/open-source/twenty)
### Railway上のTwenty
以下のコミュニティ維持テンプレートを使用して、RailwayにTwentyをデプロイします。
[![Railwayにデプロイする](https://railway.com/button.svg)](https://railway.com/deploy/nAL3hA)
### Sealos上のTwenty
以下のコミュニティがメンテナンスしているテンプレートを使用して、SealosにTwentyをデプロイします。
[![Sealosにデプロイする](https://sealos.io/Deploy-on-Sealos.svg)](https://sealos.io/products/app-store/twenty)
## その他
もっと多くのクラウドプロバイダーオプションを追加するために、PRを自由に作成してください。

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