Compare commits

..
Author SHA1 Message Date
Abdullah 9483104ee4 feat(website): add sliding hero tabs and improve mobile nav color sync
Introduce a sliding tab indicator for the product AI hero and align the
sticky nav transition on mobile with the intro/AI boundary using measured
nav height, earlier lead offset, and blur-free solid backgrounds.
2026-05-20 04:58:57 +05:00
Abdullah e37517dc3d fix(website): polish product hero opacity sync and hydration
Align dark overlay and nav opacity with the AI panel when fully in view,
use stable tab ids and remove Edit icon clipPath useId to fix SSR mismatches,
and stabilize menu scroll effect dependencies.
2026-05-20 04:22:02 +05:00
Abdullah a4161833d0 feat(website): sync product nav with hero scroll color transition
Drive the product page navbar from hero scroll progress with opacity-based
light-to-dark transitions, including fade back to light when leaving the AI section.
2026-05-20 04:09:13 +05:00
Abdullah ac03df1f5f feat(website): add scroll-driven product hero with stacked mobile layout
Replace the static product hero with a unified intro-to-AI scroll transition on
desktop and a stacked mobile layout, including taller preview sizing and updated
AI heading copy.
2026-05-20 04:09:08 +05:00
1202 changed files with 16695 additions and 35060 deletions
+1 -1
View File
@@ -50,4 +50,4 @@ runs:
- name: Deploy
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty app:publish --private --remote target
run: yarn twenty deploy --remote target
@@ -50,4 +50,4 @@ runs:
- name: Install
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty app:install --remote target
run: yarn twenty install --remote target
@@ -105,7 +105,7 @@ jobs:
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --url http://localhost:3000
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --workspace-url http://localhost:3000
- name: Install scaffolded app dependencies
run: |
@@ -153,7 +153,7 @@ jobs:
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote:add --api-key ${{ env.TWENTY_API_KEY }} --url ${{ env.TWENTY_API_URL }}
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
- name: Run scaffolded app integration test (deploys, installs, and verifies the app)
run: |
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
steps:
- name: Trigger preview environment workflow
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REPOSITORY: ${{ github.repository }}
@@ -1,69 +0,0 @@
name: 'Website Preview Dispatch'
permissions:
contents: read
on:
pull_request:
types: [opened, synchronize, reopened, closed, labeled]
paths:
- packages/twenty-website/**
- .github/workflows/website-preview-dispatch.yaml
concurrency:
# Keyed on PR number so independent PRs don't cancel each other. `github.ref`
# would resolve to the base branch under pull_request and collide.
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
trigger-build:
# Same fork PRs from outside the org don't have `secrets.*` so the dispatch
# call would fail anyway — skip explicitly to avoid noise.
if: |
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.action != 'closed' && (
(github.event.action == 'labeled' && github.event.label.name == 'preview-website') ||
(
(
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'COLLABORATOR'
) && contains(fromJSON('["opened","synchronize","reopened"]'), github.event.action)
)
)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Dispatch website-preview-build to ci-privileged
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=website-preview-build \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
-f "client_payload[pr_head_ref]=$PR_HEAD_REF"
trigger-cleanup:
# Covers both merge and close-without-merge — pull_request `closed` fires
# for both. PRs left open forever are covered by OpenNext's
# `maxVersionAgeDays: 14` + `maxNumberOfVersions: 50` auto-pruning in
# open-next.config.ts, so nothing leaks even if cleanup never runs.
if: |
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.action == 'closed'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Dispatch website-preview-cleanup to ci-privileged
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=website-preview-cleanup \
-f "client_payload[pr_number]=$PR_NUMBER"
-27
View File
@@ -1,27 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf",
"printWidth": 80,
"sortPackageJson": false,
"ignorePatterns": [
"**/dist/**",
"**/build/**",
"**/lib/**",
"**/.next/**",
"**/coverage/**",
"**/generated/**",
"**/generated-admin/**",
"**/generated-metadata/**",
"**/.cache/**",
"**/node_modules/**",
"**/*.min.js",
"**/*.snap",
"**/*.md",
"**/*.mdx",
"**/seed-project/**/*.mjs",
"packages/twenty-zapier/build/**",
"**/upgrade-version-command/**"
]
}
-4
View File
@@ -10,8 +10,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
npmPreapprovedPackages:
- twenty-sdk
- twenty-client-sdk
yarnPath: .yarn/releases/yarn-4.13.0.cjs
+1 -1
View File
@@ -63,7 +63,7 @@ export default defineObject({
Then ship it to your workspace:
```bash
npx twenty app:publish --private
npx twenty deploy
```
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
+13 -9
View File
@@ -44,12 +44,12 @@
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "npx oxlint -c .oxlintrc.json . && (npx oxfmt --check . || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
"command": "npx oxlint -c .oxlintrc.json . && (prettier . --check --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
},
"configurations": {
"ci": {},
"fix": {
"command": "npx oxlint --fix -c .oxlintrc.json . && npx oxfmt ."
"command": "npx oxlint --fix -c .oxlintrc.json . && prettier . --write --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata"
}
},
"dependsOn": ["^build", "twenty-oxlint-rules:build"]
@@ -59,12 +59,12 @@
"cache": false,
"dependsOn": ["twenty-oxlint-rules:build"],
"options": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (npx oxfmt --check $FILES || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (prettier --check $FILES || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
"pattern": "\\.(ts|tsx|js|jsx)$"
},
"configurations": {
"fix": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && npx oxfmt $FILES)"
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && prettier --write $FILES)"
}
}
},
@@ -73,14 +73,18 @@
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "npx oxfmt --check {args.files} {args.write}",
"files": ".",
"write": ""
"command": "prettier {args.files} --check --cache {args.cache} --cache-location {args.cacheLocation} --write {args.write} --cache-strategy {args.cacheStrategy}",
"cache": true,
"cacheLocation": "../../.cache/prettier/{projectRoot}",
"cacheStrategy": "metadata",
"write": false
},
"configurations": {
"ci": {},
"ci": {
"cacheStrategy": "content"
},
"fix": {
"command": "npx oxfmt {args.files}"
"write": true
}
},
"dependsOn": ["^build"]
-1
View File
@@ -13,7 +13,6 @@
"concurrently": "^8.2.2",
"http-server": "^14.1.1",
"nx": "22.5.4",
"oxfmt": "0.50.0",
"tsx": "^4.17.0",
"verdaccio": "^6.3.1"
},
+3 -3
View File
@@ -35,7 +35,7 @@ The scaffolder will:
| `--name <name>` | Set the app name |
| `--display-name <displayName>` | Set the display name |
| `--description <description>` | Set the description |
| `--url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
| `--workspace-url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
| `--authentication-method <method>` | `oauth` or `apiKey` (default: `apiKey` for local, `oauth` for remote) |
## Documentation
@@ -48,8 +48,8 @@ Full documentation is available at **[docs.twenty.com/developers/extend/apps](ht
## Troubleshooting
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty docker:logs`.
- Auth not working: run `yarn twenty remote:add --local` to re-authenticate.
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
- Auth not working: run `yarn twenty remote add --local` to re-authenticate.
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
## Contributing
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.7.0",
"version": "2.5.1",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
+10 -7
View File
@@ -18,8 +18,11 @@ const program = new Command(packageJson.name)
.option('-n, --name <name>', 'Application name')
.option('-d, --display-name <displayName>', 'Application display name')
.option('--description <description>', 'Application description')
.option('--url <url>', 'Twenty server URL (default: http://localhost:2020)')
.option('--api-url <apiUrl>', '[deprecated: use --url]')
.option(
'--workspace-url <workspaceUrl>',
'Twenty workspace URL (default: http://localhost:2020)',
)
.option('--api-url <apiUrl>', '[deprecated: use --workspace-url]')
.option(
'--authentication-method <method>',
'Authentication method: oauth or apiKey (default: apiKey for local, oauth for remote)',
@@ -32,7 +35,7 @@ const program = new Command(packageJson.name)
name?: string;
displayName?: string;
description?: string;
url?: string;
workspaceUrl?: string;
apiUrl?: string;
authenticationMethod?: AuthenticationMethod;
},
@@ -65,18 +68,18 @@ const program = new Command(packageJson.name)
if (options?.apiUrl) {
console.warn(
chalk.yellow('Warning: --api-url is deprecated. Use --url instead.'),
chalk.yellow(
'Warning: --api-url is deprecated. Use --workspace-url instead.',
),
);
}
const serverUrl = (options?.url ?? options?.apiUrl)?.replace(/\/+$/, '');
await new CreateAppCommand().execute({
directory,
name: options?.name,
displayName: options?.displayName,
description: options?.description,
serverUrl,
workspaceUrl: options?.workspaceUrl ?? options?.apiUrl,
authenticationMethod: options?.authenticationMethod,
});
},
@@ -49,19 +49,19 @@
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
It's highly recommended to create new app entities using `yarn twenty add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
| Entity type | Command | Generated file |
| -------------------- | ------------------------------------ | ------------------------------------- |
| Object | `yarn twenty add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty add pageLayout` | `src/page-layouts/<name>.ts` |
This helps automatically generate required IDs etc.
@@ -11,8 +11,8 @@ Run `yarn twenty help` to list all available commands.
## Useful Commands
- `yarn twenty dev` - Start the development server and sync your app
- `yarn twenty docker:status` - Check the local Twenty server status
- `yarn twenty docker:start` - Start the local Twenty server
- `yarn twenty server status` - Check the local Twenty server status
- `yarn twenty server start` - Start the local Twenty server
- `yarn test` - Run integration tests
## Learn More
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -1,11 +1,4 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
Avatar,
IconBox,
IconHierarchy,
IconLayout,
IconSettingsAutomation,
} from 'twenty-sdk/ui';
import { useState } from 'react';
import {
@@ -37,7 +30,7 @@ const CATEGORIES = [
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'LOGIC FUNCTION',
label: 'SERVERLESS FUNCT.',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
@@ -66,6 +59,15 @@ const CATEGORIES = [
},
] as const;
const ItemIcon = ({ color }: { color: string }) => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2" y="2" width="5" height="5" rx="1" fill={color} />
<rect x="9" y="2" width="5" height="5" rx="1" fill={color} opacity="0.6" />
<rect x="2" y="9" width="5" height="5" rx="1" fill={color} opacity="0.6" />
<rect x="9" y="9" width="5" height="5" rx="1" fill={color} opacity="0.3" />
</svg>
);
const ArrowUpRight = ({ color = '#999' }: { color?: string }) => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
@@ -91,18 +93,6 @@ const CategoryCard = ({
}) => {
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
const CategoryIcon = () => {
if (title === 'Data model') {
return <IconHierarchy color={color} size={'20px'} />;
}
if (title === 'Logic') {
return <IconSettingsAutomation color={color} size={'20px'} />;
}
if (title === 'Layout') {
return <IconLayout color={color} size={'20px'} />;
}
};
return (
<div
style={{
@@ -121,12 +111,8 @@ const CategoryCard = ({
style={{
padding: '16px 20px',
background: `${color}22`,
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<CategoryIcon />
<span
style={{
fontSize: '16px',
@@ -168,7 +154,7 @@ const CategoryCard = ({
transition: 'background 0.15s',
}}
>
<IconBox color={color} size={'20px'} />
<ItemIcon color={color} />
<span
style={{
fontSize: '13px',
@@ -204,11 +190,13 @@ const MainPage = () => {
padding: '40px',
}}
>
<Avatar
{/*<Avatar
avatarUrl={getPublicAssetUrl('logo.svg')}
placeholder={APP_DISPLAY_NAME}
placeholderColorSeed={APP_DISPLAY_NAME}
type="squared"
size="xl"
/>
/>*/}
<span
style={{
fontSize: '24px',
@@ -232,7 +220,7 @@ const MainPage = () => {
You can now add content to your app.
</span>
<a
href="/settings/applications#installed"
href="/settings/applications"
style={{
display: 'inline-flex',
alignItems: 'center',
@@ -1 +0,0 @@
nodeLinker: node-modules
@@ -17,7 +17,7 @@ import {
DEV_API_URL,
serverStart,
} from 'twenty-sdk/cli';
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import {
getDockerInstallInstructions,
isDockerInstalled,
@@ -33,7 +33,7 @@ type CreateAppOptions = {
name?: string;
displayName?: string;
description?: string;
serverUrl?: string;
workspaceUrl?: string;
authenticationMethod?: AuthenticationMethod;
};
@@ -45,9 +45,9 @@ export class CreateAppCommand {
const { appName, appDisplayName, appDirectory, appDescription } =
this.getAppInfos(options);
const serverUrl = options.serverUrl ?? DEV_API_URL;
const workspaceUrl = options.workspaceUrl ?? DEV_API_URL;
const skipLocalInstance = serverUrl !== DEV_API_URL;
const skipLocalInstance = workspaceUrl !== DEV_API_URL;
if (!skipLocalInstance && !isDockerInstalled()) {
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
@@ -118,7 +118,7 @@ export class CreateAppCommand {
console.log('');
let authSucceeded = false;
let resolvedServerUrl = serverUrl;
let resolvedWorkspaceUrl = workspaceUrl;
let serverReady = skipLocalInstance;
if (!skipLocalInstance) {
@@ -126,7 +126,7 @@ export class CreateAppCommand {
const serverResult = await this.ensureDockerServer(dockerPullPromise);
if (isDefined(serverResult.url)) {
resolvedServerUrl = serverResult.url;
resolvedWorkspaceUrl = serverResult.url;
serverReady = true;
}
}
@@ -134,16 +134,18 @@ export class CreateAppCommand {
if (serverReady) {
this.logNextStep('Authenticating');
authSucceeded = await this.tryExistingAuth(resolvedServerUrl);
authSucceeded = await this.tryExistingAuth(resolvedWorkspaceUrl);
if (authSucceeded) {
this.logDetail('Reusing existing credentials');
} else if (authenticationMethod === 'oauth') {
this.logDetail('Starting OAuth flow');
authSucceeded = await this.authenticateWithOAuth(resolvedServerUrl);
authSucceeded =
await this.authenticateWithOAuth(resolvedWorkspaceUrl);
} else {
this.logDetail('Using development API key');
authSucceeded = await this.authenticateWithDevKey(resolvedServerUrl);
authSucceeded =
await this.authenticateWithDevKey(resolvedWorkspaceUrl);
}
}
@@ -163,10 +165,10 @@ export class CreateAppCommand {
}
if (syncSucceeded) {
await this.openMainPage(appDirectory, resolvedServerUrl);
await this.openMainPage(appDirectory, resolvedWorkspaceUrl);
}
this.logSuccess(appDirectory, resolvedServerUrl, authSucceeded);
this.logSuccess(appDirectory, resolvedWorkspaceUrl, authSucceeded);
} catch (error) {
console.error(
chalk.red('\nCreate application failed:'),
@@ -313,7 +315,7 @@ export class CreateAppCommand {
private async openMainPage(
appDirectory: string,
serverUrl: string,
workspaceUrl: string,
): Promise<void> {
try {
const configService = new ConfigService();
@@ -326,7 +328,7 @@ export class CreateAppCommand {
const [universalIdentifier, frontUrl] = await Promise.all([
this.readMainPageLayoutUniversalIdentifier(appDirectory),
this.resolveWorkspaceFrontUrl(serverUrl, token),
this.resolveWorkspaceFrontUrl(workspaceUrl, token),
]);
if (!universalIdentifier || !frontUrl) {
@@ -334,7 +336,7 @@ export class CreateAppCommand {
}
const pageLayoutId = await this.resolvePageLayoutId(
serverUrl,
workspaceUrl,
universalIdentifier,
token,
);
@@ -353,12 +355,12 @@ export class CreateAppCommand {
}
private async resolveWorkspaceFrontUrl(
serverUrl: string,
workspaceUrl: string,
token: string,
): Promise<string | null> {
const query = `{ currentWorkspace { workspaceUrls { subdomainUrl customUrl } } }`;
const response = await fetch(`${serverUrl}/metadata`, {
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -387,7 +389,7 @@ export class CreateAppCommand {
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
return frontUrl ? normalizeUrl(frontUrl) : null;
return frontUrl?.replace(/\/+$/, '') ?? null;
}
private async readMainPageLayoutUniversalIdentifier(
@@ -408,13 +410,13 @@ export class CreateAppCommand {
}
private async resolvePageLayoutId(
serverUrl: string,
workspaceUrl: string,
universalIdentifier: string,
token: string,
): Promise<string | null> {
const query = `{ getPageLayouts { id universalIdentifier } }`;
const response = await fetch(`${serverUrl}/metadata`, {
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -501,7 +503,7 @@ export class CreateAppCommand {
});
}
private async tryExistingAuth(serverUrl: string): Promise<boolean> {
private async tryExistingAuth(workspaceUrl: string): Promise<boolean> {
try {
const configService = new ConfigService();
const remoteNames = await configService.getRemotes();
@@ -509,7 +511,7 @@ export class CreateAppCommand {
for (const remoteName of remoteNames) {
const remoteConfig = await configService.getConfigForRemote(remoteName);
if (remoteConfig.apiUrl !== serverUrl) {
if (remoteConfig.apiUrl !== workspaceUrl) {
continue;
}
@@ -519,7 +521,7 @@ export class CreateAppCommand {
continue;
}
const response = await fetch(`${serverUrl}/metadata`, {
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -553,11 +555,11 @@ export class CreateAppCommand {
}
}
private async authenticateWithDevKey(serverUrl: string): Promise<boolean> {
private async authenticateWithDevKey(workspaceUrl: string): Promise<boolean> {
try {
const result = await authLogin({
apiKey: DEV_API_KEY,
apiUrl: serverUrl,
apiUrl: workspaceUrl,
remote: 'local',
});
@@ -572,7 +574,7 @@ export class CreateAppCommand {
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
' Authentication failed. Run `yarn twenty remote add --local` manually.',
),
);
@@ -580,7 +582,7 @@ export class CreateAppCommand {
} catch {
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
' Authentication failed. Run `yarn twenty remote add --local` manually.',
),
);
@@ -596,21 +598,21 @@ export class CreateAppCommand {
}
}
private async authenticateWithOAuth(serverUrl: string): Promise<boolean> {
private async authenticateWithOAuth(workspaceUrl: string): Promise<boolean> {
try {
const remoteName = this.deriveRemoteName(serverUrl);
const remoteName = this.deriveRemoteName(workspaceUrl);
ConfigService.setActiveRemote(remoteName);
this.logDetail('Opening browser for OAuth...');
const result = await authLoginOAuth({ apiUrl: serverUrl });
const result = await authLoginOAuth({ apiUrl: workspaceUrl });
if (result.success) {
const configService = new ConfigService();
await configService.setDefaultRemote(remoteName);
this.logDetail(`Authenticated via OAuth to ${serverUrl}`);
this.logDetail(`Authenticated via OAuth to ${workspaceUrl}`);
return true;
}
@@ -618,7 +620,7 @@ export class CreateAppCommand {
console.log(
chalk.yellow(
` OAuth failed: ${result.error.message}\n` +
` Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
` Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
),
);
@@ -626,7 +628,7 @@ export class CreateAppCommand {
} catch {
console.log(
chalk.yellow(
` Authentication failed. Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
` Authentication failed. Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
),
);
@@ -636,7 +638,7 @@ export class CreateAppCommand {
private logSuccess(
appDirectory: string,
serverUrl: string,
workspaceUrl: string,
authSucceeded: boolean,
): void {
const dirName = basename(appDirectory);
@@ -654,7 +656,9 @@ export class CreateAppCommand {
if (!authSucceeded) {
console.log(chalk.white(` ${stepNumber}. Connect to a Twenty instance`));
console.log(
chalk.cyan(' yarn twenty remote:add --url <your-instance-url>\n'),
chalk.cyan(
' yarn twenty remote add --api-url <your-instance-url>\n',
),
);
stepNumber++;
}
@@ -664,7 +668,7 @@ export class CreateAppCommand {
stepNumber++;
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
console.log(chalk.cyan(` ${serverUrl}\n`));
console.log(chalk.cyan(` ${workspaceUrl}\n`));
console.log(
chalk.gray(
@@ -17,7 +17,6 @@ const UNIVERSAL_IDENTIFIERS_PATH = join(
'constants',
'universal-identifiers.ts',
);
const YARNRC_PATH = 'yarnrc.yml';
// Template content matching template/src/constants/universal-identifiers.ts
const TEMPLATE_UNIVERSAL_IDENTIFIERS = `export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
@@ -174,27 +173,6 @@ describe('copyBaseApplicationProject', () => {
expect(publicDirectoryContents).toHaveLength(0);
});
it('should rename yarnrc.yml to .yarnrc.yml in the scaffolded project', async () => {
await fs.writeFile(
join(testAppDirectory, YARNRC_PATH),
'nodeLinker: node-modules',
);
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
expect(await fs.pathExists(join(testAppDirectory, YARNRC_PATH))).toBe(
false,
);
expect(await fs.pathExists(join(testAppDirectory, '.yarnrc.yml'))).toBe(
true,
);
});
it('should handle empty description', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
@@ -22,7 +22,7 @@ export const copyBaseApplicationProject = async ({
onProgress?.('Copying base template');
await fs.copy(join(__dirname, './constants/template'), appDirectory);
onProgress?.('Configuring dotfiles (.gitignore, .github, .yarnrc.yml)');
onProgress?.('Configuring dotfiles (.gitignore, .github)');
await renameDotfiles({ appDirectory });
onProgress?.('Mirroring AGENTS.md to CLAUDE.md');
@@ -47,7 +47,6 @@ const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
const renames = [
{ from: 'gitignore', to: '.gitignore' },
{ from: 'github', to: '.github' },
{ from: 'yarnrc.yml', to: '.yarnrc.yml' },
];
for (const { from, to } of renames) {
@@ -28,6 +28,6 @@ export const getDockerInstallInstructions = (): string => {
' Then run this command again.',
'',
' Alternatively, connect to an existing Twenty instance:',
' npx create-twenty-app@latest my-twenty-app --url <your-instance-url>',
' npx create-twenty-app@latest my-twenty-app --workspace-url <your-instance-url>',
].join('\n');
};
@@ -93,7 +93,7 @@ yarn install
# Register your local Twenty server as a remote (interactive prompt).
# When asked for the URL use http://localhost:2021 and paste an API key
# from Settings -> Developers in the Twenty UI.
yarn twenty remote:add
yarn twenty remote add
# Build, install, and watch for changes.
yarn twenty dev
@@ -107,8 +107,8 @@ watching `src/`. Edit any file and the change is re-synced within seconds.
```bash
cd packages/twenty-apps/community/github-connector
yarn install
yarn twenty remote:add # same prompts as above
yarn twenty app:install # builds and installs once
yarn twenty remote add # same prompts as above
yarn twenty install # builds and installs once
```
## Configure authentication
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote:add --api-url http://localhost:2020 --as local
yarn twenty remote add --api-url http://localhost:2020 --as local
```
Then, start development mode to sync your app and watch for changes:
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote:status # Check auth status
yarn twenty remote:use # Set default remote
yarn twenty remote:list # List all configured remotes
yarn twenty remote:remove <name> # Remove a remote
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Application
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty dev:function:logs # Stream function logs
yarn twenty dev:function:exec # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
```
## Integration Tests
@@ -13,7 +13,7 @@ beforeAll(async () => {
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -10,8 +10,8 @@
"packageManager": "yarn@4.9.2",
"scripts": {
"dev": "twenty dev",
"exec": "twenty dev:fn-exec",
"uninstall": "twenty app:uninstall",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote:add --api-url http://localhost:2020 --as local
yarn twenty remote add --api-url http://localhost:2020 --as local
```
Then, start development mode to sync your app and watch for changes:
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote:status # Check auth status
yarn twenty remote:use # Set default remote
yarn twenty remote:list # List all configured remotes
yarn twenty remote:remove <name> # Remove a remote
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Application
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty dev:function:logs # Stream function logs
yarn twenty dev:function:exec # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
```
## LLMs instructions
@@ -13,7 +13,7 @@ beforeAll(async () => {
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -64,15 +64,15 @@ cd packages/twenty-apps/internal/twenty-linear
# For day-to-day development (publish + install + watch in one):
yarn twenty dev
# Manual publish flow (publish registers the app, install activates it):
yarn twenty app:publish --private
yarn twenty app:install
# Manual publish flow (deploy registers the app, install activates it):
yarn twenty deploy
yarn twenty install
```
`twenty dev` is recommended for iteration — it publishes, installs, and
watches for changes in one command. Use `twenty app:publish --private` +
`twenty app:install` when you want to control each step separately (e.g.
deploying to a production server without auto-installing).
watches for changes in one command. Use `twenty deploy` + `twenty install`
when you want to control each step separately (e.g. deploying to a
production server without auto-installing).
This serves as the reference implementation for Twenty's
`defineConnectionProvider({ type: 'oauth' })` flow — useful as a template
@@ -1,6 +0,0 @@
# Credentials for integration tests (vitest.config.ts) and seed scripts
# (vitest.seed.config.ts). Copy this file to .env.local and fill in the key.
# Get an API key from the Twenty UI: Settings -> APIs & Webhooks.
# .env.local is gitignored; never commit a real key.
TWENTY_API_URL=http://localhost:2020
TWENTY_API_KEY=
@@ -1,42 +0,0 @@
name: CD
on:
push:
branches:
- main
pull_request:
types: [labeled]
permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:2020
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-and-install:
if: >-
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
- name: Install
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
@@ -1,48 +0,0 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty test instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
@@ -1,43 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
# internal planning docs
docs/superpowers/
**/docs/superpowers/
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# typescript
*.tsbuildinfo
*.d.ts
@@ -1 +0,0 @@
24.5.0
@@ -1,37 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
},
"overrides": [
{
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["twenty-shared", "twenty-shared/*"],
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
}
]
}
]
}
}
]
}
@@ -1,101 +0,0 @@
# twenty-partners
A Twenty app that turns the CRM into the operating system for the Twenty partner program:
intake partner-eligible deals, match them to vetted marketplace partners, and track the
matching pipeline end-to-end.
Built on [Twenty](https://twenty.com) with [`twenty-sdk`](https://www.npmjs.com/package/twenty-sdk) v2.5.
## What's inside
- **Custom object: `Partner`** — slug, status, availability, served geos, languages spoken,
deployment expertise, Calendly link, last-match timestamp. See `src/objects/partner.object.ts`.
- **Opportunity extensions**`matchStatus`, `designDocStatus`,
`introSentAt`, `lastRelanceSentAt`, `tftId`, plus a `partner` relation.
- **Logic functions**
- `on-opportunity-auto-match` — fires when `matchStatus` is set to `AUTO_MATCH`. Assigns the longest-idle available partner and flips status to `MATCHED`. If no partner is available, hands off to `MANUAL_MATCH` with an audit Note explaining why.
- `list-available-partners` — surfaces matchable partners for a given opportunity.
- `post-install` — first-run setup.
- **Roles** (`src/roles/`)
- **Twenty Partner Ops** — internal team role, full CRUD on Partner/Company/Person/Opportunity.
- **Partner** — placeholder external-partner role. *Do not assign until Twenty ships
row-level permissions* — it currently grants access to every record.
- **Views** (`src/views/`)
- `Waiting for match` — opportunities awaiting human action (`matchStatus` is `TO_BE_MATCHED` or `MANUAL_MATCH`).
- `Matches overview` — full matching funnel grouped by `matchStatus` (configure Kanban
grouping manually in the UI).
- `Opportunities` — replacement of the native opportunities view with the partner columns.
- `Partners` and `All matched deals` — partner-side index and deal log.
- **Sidebar nav** — surfaced in workflow order: `Waiting for match`, `All partner deals`,
`Matches overview`, `Partners`, `Opportunities`.
- **Seed scripts** (`src/scripts/`) — populate a fresh workspace with realistic demo data.
## Match status pipeline
`matchStatus` is a non-nullable SELECT field with a default of `TO_BE_MATCHED`. The 10 states follow the deal lifecycle:
| Status | Meaning |
| --- | --- |
| `TO_BE_MATCHED` | Default — deal entered, awaiting assignment |
| `MANUAL_MATCH` | Needs a human to pick a partner |
| `AUTO_MATCH` | Triggers automatic partner assignment |
| `MATCHED` | Partner assigned |
| `INTRODUCED_TO_A_PARTNER` | Customer intro sent |
| `WORKING_WITH_A_PARTNER` | Engagement underway |
| `IMPLEMENTING` | Active implementation |
| `WON` | Deal closed won |
| `RECONNECT_LATER` | Paused — reconnect in future |
| `LOST` | Deal closed lost |
## Getting started
Requires a local Twenty server at `http://localhost:2020` and Node `^24.5`.
```bash
yarn install
yarn twenty dev
```
Default dev credentials: `tim@apple.dev` / `tim@apple.dev`.
Run `yarn twenty help` for the full CLI reference.
## Common commands
| Command | What it does |
| --- | --- |
| `yarn twenty dev` | Start the dev server and sync the app on file changes |
| `yarn twenty server status` | Check the local Twenty server |
| `yarn lint` / `yarn lint:fix` | Run oxlint |
| `yarn test` | Run integration tests (`vitest.config.ts`) |
## Seeding demo data
Two idempotent seed scripts. Both run via the `vitest.seed.config.ts` config that skips
the global app uninstall/reinstall.
```bash
# 1. Marketplace partners (run first — pipeline seed wires opportunities to these by slug)
yarn vitest run --config vitest.seed.config.ts src/scripts/seed-marketplace-partners.ts
# 2. Pipeline demo: 3 companies, 3 people, 15 opportunities spread across matchStatus values
yarn vitest run --config vitest.seed.config.ts src/scripts/seed-pipeline-demo.ts
```
Both scripts skip records that already exist (by `slug`, `name`, or `firstName+lastName`),
so they are safe to re-run.
## Known limitations
Current SDK gaps blocking further polish:
- Custom Partner record page layout (RECORD_TABLE has no relation scoping).
- Native Opportunities view column-order override.
- Kanban view configuration from app code (`ViewType.KANBAN` is currently ignored).
- App and field descriptions.
## Learn more
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)
- [`twenty-sdk` on npm](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)
@@ -1,38 +0,0 @@
{
"name": "twenty-partners",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest",
"seed": "tsx src/scripts/seed.ts",
"import:dryrun": "tsx src/scripts/import-from-tft.ts",
"import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts"
},
"dependencies": {
"twenty-client-sdk": "2.4.0",
"twenty-sdk": "2.4.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"dotenv": "^16.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsx": "^4.0.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
}
}
@@ -1,74 +0,0 @@
// Seed file: ensures at least one ACTIVE + AVAILABLE partner exists before
// the matching integration tests run. Idempotent — skips if already seeded.
import { CoreApiClient } from 'twenty-client-sdk/core';
import { beforeAll, describe, it } from 'vitest';
describe('seed: marketplace partners', () => {
let client: CoreApiClient;
beforeAll(() => {
client = new CoreApiClient();
});
it('ensures at least one ACTIVE AVAILABLE partner exists', async () => {
const existingResult = await client.query({
partners: {
__args: {
filter: { validationStage: { eq: 'VALIDATED' }, availability: { eq: 'AVAILABLE' } },
first: 1,
},
edges: { node: { id: true } },
},
} as any);
const existing = (existingResult as any).partners.edges;
if (existing.length > 0) {
console.log('[seed] partner already exists, skipping');
return;
}
const PARTNERS = [
{
slug: 'nine-dots-ventures',
name: 'Nine Dots Ventures',
introduction: 'Boutique CRM implementer specialising in real-estate workflows.',
calendlyLink: 'https://calendly.com/placeholder',
deploymentExpertise: ['CLOUD', 'SELF_HOST'],
servedGeos: ['EUROPE', 'MENA'],
languagesSpoken: ['ENGLISH', 'FRENCH'],
},
{
slug: 'elevate-consulting',
name: 'Elevate Consulting',
introduction: 'Revenue-operations partner for B2B SaaS teams.',
calendlyLink: 'https://calendly.com/placeholder',
deploymentExpertise: ['CLOUD'],
servedGeos: ['US', 'LATAM'],
languagesSpoken: ['ENGLISH', 'SPANISH'],
},
];
for (const p of PARTNERS) {
const r = await client.mutation({
createPartner: {
__args: {
data: {
name: p.name,
slug: p.slug,
introduction: p.introduction,
calendarLink: { primaryLinkUrl: p.calendlyLink },
deploymentExpertise: p.deploymentExpertise,
region: p.servedGeos,
languagesSpoken: p.languagesSpoken,
validationStage: 'VALIDATED',
availability: 'AVAILABLE',
},
},
id: true,
name: true,
},
} as any);
console.log('[seed] created', (r as any).createPartner.name);
}
});
});
@@ -1,87 +0,0 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}
@@ -1,46 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CoreApiClient', () => {
it('should support CRUD on standard objects', async () => {
const client = new CoreApiClient();
const created = await client.mutation({
createNote: {
__args: { data: { title: 'Integration test note' } },
id: true,
},
});
expect(created.createNote.id).toBeDefined();
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
id: true,
},
});
});
});
@@ -1,13 +0,0 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
});
@@ -1,41 +0,0 @@
export const APP_DISPLAY_NAME = 'Twenty partners';
export const APP_DESCRIPTION = '';
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'e662fc1f-02c1-41ff-b8ba-c95a447b3965';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'ee18c3f3-ebe7-4c56-ad6d-aad555cc32db';
export const PARTNER_OBJECT_UNIVERSAL_IDENTIFIER = '39101b39-1c16-4148-9e82-45dc271bb90d';
export const PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd';
export const ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '379b11d5-44d5-476b-ba7d-31d5f515c9b4';
export const ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER = '7a34da39-a8e1-44c7-88b4-91ceaa064920';
export const PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '3fe15ab5-e38b-4914-af17-2270b210aeb2';
export const PARTNER_DEALS_NAV_UNIVERSAL_IDENTIFIER = 'c5e4ac36-bede-4f4b-bfe8-bbd09518abae';
export const ON_OPP_AUTO_MATCH_FN_UNIVERSAL_IDENTIFIER = 'eb8d4d26-8103-4b66-9026-6a86556f7ca5';
export const POST_INSTALL_FN_UNIVERSAL_IDENTIFIER = 'f92bad2e-5905-4757-96ee-af9869d4ca0c';
export const MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER = 'd8dd0623-3a4c-4ab3-a1e0-4ece7df24fb2';
export const INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER = 'fcf39b0c-0547-415e-806d-b238131ad7cc';
// Roles (Task 2)
export const TWENTY_PARTNER_OPS_ROLE_UNIVERSAL_IDENTIFIER = '3340ca65-863d-4cdc-95c9-8abdec13d0f6';
export const PARTNER_ROLE_UNIVERSAL_IDENTIFIER = 'c3c1dc2e-1a08-4de5-abb7-2139b3d99343';
// Views (Task 3)
export const WAITING_FOR_MATCH_VIEW_UNIVERSAL_IDENTIFIER = 'fe11e738-6bf3-4714-929c-51c76a3fd050';
export const MATCHES_OVERVIEW_VIEW_UNIVERSAL_IDENTIFIER = '5a8fd51a-cf9e-4a6a-b1b4-b833b215fc1c';
// Nav items (Task 5)
export const WAITING_FOR_MATCH_NAV_UNIVERSAL_IDENTIFIER = '00be7449-8927-47c8-a6a1-212d9106587f';
export const MATCHES_OVERVIEW_NAV_UNIVERSAL_IDENTIFIER = '0cf349c9-fcbf-40f8-8e91-142c02bbde9c';
// Page layout (Task 6)
export const PARTNER_RECORD_PAGE_UNIVERSAL_IDENTIFIER = 'a888b39e-d64a-48ba-a044-d8cb685fad74';
// All Opportunities view + nav
export const ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER = '6ce1300b-6e91-4c28-83bb-6f692dbc7a98';
export const ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER = '37944f52-cbe5-4814-a1e6-be5b21425870';
// Partner views + nav (harmonization)
export const PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER = 'b57a84ed-d7c1-420d-b0eb-348db0dac612';
export const VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '13cca6a7-b9f1-4103-b011-ea2e39430899';
export const PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b891-6201149510b3';
export const PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER = '13e2334a-6b1e-4080-8c74-d11109990cc1';
export const VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '6aed30c6-d80f-4ac6-aab0-db5bc59e5c4b';
export const PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
@@ -1,16 +0,0 @@
import { defineApplicationRole } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default function role`,
description: `${APP_DISPLAY_NAME} default function role`,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { OPPORTUNITIES_ON_PARTNER_FIELD_ID, PARTNER_ON_OPPORTUNITY_FIELD_ID } from './partner-on-opportunity.field';
export default defineField({
universalIdentifier: OPPORTUNITIES_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'opportunities',
label: 'Opportunities',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_ON_OPPORTUNITY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,15 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'cc6b8a59-f860-493f-8b9a-f138c078fbf1',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'designDocStatus',
label: 'Design Doc Status',
defaultValue: "'DRAFT'",
options: [
{ id: '1901c790-22af-4149-a792-09374d67acfd', value: 'DRAFT', label: 'Draft', position: 0, color: 'gray' },
{ id: '02cbe191-cc96-4b42-9d8e-f85cd47bed24', value: 'DONE', label: 'Done', position: 1, color: 'green' },
{ id: '943e1389-12cd-4605-8066-db27ba68a50a', value: 'SHARED_WITH_PARTNER', label: 'Shared with Partner', position: 2, color: 'blue' },
],
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '37e5428c-6c8c-4616-b626-f0ea1caa443d',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.LINKS,
name: 'designDocUrl',
label: 'Design Doc URL',
isNullable: true,
});
@@ -1,14 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '7ac7517f-bbca-4b4c-8996-6f864f71219b',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'hostingType',
label: 'Hosting Type',
isNullable: true,
options: [
{ id: '42c108d7-a874-4d1f-be4c-e87edd08f3c7', value: 'CLOUD', label: 'Cloud', position: 0, color: 'sky' },
{ id: '0fe995f4-42de-4160-96af-b3e7d542dfdd', value: 'SELF_HOSTING', label: 'Self-hosting', position: 1, color: 'purple' },
],
});
@@ -1,11 +0,0 @@
import { INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.DATE_TIME,
name: 'introSentAt',
label: 'Intro Sent At',
isNullable: true,
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '834e233d-b171-409e-825f-77ac49b0f19d',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'lostReason',
label: 'Lost Reason',
isNullable: true,
});
@@ -1,26 +0,0 @@
import { MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'matchStatus',
label: 'Match Status',
isNullable: false,
defaultValue: "'TO_BE_MATCHED'",
options: [
// Pre-match (new). NEW UUIDs generated with `uuidgen` (v4).
{ id: '8b3a1c0e-2f64-4a87-9d2b-1e3c4f5a6b78', value: 'TO_BE_MATCHED', label: 'To Be Matched', position: 0, color: 'grey' },
{ id: '4c5d6e7f-8a9b-4c0d-9e1f-2a3b4c5d6e7f', value: 'MANUAL_MATCH', label: 'Manual Match', position: 1, color: 'grey' },
{ id: '7e8f9a0b-1c2d-4e3f-8a4b-5c6d7e8f9a0b', value: 'AUTO_MATCH', label: 'Auto Match', position: 2, color: 'yellow' },
// Post-match. Reuse DELIVERED's UUID for MATCHED so existing rows auto-relabel.
{ id: '095428d8-4680-4a2c-af83-7809dcb3f194', value: 'MATCHED', label: 'Matched', position: 3, color: 'blue' },
{ id: '2f1c79a1-ca91-4937-a4c0-6422f6534d34', value: 'INTRODUCED_TO_A_PARTNER', label: 'Introduced to a partner', position: 4, color: 'sky' },
{ id: '45cdf6ef-8672-40d5-b71f-1e5687ba5776', value: 'WORKING_WITH_A_PARTNER', label: 'Working with a partner', position: 5, color: 'turquoise' },
{ id: '7189b18d-b0f7-435a-9272-f812cba5d13d', value: 'IMPLEMENTING', label: 'Implementing', position: 6, color: 'green' },
{ id: '54cd33bc-11ea-42f1-87c8-cd9d32d2c266', value: 'WON', label: 'Won', position: 7, color: 'purple' },
{ id: '505433e8-5367-4dfb-a89a-708d5182165b', value: 'RECONNECT_LATER', label: 'Reconnect later', position: 8, color: 'orange' },
{ id: '572a9ad2-e0a6-49f2-b1f5-e36c75cc5176', value: 'LOST', label: 'Lost', position: 9, color: 'red' },
],
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '90c683ec-2365-4533-a187-7b9ae162b753',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.NUMBER,
name: 'numberOfSeats',
label: 'Number of Seats',
isNullable: true,
});
@@ -1,14 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '59d5de53-202f-4913-a417-8a08970d87cc',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'subscriptionFrequency',
label: 'Subscription Frequency',
isNullable: true,
options: [
{ id: 'e53a9ebb-11d6-40de-93f2-6bcb6ab7141c', value: 'MONTHLY', label: 'Monthly', position: 0, color: 'blue' },
{ id: '6eca9f01-f891-4c9d-bef6-9238c8e67392', value: 'ANNUAL', label: 'Annual', position: 1, color: 'green' },
],
});
@@ -1,15 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'a58214e9-38f9-4faf-8927-09b3980fd8c3',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'subscriptionType',
label: 'Subscription Type',
isNullable: true,
options: [
{ id: '6d91b477-5bf1-4f5c-8aef-577b6c21fe45', value: 'PRO', label: 'Pro', position: 0, color: 'blue' },
{ id: '7b64f281-3445-4429-a5f0-af9484dff8b4', value: 'ORG', label: 'Org', position: 1, color: 'green' },
{ id: 'bf202f8f-caf9-45bf-a80c-65b344b2798d', value: 'ENT', label: 'Enterprise', position: 2, color: 'purple' },
],
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '2e3e1d04-2719-4e0d-9a6b-ec73acf896c5',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'tftOpportunityId',
label: 'TfT Opportunity ID',
isNullable: true,
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: '1bc57f52-a621-4243-ae3e-05c3f504b90c',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'useCase',
label: 'Use Case',
isNullable: true,
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_COMPANY_FIELD_ID = '2779015b-28fa-4117-8ce1-b0c98cf16de2';
export const PARTNERS_ON_COMPANY_FIELD_ID = '2896d888-a4ab-4c29-bf63-e8bfdbd1924f';
export default defineField({
universalIdentifier: PARTNER_COMPANY_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'company',
label: 'Company',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNERS_ON_COMPANY_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'companyId',
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_ON_OPPORTUNITY_FIELD_ID = 'd9eeacaa-2f9e-44cc-b5f6-5e1526256d49';
export const OPPORTUNITIES_ON_PARTNER_FIELD_ID = '8c04a5d4-c423-487e-bd78-7142a75b2896';
export default defineField({
universalIdentifier: PARTNER_ON_OPPORTUNITY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: OPPORTUNITIES_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_ON_PERSON_FIELD_ID = 'b49eeaa3-c7ef-4a5c-8c47-d2c234b5122f';
export const PERSONS_ON_PARTNER_FIELD_ID = '2c0e2035-db52-434b-9706-cd2210009a86';
export default defineField({
universalIdentifier: PARTNER_ON_PERSON_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PERSONS_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});
@@ -1,22 +0,0 @@
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',
},
});
@@ -1,22 +0,0 @@
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',
},
});
@@ -1,18 +0,0 @@
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,
},
});
@@ -1,18 +0,0 @@
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,18 +0,0 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNERS_ON_COMPANY_FIELD_ID, PARTNER_COMPANY_FIELD_ID } from './partner-company.field';
export default defineField({
universalIdentifier: PARTNERS_ON_COMPANY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.RELATION,
name: 'partners',
label: 'Partners',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_COMPANY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,10 +0,0 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'c6862035-bf2e-42ea-86c4-bf46636f7859',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.TEXT,
name: 'discord',
label: 'Discord',
isNullable: true,
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_ON_PERSON_FIELD_ID, PERSONS_ON_PARTNER_FIELD_ID } from './partner-on-person.field';
export default defineField({
universalIdentifier: PERSONS_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'persons',
label: 'Persons',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_ON_PERSON_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,220 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// Helpers
async function createOpp(client: CoreApiClient, name: string) {
const r = await client.mutation({
createOpportunity: {
__args: { data: { name } },
id: true,
matchStatus: true,
},
} as any);
return (r as any).createOpportunity as { id: string; matchStatus: string };
}
async function destroyOpp(client: CoreApiClient, id: string) {
await client
.mutation({ destroyOpportunity: { __args: { id }, id: true } } as any)
.catch(() => {});
}
async function getOpp(client: CoreApiClient, id: string) {
const r = await client.query({
opportunity: {
__args: { filter: { id: { eq: id } } },
id: true,
matchStatus: true,
partner: { id: true },
},
} as any);
return (r as any).opportunity as {
id: string;
matchStatus: string;
partner: { id: string } | null;
};
}
async function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
describe('on-opportunity-auto-match (success path)', () => {
let client: CoreApiClient;
const created: string[] = [];
let seededPartnerId: string | null = null;
beforeAll(async () => {
client = new CoreApiClient();
// Ensure at least one ACTIVE+AVAILABLE partner exists for the function to pick.
const existing = await client.query({
partners: {
__args: {
filter: { validationStage: { eq: 'VALIDATED' }, availability: { eq: 'AVAILABLE' } },
first: 1,
},
edges: { node: { id: true } },
},
} as any);
if ((existing as any).partners.edges.length === 0) {
const r = await client.mutation({
createPartner: {
__args: {
data: {
name: '[test] auto-match seed partner',
slug: 'test-auto-match-seed',
validationStage: 'VALIDATED',
availability: 'AVAILABLE',
},
},
id: true,
},
} as any);
seededPartnerId = (r as any).createPartner.id;
}
});
afterAll(async () => {
if (seededPartnerId) {
await client
.mutation({ destroyPartner: { __args: { id: seededPartnerId }, id: true } } as any)
.catch(() => {});
}
});
beforeEach(() => {
client = new CoreApiClient();
});
afterEach(async () => {
for (const id of created) await destroyOpp(client, id);
created.length = 0;
});
it('defaults a new opportunity to TO_BE_MATCHED', async () => {
const opp = await createOpp(client, `[test] default status ${Date.now()}`);
created.push(opp.id);
expect(opp.matchStatus).toBe('TO_BE_MATCHED');
});
it('assigns a partner and flips to MATCHED when set to AUTO_MATCH', async () => {
const opp = await createOpp(client, `[test] auto match ${Date.now()}`);
created.push(opp.id);
await client.mutation({
updateOpportunity: {
__args: { id: opp.id, data: { matchStatus: 'AUTO_MATCH' } },
id: true,
},
} as any);
// Logic function runs async (~2s per spec). Poll up to 10s.
let final = await getOpp(client, opp.id);
for (let i = 0; i < 20 && final.matchStatus === 'AUTO_MATCH'; i++) {
await sleep(500);
final = await getOpp(client, opp.id);
}
expect(final.matchStatus).toBe('MATCHED');
expect(final.partner?.id).toBeDefined();
});
});
describe('on-opportunity-auto-match (failure path)', () => {
let client: CoreApiClient;
const createdOpps: string[] = [];
const flippedPartners: Array<{ id: string; prevAvailability: string }> = [];
beforeEach(() => {
client = new CoreApiClient();
});
afterEach(async () => {
// Restore partner availabilities first so other tests find a partner.
for (const p of flippedPartners) {
await client
.mutation({
updatePartner: {
__args: { id: p.id, data: { availability: p.prevAvailability } },
id: true,
},
} as any)
.catch(() => {});
}
flippedPartners.length = 0;
for (const id of createdOpps) await destroyOpp(client, id);
createdOpps.length = 0;
});
it('hands off to MANUAL_MATCH with a Note when no partner is available', async () => {
// Make every AVAILABLE partner UNAVAILABLE for the duration of this test.
const all = await client.query({
partners: {
__args: {
filter: { availability: { eq: 'AVAILABLE' } },
first: 100,
},
edges: { node: { id: true, availability: true } },
},
} as any);
const edges = ((all as any)?.partners?.edges ?? []) as Array<{
node: { id: string; availability: string };
}>;
for (const e of edges) {
flippedPartners.push({ id: e.node.id, prevAvailability: e.node.availability });
await client.mutation({
updatePartner: {
__args: { id: e.node.id, data: { availability: 'UNAVAILABLE' } },
id: true,
},
} as any);
}
const opp = await createOpp(client, `[test] no-partner ${Date.now()}`);
createdOpps.push(opp.id);
await client.mutation({
updateOpportunity: {
__args: { id: opp.id, data: { matchStatus: 'AUTO_MATCH' } },
id: true,
},
} as any);
let final = await getOpp(client, opp.id);
for (let i = 0; i < 20 && final.matchStatus === 'AUTO_MATCH'; i++) {
await sleep(500);
final = await getOpp(client, opp.id);
}
expect(final.matchStatus).toBe('MANUAL_MATCH');
expect(final.partner).toBeNull();
// Confirm a Note was attached.
const notes = await client.query({
noteTargets: {
__args: {
filter: { targetOpportunityId: { eq: opp.id } },
first: 10,
},
edges: {
node: {
id: true,
note: { id: true, title: true, bodyV2: { markdown: true } },
},
},
},
} as any);
const noteEdges = ((notes as any)?.noteTargets?.edges ?? []) as Array<{
node: { note: { title: string; bodyV2: { markdown: string } } };
}>;
const autoMatchNote = noteEdges.find((e) =>
e.node.note.title.toLowerCase().includes('auto-match'),
);
expect(autoMatchNote).toBeDefined();
expect(autoMatchNote!.node.note.bodyV2.markdown).toContain('No partners');
});
});
@@ -1,75 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
export const LIST_AVAILABLE_PARTNERS_LOGIC_FUNCTION_ID =
'0f91164f-f492-41e8-9bb0-481be5a3d5b9';
type Partner = {
id: string;
name: string | null;
slug: string | null;
introduction: string | null;
languagesSpoken: string[] | null;
deploymentExpertise: string[] | null;
region: string[] | null;
calendarLink: { primaryLinkUrl: string | null } | null;
};
type ListAvailablePartnersResult =
| { ok: true; count: number; partners: Partner[] }
| { ok: false; reason: string };
const handler = async (): Promise<ListAvailablePartnersResult> => {
try {
const client = new CoreApiClient();
const result = await client.query({
partners: {
__args: {
filter: {
validationStage: { eq: 'VALIDATED' },
availability: { eq: 'AVAILABLE' },
},
orderBy: [{ name: 'AscNullsLast' }],
first: 100,
},
edges: {
node: {
id: true,
name: true,
slug: true,
introduction: true,
languagesSpoken: true,
deploymentExpertise: true,
region: true,
calendarLink: { primaryLinkUrl: true },
},
},
},
} as any);
const partners = (
(result?.partners?.edges ?? []) as Array<{ node: Partner }>
).map((edge) => edge.node);
return { ok: true, count: partners.length, partners };
} catch (err) {
return {
ok: false,
reason: err instanceof Error ? err.message : String(err),
};
}
};
export default defineLogicFunction({
universalIdentifier: LIST_AVAILABLE_PARTNERS_LOGIC_FUNCTION_ID,
name: 'list-available-partners',
description: 'Returns all partners with validationStage=VALIDATED and availability=AVAILABLE.',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: '/partners',
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -1,105 +0,0 @@
import { DatabaseEventPayload, defineLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { ON_OPP_AUTO_MATCH_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
// Fires when an opportunity's matchStatus is set to 'AUTO_MATCH'.
// Happy path: picks the longest-idle ACTIVE+AVAILABLE partner, assigns it, flips to 'MATCHED'.
// Failure path: no partner available → flips to 'MANUAL_MATCH' and creates an audit Note.
const handler = async (payload: DatabaseEventPayload) => {
const props = payload.properties as {
after?: { id: string; matchStatus?: string; partnerId?: string | null };
before?: { matchStatus?: string };
updatedFields?: string[];
};
if (!props.updatedFields?.includes('matchStatus')) return {};
if (props.after?.matchStatus !== 'AUTO_MATCH') return {};
if (props.before?.matchStatus === 'AUTO_MATCH') return {};
if (props.after.partnerId) return {};
const client = new CoreApiClient();
const partnersResult = await client.query({
partners: {
__args: {
filter: {
validationStage: { eq: 'VALIDATED' },
availability: { eq: 'AVAILABLE' },
},
orderBy: [{ lastMatchAt: 'AscNullsFirst' }],
first: 1,
},
edges: { node: { id: true, lastMatchAt: true } },
},
} as any);
const topPartner = (partnersResult.partners as any).edges[0]?.node;
if (!topPartner) {
const noteBody =
`Auto-match attempted ${new Date().toISOString()}.\n` +
`No partners matched (status=ACTIVE, availability=AVAILABLE).\n` +
`Status moved to Manual Match — pick a partner manually or ` +
`update partner availability and retry by setting status back to Auto Match.`;
try {
const noteResult = await client.mutation({
createNote: {
__args: { data: { title: 'Auto-match failed', bodyV2: { markdown: noteBody } } },
id: true,
},
} as any);
const noteId = (noteResult as any).createNote.id as string;
await client.mutation({
createNoteTarget: {
__args: { data: { noteId, targetOpportunityId: props.after.id } },
id: true,
},
} as any);
} catch {
// Note creation is best-effort; status flip is the critical action.
}
await client.mutation({
updateOpportunity: {
__args: { id: props.after.id, data: { matchStatus: 'MANUAL_MATCH' } },
id: true,
},
} as any);
return { matched: false, reason: 'no_partner_available' };
}
await client.mutation({
updateOpportunity: {
__args: {
id: props.after.id,
data: { partnerId: topPartner.id, matchStatus: 'MATCHED' },
},
id: true,
},
} as any);
await client.mutation({
updatePartner: {
__args: {
id: topPartner.id,
data: { lastMatchAt: new Date().toISOString() },
},
id: true,
},
} as any);
return { matched: true, partnerId: topPartner.id };
};
export default defineLogicFunction({
universalIdentifier: ON_OPP_AUTO_MATCH_FN_UNIVERSAL_IDENTIFIER,
name: 'on-opportunity-auto-match',
timeoutSeconds: 10,
handler,
databaseEventTriggerSettings: {
eventName: 'opportunity.updated',
},
});
@@ -1,48 +0,0 @@
import { InstallPayload, definePostInstallLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (_payload: InstallPayload) => {
const client = new CoreApiClient();
const partnerResult = await client.mutation({
createPartner: {
__args: {
data: {
name: 'Test Partner Alpha',
validationStage: 'VALIDATED',
availability: 'AVAILABLE',
languagesSpoken: ['ENGLISH', 'FRENCH'],
deploymentExpertise: ['CLOUD', 'SELF_HOST'],
region: 'EUROPE',
},
},
id: true,
},
} as any);
const partnerId = (partnerResult.createPartner as any).id;
await client.mutation({
createPerson: {
__args: {
data: {
name: {
firstName: 'Test Partner',
lastName: 'Contact',
},
partnerId,
},
},
id: true,
},
} as any);
return { seeded: true, partnerId };
};
export default definePostInstallLogicFunction({
universalIdentifier: 'f92bad2e-5905-4757-96ee-af9869d4ca0c',
name: 'post-install',
handler,
shouldRunSynchronously: true,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER,
ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconTargetArrow',
position: 3,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
MATCHES_OVERVIEW_NAV_UNIVERSAL_IDENTIFIER,
MATCHES_OVERVIEW_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: MATCHES_OVERVIEW_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconLayoutKanban',
position: 1,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: MATCHES_OVERVIEW_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER,
PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconUserPlus',
position: 1,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_DEALS_NAV_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: PARTNER_DEALS_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconHandshake',
position: 2,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER,
PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconFileDollar',
position: 3,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,10 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
type: NavigationMenuItemType.FOLDER,
name: 'Partners',
icon: 'IconBuildingStore',
color: 'purple',
position: -1,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
PARTNERS_NAV_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: PARTNERS_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconBuildingStore',
position: 0,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,9 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
type: NavigationMenuItemType.FOLDER,
name: 'Pipeline',
icon: 'IconTargetArrow',
position: -2,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER,
VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconCircleCheck',
position: 2,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +0,0 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
WAITING_FOR_MATCH_NAV_UNIVERSAL_IDENTIFIER,
WAITING_FOR_MATCH_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: WAITING_FOR_MATCH_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconClockHour4',
position: 0,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: WAITING_FOR_MATCH_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,65 +0,0 @@
import { FieldType, defineObject } from 'twenty-sdk/define';
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineObject({
universalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'partnerQuote',
namePlural: 'partnerQuotes',
labelSingular: 'Partner Quote',
labelPlural: 'Partner Quotes',
description: 'A quote a partner submitted for a customer deal',
icon: 'IconFileDollar',
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6',
fields: [
{
universalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6',
type: FieldType.TEXT,
name: 'name',
label: 'Name',
icon: 'IconTag',
defaultValue: "''",
},
{
universalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a',
type: FieldType.SELECT,
name: 'status',
label: 'Status',
icon: 'IconProgressCheck',
defaultValue: "'WIP'",
options: [
{ id: '5d41450b-8efb-41e8-81b7-b534429ec1b4', value: 'WIP', label: 'WIP', position: 0, color: 'gray' },
{ id: '6611ae9a-4253-4638-9c47-d8bf2ec54368', value: 'INTERVIEW_SCHEDULED', label: 'Interview scheduled', position: 1, color: 'blue' },
{ id: 'ae3d91e4-96ea-4687-b71f-d2d80088f28a', value: 'UNDER_CUSTOMER_PARTNER_REVIEW', label: 'Under review', position: 2, color: 'orange' },
{ id: '91fe48a3-7950-4cdd-8c52-8d9b2cc03f0b', value: 'APPROVED', label: 'Approved', position: 3, color: 'green' },
{ id: 'fa640b71-862e-4c64-80fa-37ab8b50d254', value: 'REJECTED', label: 'Rejected', position: 4, color: 'red' },
],
},
{
universalIdentifier: 'b52d263e-423e-40b0-b82c-29214597c005',
type: FieldType.DATE_TIME,
name: 'approvalDate',
label: 'Approval Date',
icon: 'IconCalendarCheck',
isNullable: true,
},
{
universalIdentifier: '2cbe67e3-24ec-421b-bab5-50f3306c2391',
type: FieldType.CURRENCY,
name: 'amount',
label: 'Amount',
icon: 'IconCoin',
isNullable: true,
},
{
universalIdentifier: 'f303369e-288c-4a48-9920-c1de0ad9a159',
type: FieldType.FILES,
name: 'quoteFile',
label: 'Quote File',
icon: 'IconPaperclip',
isNullable: true,
universalSettings: { maxNumberOfValues: 1 },
},
],
});
@@ -1,483 +0,0 @@
import { FieldType, defineObject } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineObject({
universalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'partner',
namePlural: 'partners',
labelSingular: 'Partner',
labelPlural: 'Partners',
description: 'A certified implementation partner',
icon: 'IconBuildingStore',
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier: 'a0000001-0000-4000-8000-000000000001',
fields: [
{
universalIdentifier: 'a0000001-0000-4000-8000-000000000001',
type: FieldType.TEXT,
name: 'name',
label: 'Name',
icon: 'IconTag',
defaultValue: "''",
},
{
universalIdentifier: 'a0000002-0000-4000-8000-000000000002',
type: FieldType.TEXT,
name: 'slug',
label: 'Slug',
icon: 'IconLink',
isNullable: true,
},
{
universalIdentifier: '2ca9856f-f54a-4326-9ff3-668fd7da0b50',
type: FieldType.SELECT,
name: 'validationStage',
label: 'Validation Stage',
icon: 'IconProgressCheck',
defaultValue: "'APPLICATION'",
options: [
{ id: '1b4f8b0b-68ec-4c02-a7e6-fbd0cda2f3b9', value: 'APPLICATION', label: 'Application', position: 0, color: 'gray' },
{ id: '901181af-b2cb-4052-9ac8-3c84281671f8', value: 'POTENTIAL', label: 'Potential partner', position: 1, color: 'blue' },
{ id: '3ebbc425-e5e3-412d-93df-5f70fefa1798', value: 'VALIDATED', label: 'Validated partner', position: 2, color: 'green' },
{ id: '3d0ec027-0d51-4df7-932e-13c60d4b4382', value: 'FORMER', label: 'Former partner', position: 3, color: 'orange' },
{ id: '19dc4929-735a-4b64-8f57-f34f3eaa3a73', value: 'REJECTED', label: 'Rejected', position: 4, color: 'red' },
],
},
{
universalIdentifier: '5af4e57e-7fa7-4c4f-b40f-37549361459a',
type: FieldType.BOOLEAN,
name: 'reviewed',
label: 'Reviewed',
icon: 'IconChecks',
isNullable: true,
},
{
universalIdentifier: '5412e4ca-cc96-4be8-8652-b73dace7673b',
type: FieldType.RATING,
name: 'ranking',
label: 'Ranking',
icon: 'IconStar',
isNullable: true,
options: [
{ id: 'e4c784f0-7e2b-4eca-94dc-fc447266f252', value: 'RATING_1', label: '1', position: 0 },
{ id: '79d033bd-4189-4f69-a9d1-a54d7cbbc5bc', value: 'RATING_2', label: '2', position: 1 },
{ id: '1206bdcc-a804-4649-a3ce-bd001a3abc2c', value: 'RATING_3', label: '3', position: 2 },
{ id: '1c195738-d823-415e-b4db-44a6cdc09ec5', value: 'RATING_4', label: '4', position: 3 },
{ id: '92cd3a72-8d5b-4077-a369-ddfe429aa2aa', value: 'RATING_5', label: '5', position: 4 },
],
},
{
universalIdentifier: 'd4fa6461-37b6-49ee-9181-dd482e74a70b',
type: FieldType.SELECT,
name: 'partnerTier',
label: 'Partner Tier',
icon: 'IconMedal',
isNullable: true,
options: [
{ id: '63806cc0-02a8-4fe9-b2ba-0efe43a33109', value: 'NEW', label: 'New', position: 0, color: 'gray' },
{ id: '1ea6f5ba-6a4a-4e64-9b56-11e9ae56c97e', value: 'INTERMEDIATE', label: 'Intermediate', position: 1, color: 'blue' },
{ id: 'b44de8b4-caea-4ca6-94cd-fb67221b5fdb', value: 'ADVANCED', label: 'Advanced', position: 2, color: 'green' },
],
},
{
universalIdentifier: '500021ad-ca42-4fd3-8727-392dd26b722a',
type: FieldType.MULTI_SELECT,
name: 'partnerScope',
label: 'Partner Scope',
icon: 'IconListCheck',
isNullable: true,
options: [
{ id: '00f05e1c-0fd9-4214-a461-554b7c9e7eb5', value: 'APPS', label: 'Apps', position: 0, color: 'blue' },
{ id: 'e0f789d5-a3bc-4ecb-8bc2-5aa018468d83', value: 'DATA_MODEL', label: 'Data model', position: 1, color: 'green' },
{ id: 'c88bf189-8be4-4431-aa03-85928f8b2a52', value: 'DATA_MIGRATION', label: 'Data migration', position: 2, color: 'turquoise' },
{ id: 'a7fd9429-c26f-49ab-bf52-4d591f5ca7a0', value: 'HOSTING_ENVIRONMENT', label: 'Hosting environment', position: 3, color: 'purple' },
{ id: '9e416a39-05c6-4c80-9f36-99b5b60c26ec', value: 'WORKFLOWS', label: 'Workflows', position: 4, color: 'orange' },
],
},
{
universalIdentifier: 'a451e557-a488-470a-8b35-6f9b8cfb1a10',
type: FieldType.SELECT,
name: 'typeOfTeam',
label: 'Type of Team',
icon: 'IconUsersGroup',
isNullable: true,
options: [
{ id: '0f59b781-5a81-4025-92ad-4b93b2c62df0', value: 'SOLO', label: 'Solo', position: 0, color: 'blue' },
{ id: 'db47bc44-93db-4cbf-8d0f-3057cf01d11e', value: 'AGENCY', label: 'Agency', position: 1, color: 'green' },
],
},
{
universalIdentifier: '6f260cc0-a860-41e6-ad40-a2ef32ecffbe',
type: FieldType.ARRAY,
name: 'skills',
label: 'Skills',
icon: 'IconTags',
isNullable: true,
},
{
universalIdentifier: 'a0000004-0000-4000-8000-000000000004',
type: FieldType.SELECT,
name: 'availability',
label: 'Availability',
icon: 'IconCalendarCheck',
defaultValue: "'AVAILABLE'",
options: [
{ id: '639d74c3-5d38-407b-b0e4-81f3930db451', value: 'AVAILABLE', label: 'Available', position: 0, color: 'green' },
{ id: '6a921479-559a-4446-892a-d4a1b5a3abb4', value: 'UNAVAILABLE', label: 'Unavailable', position: 1, color: 'gray' },
],
},
{
universalIdentifier: 'a0000005-0000-4000-8000-000000000005',
type: FieldType.MULTI_SELECT,
name: 'deploymentExpertise',
label: 'Deployment Expertise',
icon: 'IconCloud',
isNullable: true,
options: [
{ id: 'f2f53365-d909-4a97-bb81-efab43f0a17e', value: 'CLOUD', label: 'Cloud', position: 0, color: 'sky' },
{ id: '5e47226b-4e60-413f-80ee-b8f1d9ee6ad6', value: 'SELF_HOST', label: 'Self-host', position: 1, color: 'purple' },
],
},
{
universalIdentifier: 'a0000007-0000-4000-8000-000000000007',
type: FieldType.MULTI_SELECT,
name: 'languagesSpoken',
label: 'Languages Spoken',
icon: 'IconLanguage',
isNullable: true,
options: [
{ id: '3fe94a92-5f64-46f4-8697-1a44e06e939f', value: 'ENGLISH', label: 'English', position: 0, color: 'blue' },
{ id: 'fdfe8adf-b2f1-4ef9-97c1-449302932698', value: 'FRENCH', label: 'French', position: 1, color: 'red' },
{ id: '820c120a-17ba-4e9a-8dd7-9724ac41d7ca', value: 'GERMAN', label: 'German', position: 2, color: 'yellow' },
{ id: '28171507-753d-4a8e-b9c2-44c698c2a26b', value: 'CHINESE', label: 'Chinese', position: 3, color: 'orange' },
{ id: 'd9111720-26c1-47c3-85bb-a30699737cce', value: 'SPANISH', label: 'Spanish', position: 4, color: 'green' },
{ id: '7dbfcb38-4255-4fe8-91dc-ce0686191521', value: 'ARABIC', label: 'Arabic', position: 5, color: 'sky' },
{ id: 'd4e0e54b-2c57-498a-9ccc-9cfe64f09a36', value: 'BENGALI', label: 'Bengali', position: 6, color: 'purple' },
{ id: '34e765d2-5b03-4a63-a6e6-6c3c75fd8d75', value: 'CATALAN', label: 'Catalan', position: 7, color: 'pink' },
{ id: 'b042b1d3-19d6-4094-b53e-85f9727360b1', value: 'CZECH', label: 'Czech', position: 8, color: 'turquoise' },
{ id: '0a8ebe74-dcfa-44f5-abde-1af3a8dd8879', value: 'DANISH', label: 'Danish', position: 9, color: 'gray' },
{ id: '385e0db5-ab16-4a59-94fd-98921fa367d5', value: 'DUTCH', label: 'Dutch', position: 10, color: 'blue' },
{ id: 'f8ec118f-6652-4fe4-a7dc-a77c079f473e', value: 'FARSI', label: 'Farsi', position: 11, color: 'red' },
{ id: 'cd58db19-6be6-4deb-8f1b-4cd9bdbcacdf', value: 'FINNISH', label: 'Finnish', position: 12, color: 'yellow' },
{ id: 'c20be129-8a93-479c-99f1-89dad2177c0a', value: 'GREEK', label: 'Greek', position: 13, color: 'orange' },
{ id: '7faaec05-22d1-4b37-a700-9dc4733effb6', value: 'HINDI', label: 'Hindi', position: 14, color: 'green' },
{ id: '3400d7db-312f-4d54-ac19-04a2f6fed6e0', value: 'INDONESIAN', label: 'Indonesian', position: 15, color: 'sky' },
{ id: 'd1eb3870-2938-4c87-9596-3980b31df293', value: 'ITALIAN', label: 'Italian', position: 16, color: 'purple' },
{ id: '7a854dd9-6c81-4d16-8b76-79b3571638ec', value: 'JAPANESE', label: 'Japanese', position: 17, color: 'pink' },
{ id: '51be3b14-195c-45e2-815e-a6591d0946b1', value: 'KOREAN', label: 'Korean', position: 18, color: 'turquoise' },
{ id: 'c31168a0-276c-45dc-bd39-a498d39a44d8', value: 'MALAY', label: 'Malay', position: 19, color: 'gray' },
{ id: '7719d197-9e91-41d3-a66f-728cfce32c33', value: 'NORWEGIAN', label: 'Norwegian', position: 20, color: 'blue' },
{ id: '115c5e11-2462-4371-92ca-c4835649de15', value: 'POLISH', label: 'Polish', position: 21, color: 'red' },
{ id: '018e3663-3de8-4110-aceb-48c8083ebb9a', value: 'PORTUGUESE', label: 'Portuguese', position: 22, color: 'yellow' },
{ id: 'ca97987f-4f43-43d0-b7a0-490bc566b42f', value: 'PUNJABI', label: 'Punjabi', position: 23, color: 'orange' },
{ id: '05215653-4d32-49e2-9ff3-73e66e14d97d', value: 'ROMANIAN', label: 'Romanian', position: 24, color: 'green' },
{ id: 'a14e341e-a266-407f-b4c7-936cfa608500', value: 'RUSSIAN', label: 'Russian', position: 25, color: 'sky' },
{ id: '1190a9eb-bd06-4d77-ab59-c619f4e2faa3', value: 'SWAHILI', label: 'Swahili', position: 26, color: 'purple' },
{ id: '0fedffb0-c0d7-47b7-ac5f-0b166d24d670', value: 'SWEDISH', label: 'Swedish', position: 27, color: 'pink' },
{ id: '8205bc44-b2a0-4211-9fe5-1bc64efaa844', value: 'TAGALOG', label: 'Tagalog', position: 28, color: 'turquoise' },
{ id: '866b6438-c172-4c72-b69e-b1ca89342526', value: 'TAMIL', label: 'Tamil', position: 29, color: 'gray' },
{ id: 'a2c4851b-302b-4420-aff2-6d7dc614fa6c', value: 'THAI', label: 'Thai', position: 30, color: 'blue' },
{ id: '0a5672dd-ebe5-48bc-b8ab-7d2bf6a605e2', value: 'TURKISH', label: 'Turkish', position: 31, color: 'red' },
{ id: '36324fb0-5ea2-4e9f-845c-ac65b977ab20', value: 'UKRAINIAN', label: 'Ukrainian', position: 32, color: 'yellow' },
{ id: 'b1aad243-fc85-4df3-a83e-07796ae01d6c', value: 'URDU', label: 'Urdu', position: 33, color: 'orange' },
{ id: '922895f8-3a8a-45b8-a70e-0ae7b0c8ff32', value: 'VIETNAMESE', label: 'Vietnamese', position: 34, color: 'green' },
],
},
{
universalIdentifier: '0a1ce916-4df5-469a-b793-30f19e45b38d',
type: FieldType.TEXT,
name: 'city',
label: 'City',
icon: 'IconMapPin',
isNullable: true,
},
{
universalIdentifier: 'a77d7fa6-c398-47db-af0f-036a5c719f20',
type: FieldType.SELECT,
name: 'country',
label: 'Country',
icon: 'IconFlag',
isNullable: true,
options: [
{ id: '03332728-50b1-49ec-bdec-a938cb97ca74', value: 'AFGHANISTAN', label: 'Afghanistan 🇦🇫', position: 0, color: 'blue' },
{ id: 'c39c7baa-6f52-4349-bc28-97b59f2a9a3a', value: 'ALBANIA', label: 'Albania 🇦🇱', position: 1, color: 'turquoise' },
{ id: '1bce0427-d693-451a-9a6b-a50c0c502c85', value: 'ALGERIA', label: 'Algeria 🇩🇿', position: 2, color: 'green' },
{ id: '634fb580-4f12-4a5b-b43c-5787330d4b60', value: 'ANDORRA', label: 'Andorra 🇦🇩', position: 3, color: 'sky' },
{ id: '545797b9-16df-40d5-ab9b-037d3d875263', value: 'ANGOLA', label: 'Angola 🇦🇴', position: 4, color: 'purple' },
{ id: '83b58c61-8b0d-42e1-9d4f-9a530ace7899', value: 'ANTIGUA_AND_BARBUDA', label: 'Antigua & Barbuda 🇦🇬', position: 5, color: 'orange' },
{ id: '58fd4e0d-b48d-4b22-81d8-de6db6bd22ed', value: 'ARGENTINA', label: 'Argentina 🇦🇷', position: 6, color: 'yellow' },
{ id: '7091a2e3-2095-4693-bd53-366df4ba5c75', value: 'ARMENIA', label: 'Armenia 🇦🇲', position: 7, color: 'red' },
{ id: '479725e7-f185-46c6-9e8b-605fbbb392f8', value: 'AUSTRALIA', label: 'Australia 🇦🇺', position: 8, color: 'pink' },
{ id: 'b7993e1b-5f7d-47b9-a84d-41795160393a', value: 'AUSTRIA', label: 'Austria 🇦🇹', position: 9, color: 'gray' },
{ id: 'e2d324df-2baa-49a2-a016-2c7cab0d0e95', value: 'AZERBAIJAN', label: 'Azerbaijan 🇦🇿', position: 10, color: 'blue' },
{ id: '87dc4b54-c4f7-4813-a0e4-d2214e659a77', value: 'BAHAMAS', label: 'Bahamas 🇧🇸', position: 11, color: 'turquoise' },
{ id: '32149c4f-3319-42a6-a0ca-6afb42bdf3b9', value: 'BAHRAIN', label: 'Bahrain 🇧🇭', position: 12, color: 'green' },
{ id: 'c74d1f90-e17f-4e9a-9341-5c6ffda2d3ac', value: 'BANGLADESH', label: 'Bangladesh 🇧🇩', position: 13, color: 'sky' },
{ id: '61c2e8c4-06af-4daa-ac52-34f4b47e2aad', value: 'BARBADOS', label: 'Barbados 🇧🇧', position: 14, color: 'purple' },
{ id: '1c7dfa46-72e0-4ba3-8af5-3b2d1d46fd4b', value: 'BELARUS', label: 'Belarus 🇧🇾', position: 15, color: 'orange' },
{ id: '771258e5-b16f-47d4-aaed-47a4c931b72d', value: 'BELGIUM', label: 'Belgium 🇧🇪', position: 16, color: 'yellow' },
{ id: 'f3910b68-3f3f-4afb-9f90-5561376e5098', value: 'BELIZE', label: 'Belize 🇧🇿', position: 17, color: 'red' },
{ id: 'b06f1bc7-2058-44fc-b868-631559de7d11', value: 'BENIN', label: 'Benin 🇧🇯', position: 18, color: 'pink' },
{ id: 'acff20b8-b865-47de-9049-c660866f5218', value: 'BHUTAN', label: 'Bhutan 🇧🇹', position: 19, color: 'gray' },
{ id: 'bee20647-c283-40af-9733-37753e42b464', value: 'BOLIVIA', label: 'Bolivia 🇧🇴', position: 20, color: 'blue' },
{ id: '4dd91769-c713-47af-bf48-aed0251115b1', value: 'BOSNIA_AND_HERZEGOVINA', label: 'Bosnia & Herzegovina 🇧🇦', position: 21, color: 'turquoise' },
{ id: 'a6997427-007f-42b4-b6ea-892f7ce58dff', value: 'BOTSWANA', label: 'Botswana 🇧🇼', position: 22, color: 'green' },
{ id: '3ff1f170-d3f0-4ce6-9490-bfc20aadf470', value: 'BRAZIL', label: 'Brazil 🇧🇷', position: 23, color: 'sky' },
{ id: '7b2d6911-4676-433d-93f1-6eca2530d89d', value: 'BRUNEI', label: 'Brunei 🇧🇳', position: 24, color: 'purple' },
{ id: '767c72f3-f951-4b00-958d-856d2a8bda7a', value: 'BULGARIA', label: 'Bulgaria 🇧🇬', position: 25, color: 'orange' },
{ id: 'e16ce6af-86b0-4448-8008-c5efa2ce3fe9', value: 'BURKINA_FASO', label: 'Burkina Faso 🇧🇫', position: 26, color: 'yellow' },
{ id: 'dd7e7b2a-8367-482d-99d4-7877d883c2e3', value: 'BURUNDI', label: 'Burundi 🇧🇮', position: 27, color: 'red' },
{ id: 'd9d6eea9-7724-4c65-be48-e861075121f1', value: 'CAMBODIA', label: 'Cambodia 🇰🇭', position: 28, color: 'pink' },
{ id: '49178472-0bdd-4797-be6c-e0a1ac6e4147', value: 'CAMEROON', label: 'Cameroon 🇨🇲', position: 29, color: 'gray' },
{ id: '849b6361-a83b-48fb-a3e5-2ac6e9089c7f', value: 'CANADA', label: 'Canada 🇨🇦', position: 30, color: 'blue' },
{ id: 'ca22a230-01b1-4099-b7c2-29fd3b8d09de', value: 'CAPE_VERDE', label: 'Cape Verde 🇨🇻', position: 31, color: 'turquoise' },
{ id: '360bef1e-34a9-4467-9ffe-5ebeb6427711', value: 'CENTRAL_AFRICAN_REPUBLIC', label: 'Central African Republic 🇨🇫', position: 32, color: 'green' },
{ id: 'deba0ee2-3027-4646-afc6-24410bf68198', value: 'CHAD', label: 'Chad 🇹🇩', position: 33, color: 'sky' },
{ id: 'd0bf04d5-a40c-45f0-8e82-4d5905c00131', value: 'CHILE', label: 'Chile 🇨🇱', position: 34, color: 'purple' },
{ id: '60522b37-dded-4f18-a25b-05b51a8c9aa7', value: 'CHINA', label: 'China 🇨🇳', position: 35, color: 'orange' },
{ id: '3b2c58f1-4efc-4c0f-9d43-f3a5b5b577cf', value: 'COLOMBIA', label: 'Colombia 🇨🇴', position: 36, color: 'yellow' },
{ id: 'cbea73ba-8a9d-47ee-a38b-b7c3d89d3922', value: 'COMOROS', label: 'Comoros 🇰🇲', position: 37, color: 'red' },
{ id: '895ed0d4-6a00-482a-840e-44edef034d2b', value: 'CONGO', label: 'Congo 🇨🇬', position: 38, color: 'pink' },
{ id: '13db3209-398c-474c-b5f9-269ccf27adc5', value: 'DR_CONGO', label: 'DR Congo 🇨🇩', position: 39, color: 'gray' },
{ id: '150c4227-ce67-420b-bb6c-82d5a4fb9c70', value: 'COSTA_RICA', label: 'Costa Rica 🇨🇷', position: 40, color: 'blue' },
{ id: 'ad1853e2-753d-4245-b9a7-88a29281ca0a', value: 'CROATIA', label: 'Croatia 🇭🇷', position: 41, color: 'turquoise' },
{ id: 'e42cad17-f7ea-450b-8c7f-a0ac0dc31dac', value: 'CUBA', label: 'Cuba 🇨🇺', position: 42, color: 'green' },
{ id: '9035a680-5962-48c3-8d79-ee805a2c8b1c', value: 'CYPRUS', label: 'Cyprus 🇨🇾', position: 43, color: 'sky' },
{ id: 'facb14c9-40e9-47f6-bc4b-b29178761210', value: 'CZECH_REPUBLIC', label: 'Czech Republic 🇨🇿', position: 44, color: 'purple' },
{ id: '47026695-ee4b-47f2-97c1-9d1ea9854cd5', value: 'DENMARK', label: 'Denmark 🇩🇰', position: 45, color: 'orange' },
{ id: '335e3877-1f8b-4f2b-a409-ec74596dd779', value: 'DJIBOUTI', label: 'Djibouti 🇩🇯', position: 46, color: 'yellow' },
{ id: 'ee17957f-f692-495b-a664-61284fa723f2', value: 'DOMINICA', label: 'Dominica 🇩🇲', position: 47, color: 'red' },
{ id: '4114c313-27d7-4f5a-a381-38daca143642', value: 'DOMINICAN_REPUBLIC', label: 'Dominican Republic 🇩🇴', position: 48, color: 'pink' },
{ id: 'ffcfae44-e1b7-4739-87dd-0d957062646d', value: 'ECUADOR', label: 'Ecuador 🇪🇨', position: 49, color: 'gray' },
{ id: '1f4e8ee4-3be1-42cd-96d4-3e5686dee153', value: 'EGYPT', label: 'Egypt 🇪🇬', position: 50, color: 'blue' },
{ id: 'cf98813f-2a7e-4426-b97f-d3f1ac044bb8', value: 'EL_SALVADOR', label: 'El Salvador 🇸🇻', position: 51, color: 'turquoise' },
{ id: '0e787d6e-e864-4ee3-b4c7-20fa873e8649', value: 'EQUATORIAL_GUINEA', label: 'Equatorial Guinea 🇬🇶', position: 52, color: 'green' },
{ id: 'f68b6554-4ed5-4161-933f-a321b41391ea', value: 'ERITREA', label: 'Eritrea 🇪🇷', position: 53, color: 'sky' },
{ id: '7a4387dc-198c-46db-9536-7bc73de4e1b0', value: 'ESTONIA', label: 'Estonia 🇪🇪', position: 54, color: 'purple' },
{ id: '7e0fec86-e04f-499c-a814-72ac9b4cc5ce', value: 'ESWATINI', label: 'Eswatini 🇸🇿', position: 55, color: 'orange' },
{ id: 'a7768d57-5bf2-401e-a82d-ec36e3f3a419', value: 'ETHIOPIA', label: 'Ethiopia 🇪🇹', position: 56, color: 'yellow' },
{ id: '1b9e15c3-4fa5-4cd8-9bfb-8262518665fa', value: 'FIJI', label: 'Fiji 🇫🇯', position: 57, color: 'red' },
{ id: '828a6fe2-3c50-4da3-97c9-11f3b80fbb0d', value: 'FINLAND', label: 'Finland 🇫🇮', position: 58, color: 'pink' },
{ id: '03a1c597-65cf-440f-a20d-9d6bef0540c2', value: 'FRANCE', label: 'France 🇫🇷', position: 59, color: 'gray' },
{ id: 'ab2edd1f-6353-4de8-863f-4b28e774be8e', value: 'GABON', label: 'Gabon 🇬🇦', position: 60, color: 'blue' },
{ id: '12a50b82-c2f8-4d0b-b5f4-e52dfe01ba13', value: 'GAMBIA', label: 'Gambia 🇬🇲', position: 61, color: 'turquoise' },
{ id: '2adf673e-8367-4eaf-83f0-41199718f0ee', value: 'GEORGIA', label: 'Georgia 🇬🇪', position: 62, color: 'green' },
{ id: 'bd61021e-c25b-453e-b0cc-afb3942b063f', value: 'GERMANY', label: 'Germany 🇩🇪', position: 63, color: 'sky' },
{ id: '1c45b7ae-9489-4714-b21e-8c717e6599ab', value: 'GHANA', label: 'Ghana 🇬🇭', position: 64, color: 'purple' },
{ id: 'cc47d864-cd78-4cdd-a711-d679f0cbc133', value: 'GREECE', label: 'Greece 🇬🇷', position: 65, color: 'orange' },
{ id: '2a2c3ca6-611e-49a4-9639-e8f60172c1b3', value: 'GRENADA', label: 'Grenada 🇬🇩', position: 66, color: 'yellow' },
{ id: 'da2024e5-a1a0-49ab-8cf0-0d9e3f10ff9d', value: 'GUATEMALA', label: 'Guatemala 🇬🇹', position: 67, color: 'red' },
{ id: '086c9969-1911-4aa4-8d67-3abdc12b2ce7', value: 'GUINEA', label: 'Guinea 🇬🇳', position: 68, color: 'pink' },
{ id: 'a648d1d3-4d3f-4fc6-a1ea-40f98283df75', value: 'GUINEA_BISSAU', label: 'Guinea-Bissau 🇬🇼', position: 69, color: 'gray' },
{ id: 'b567e119-4dcb-4f8a-b012-febe72262d85', value: 'GUYANA', label: 'Guyana 🇬🇾', position: 70, color: 'blue' },
{ id: '26801d38-c0e6-468a-8c78-a0aed06c16fd', value: 'HAITI', label: 'Haiti 🇭🇹', position: 71, color: 'turquoise' },
{ id: '2e31c5ff-da0a-4c82-b7bc-7a8c18b54f1e', value: 'HONDURAS', label: 'Honduras 🇭🇳', position: 72, color: 'green' },
{ id: 'a78bd322-ac40-4ab3-a46f-6204e42b0712', value: 'HUNGARY', label: 'Hungary 🇭🇺', position: 73, color: 'sky' },
{ id: '0026e951-225d-4e8c-8e64-289ead339bd3', value: 'ICELAND', label: 'Iceland 🇮🇸', position: 74, color: 'purple' },
{ id: 'f5101d7d-9b81-4d2e-a9aa-e20dfab52c58', value: 'INDIA', label: 'India 🇮🇳', position: 75, color: 'orange' },
{ id: '11b30046-58f3-42e5-a3e2-5d4761675056', value: 'INDONESIA', label: 'Indonesia 🇮🇩', position: 76, color: 'yellow' },
{ id: '7345db23-db33-45cb-b8cc-e4d4a1f1b90e', value: 'IRAN', label: 'Iran 🇮🇷', position: 77, color: 'red' },
{ id: '899d0eb1-9f04-4a88-b7f3-351e998b5979', value: 'IRAQ', label: 'Iraq 🇮🇶', position: 78, color: 'pink' },
{ id: 'e45e8143-14c1-4184-84db-c11212cb8e25', value: 'IRELAND', label: 'Ireland 🇮🇪', position: 79, color: 'gray' },
{ id: 'a208dac3-455e-41ed-9db0-aa7352d420ab', value: 'ISRAEL', label: 'Israel 🇮🇱', position: 80, color: 'blue' },
{ id: '4537862d-0565-47a0-8759-ef522996881f', value: 'ITALY', label: 'Italy 🇮🇹', position: 81, color: 'turquoise' },
{ id: '988630a1-dd19-476b-a0dc-cf8904ec5f68', value: 'IVORY_COAST', label: 'Ivory Coast 🇨🇮', position: 82, color: 'green' },
{ id: '30c755e0-61f2-4590-9969-f3150c3b5aff', value: 'JAMAICA', label: 'Jamaica 🇯🇲', position: 83, color: 'sky' },
{ id: '9ca39ab6-e0fb-4bbb-80bc-cbffa48682da', value: 'JAPAN', label: 'Japan 🇯🇵', position: 84, color: 'purple' },
{ id: 'f71aefd9-1dc0-4370-8a31-2a329d8ab15d', value: 'JORDAN', label: 'Jordan 🇯🇴', position: 85, color: 'orange' },
{ id: '7f230c15-2745-4e78-ab35-8ae5bec7bcc6', value: 'KAZAKHSTAN', label: 'Kazakhstan 🇰🇿', position: 86, color: 'yellow' },
{ id: '9e745903-4ac7-432f-bfd2-d0d78e76f3e9', value: 'KENYA', label: 'Kenya 🇰🇪', position: 87, color: 'red' },
{ id: 'ee448c0f-8b3b-4666-bbbb-19e8b62ecbcc', value: 'KIRIBATI', label: 'Kiribati 🇰🇮', position: 88, color: 'pink' },
{ id: '6de78d0a-7a2b-48bb-97b5-2a86c8bab3b6', value: 'KOSOVO', label: 'Kosovo 🇽🇰', position: 89, color: 'gray' },
{ id: '48e160eb-1fdf-4456-99f7-4e97e7d16cfa', value: 'KUWAIT', label: 'Kuwait 🇰🇼', position: 90, color: 'blue' },
{ id: '7d318293-6378-4a02-af49-23d48d887a8f', value: 'KYRGYZSTAN', label: 'Kyrgyzstan 🇰🇬', position: 91, color: 'turquoise' },
{ id: 'dc540210-83bb-40c5-ab0d-585f4648df1b', value: 'LAOS', label: 'Laos 🇱🇦', position: 92, color: 'green' },
{ id: 'fe2c5828-330f-45b8-991c-180723d5bed6', value: 'LATVIA', label: 'Latvia 🇱🇻', position: 93, color: 'sky' },
{ id: 'cff1390a-7548-401b-bfa1-5e062e1e2cd3', value: 'LEBANON', label: 'Lebanon 🇱🇧', position: 94, color: 'purple' },
{ id: '2a1d40fd-a633-4bb0-890a-1009a2126ff9', value: 'LESOTHO', label: 'Lesotho 🇱🇸', position: 95, color: 'orange' },
{ id: 'dee0303d-11c9-4bf9-913b-c5fedfc93766', value: 'LIBERIA', label: 'Liberia 🇱🇷', position: 96, color: 'yellow' },
{ id: '57713732-bd4e-4888-a4e3-ad7761e76dd9', value: 'LIBYA', label: 'Libya 🇱🇾', position: 97, color: 'red' },
{ id: '5ed4e5fd-31dc-4adc-a875-b5036ef5eac6', value: 'LIECHTENSTEIN', label: 'Liechtenstein 🇱🇮', position: 98, color: 'pink' },
{ id: '8facad69-2b22-47a4-a324-75773bd7d4f8', value: 'LITHUANIA', label: 'Lithuania 🇱🇹', position: 99, color: 'gray' },
{ id: 'fb1b4543-7f22-4c44-a953-7c68e2790e27', value: 'LUXEMBOURG', label: 'Luxembourg 🇱🇺', position: 100, color: 'blue' },
{ id: '80009115-d98c-4e63-8401-285f82d94921', value: 'MADAGASCAR', label: 'Madagascar 🇲🇬', position: 101, color: 'turquoise' },
{ id: 'b32ed1f2-3d12-4255-a39a-3a5ab64b425c', value: 'MALAWI', label: 'Malawi 🇲🇼', position: 102, color: 'green' },
{ id: 'd7c744b0-23d2-4dd4-a9f9-3298cfce0d1b', value: 'MALAYSIA', label: 'Malaysia 🇲🇾', position: 103, color: 'sky' },
{ id: 'a878865c-a73f-47d7-bf52-29c1b7b947d7', value: 'MALDIVES', label: 'Maldives 🇲🇻', position: 104, color: 'purple' },
{ id: '2d5e9644-11b6-4b55-8f9a-ed33da2de6ac', value: 'MALI', label: 'Mali 🇲🇱', position: 105, color: 'orange' },
{ id: 'e2fa091a-0e78-41da-937b-42b55e501226', value: 'MALTA', label: 'Malta 🇲🇹', position: 106, color: 'yellow' },
{ id: 'e36301e0-1d79-4540-87c7-4de2bfeface6', value: 'MARSHALL_ISLANDS', label: 'Marshall Islands 🇲🇭', position: 107, color: 'red' },
{ id: '8dc782e6-3578-4d7a-a6ba-a29e48f3cc11', value: 'MAURITANIA', label: 'Mauritania 🇲🇷', position: 108, color: 'pink' },
{ id: 'edf63c87-d621-40fe-b653-a0f7764c8961', value: 'MAURITIUS', label: 'Mauritius 🇲🇺', position: 109, color: 'gray' },
{ id: '86233df1-6c71-4ffb-9298-c61c65dcc79c', value: 'MEXICO', label: 'Mexico 🇲🇽', position: 110, color: 'blue' },
{ id: 'c41c35ff-118b-4119-b12f-93be635fbae3', value: 'MICRONESIA', label: 'Micronesia 🇫🇲', position: 111, color: 'turquoise' },
{ id: '8466a06e-a34f-4dd4-b660-8e9ad2aa5998', value: 'MOLDOVA', label: 'Moldova 🇲🇩', position: 112, color: 'green' },
{ id: '948e9117-486d-40c1-847c-de6cc64b9730', value: 'MONACO', label: 'Monaco 🇲🇨', position: 113, color: 'sky' },
{ id: 'ee33ddb8-3219-4fdb-8a71-8a6f8fdf93d3', value: 'MONGOLIA', label: 'Mongolia 🇲🇳', position: 114, color: 'purple' },
{ id: 'b5c1fa55-0b1a-43d8-a871-ebd004952d0b', value: 'MONTENEGRO', label: 'Montenegro 🇲🇪', position: 115, color: 'orange' },
{ id: '9e98cb68-666e-4116-b35f-bb9711fd7546', value: 'MOROCCO', label: 'Morocco 🇲🇦', position: 116, color: 'yellow' },
{ id: '86c4b5f8-d4bc-4639-a5bf-00a84601b911', value: 'MOZAMBIQUE', label: 'Mozambique 🇲🇿', position: 117, color: 'red' },
{ id: '8b3bcfa4-1797-492e-83d7-7d939e479b1d', value: 'MYANMAR', label: 'Myanmar 🇲🇲', position: 118, color: 'pink' },
{ id: 'b8d01fef-4061-4e8c-bc38-f8226b4be6be', value: 'NAMIBIA', label: 'Namibia 🇳🇦', position: 119, color: 'gray' },
{ id: '71dd1ad1-af94-4cfb-973d-33f87b4ef0cd', value: 'NAURU', label: 'Nauru 🇳🇷', position: 120, color: 'blue' },
{ id: 'dc692171-3adc-4a73-8b45-a25cf659c546', value: 'NEPAL', label: 'Nepal 🇳🇵', position: 121, color: 'turquoise' },
{ id: '3e161b4b-be95-4a6a-910d-628ad32691e1', value: 'NETHERLANDS', label: 'Netherlands 🇳🇱', position: 122, color: 'green' },
{ id: 'b86be59e-a1ae-4df4-937f-ad97c641e0df', value: 'NEW_ZEALAND', label: 'New Zealand 🇳🇿', position: 123, color: 'sky' },
{ id: '4674abdd-0548-4317-8af5-865e4d11327d', value: 'NICARAGUA', label: 'Nicaragua 🇳🇮', position: 124, color: 'purple' },
{ id: '85ff2ce7-ddd8-4be6-b740-d79e70a3d103', value: 'NIGER', label: 'Niger 🇳🇪', position: 125, color: 'orange' },
{ id: 'a69ab8cf-5a59-4879-92da-7cd2dea6b9e6', value: 'NIGERIA', label: 'Nigeria 🇳🇬', position: 126, color: 'yellow' },
{ id: '28736977-b21f-4678-8e22-363be28a8e94', value: 'NORTH_KOREA', label: 'North Korea 🇰🇵', position: 127, color: 'red' },
{ id: '11f87414-633d-48f8-a99d-0b407c281dbd', value: 'NORTH_MACEDONIA', label: 'North Macedonia 🇲🇰', position: 128, color: 'pink' },
{ id: '9637c5aa-d405-4414-a56b-b983ed0bfa51', value: 'NORWAY', label: 'Norway 🇳🇴', position: 129, color: 'gray' },
{ id: '70f79652-7f95-4836-ab2d-4373631910ec', value: 'OMAN', label: 'Oman 🇴🇲', position: 130, color: 'blue' },
{ id: '50e4ae80-79d8-4cdf-aa0a-a2675e6e26fb', value: 'PAKISTAN', label: 'Pakistan 🇵🇰', position: 131, color: 'turquoise' },
{ id: '08d27180-4861-4937-b04e-6cbcd33f507d', value: 'PALAU', label: 'Palau 🇵🇼', position: 132, color: 'green' },
{ id: '6aa50196-ea4b-42cd-b890-8d7d56cc9731', value: 'PALESTINE', label: 'Palestine 🇵🇸', position: 133, color: 'sky' },
{ id: '5663124d-f2fc-4374-8734-f3c53f9ea4f5', value: 'PANAMA', label: 'Panama 🇵🇦', position: 134, color: 'purple' },
{ id: '8bde7cc7-fa6c-4f52-b9df-16c99928f50f', value: 'PAPUA_NEW_GUINEA', label: 'Papua New Guinea 🇵🇬', position: 135, color: 'orange' },
{ id: '3eed4b21-1009-4dd2-bdac-53b11df8400c', value: 'PARAGUAY', label: 'Paraguay 🇵🇾', position: 136, color: 'yellow' },
{ id: 'fbc734c9-63ae-4d2e-af0f-8e2abd874dda', value: 'PERU', label: 'Peru 🇵🇪', position: 137, color: 'red' },
{ id: '211555bc-8f26-4d4d-94f1-c83884c32e20', value: 'PHILIPPINES', label: 'Philippines 🇵🇭', position: 138, color: 'pink' },
{ id: '6d69e1a3-270e-4e36-9a55-40daed5b10ef', value: 'POLAND', label: 'Poland 🇵🇱', position: 139, color: 'gray' },
{ id: '23b1ae16-f00f-4844-8871-ed2b46c1cb51', value: 'PORTUGAL', label: 'Portugal 🇵🇹', position: 140, color: 'blue' },
{ id: '4dc69f6b-b0ee-48ea-95d3-517dc4ec7b91', value: 'QATAR', label: 'Qatar 🇶🇦', position: 141, color: 'turquoise' },
{ id: '8dcd82ce-98b2-45cb-a76d-f1503509035f', value: 'ROMANIA', label: 'Romania 🇷🇴', position: 142, color: 'green' },
{ id: '7e18e241-4fd6-4303-b390-0a9dea9c16bd', value: 'RUSSIA', label: 'Russia 🇷🇺', position: 143, color: 'sky' },
{ id: '966e45fb-7ed8-478e-9c26-25b7d59ae4f4', value: 'RWANDA', label: 'Rwanda 🇷🇼', position: 144, color: 'purple' },
{ id: '22ddf2d7-f1f8-46e9-8eb9-248c02652ad0', value: 'SAINT_KITTS_AND_NEVIS', label: 'Saint Kitts & Nevis 🇰🇳', position: 145, color: 'orange' },
{ id: '3312359a-b9fd-4a95-9db2-ad3e1f67f2b2', value: 'SAINT_LUCIA', label: 'Saint Lucia 🇱🇨', position: 146, color: 'yellow' },
{ id: '159c06c1-14a5-407f-af16-b84ba14f16be', value: 'SAINT_VINCENT', label: 'Saint Vincent 🇻🇨', position: 147, color: 'red' },
{ id: '0dd1cf79-89e2-4442-bd83-c65b43a3a544', value: 'SAMOA', label: 'Samoa 🇼🇸', position: 148, color: 'pink' },
{ id: 'e16851a7-97de-4e17-aeac-0cb828655406', value: 'SAN_MARINO', label: 'San Marino 🇸🇲', position: 149, color: 'gray' },
{ id: '6415861d-5fa9-46b5-a2cd-d58587c9dd03', value: 'SAO_TOME_AND_PRINCIPE', label: 'São Tomé & Príncipe 🇸🇹', position: 150, color: 'blue' },
{ id: '5831720a-3ce0-409c-9694-1b1d12394599', value: 'SAUDI_ARABIA', label: 'Saudi Arabia 🇸🇦', position: 151, color: 'turquoise' },
{ id: '89155d75-e4d0-4115-916c-8b77b9ddc5f3', value: 'SENEGAL', label: 'Senegal 🇸🇳', position: 152, color: 'green' },
{ id: 'f0c3e0e0-8106-4921-a02e-0497c1318efa', value: 'SERBIA', label: 'Serbia 🇷🇸', position: 153, color: 'sky' },
{ id: 'd8633c5d-1c65-44c5-bc71-7eb65cb988c3', value: 'SEYCHELLES', label: 'Seychelles 🇸🇨', position: 154, color: 'purple' },
{ id: '64b9fd47-a748-4add-bacf-a7dc1f1aa0c7', value: 'SIERRA_LEONE', label: 'Sierra Leone 🇸🇱', position: 155, color: 'orange' },
{ id: '5bd24cd5-a233-4d8f-930d-27f15d493c92', value: 'SINGAPORE', label: 'Singapore 🇸🇬', position: 156, color: 'yellow' },
{ id: '63c5df12-b203-4627-91b2-0526a56452d4', value: 'SLOVAKIA', label: 'Slovakia 🇸🇰', position: 157, color: 'red' },
{ id: 'b0deeef1-767a-4730-8743-588bfeb856e6', value: 'SLOVENIA', label: 'Slovenia 🇸🇮', position: 158, color: 'pink' },
{ id: '9274cd87-3400-4ccd-9192-3239b5126691', value: 'SOLOMON_ISLANDS', label: 'Solomon Islands 🇸🇧', position: 159, color: 'gray' },
{ id: '67dc7930-eaa4-4ef0-9fc4-af996eaaae09', value: 'SOMALIA', label: 'Somalia 🇸🇴', position: 160, color: 'blue' },
{ id: '19d96578-107c-4286-9504-5d59077b8290', value: 'SOUTH_AFRICA', label: 'South Africa 🇿🇦', position: 161, color: 'turquoise' },
{ id: '11cb5cb6-d67d-47ae-adf1-ecba3635fe20', value: 'SOUTH_KOREA', label: 'South Korea 🇰🇷', position: 162, color: 'green' },
{ id: '8293e893-0d7d-4002-9929-fea536a9a73c', value: 'SOUTH_SUDAN', label: 'South Sudan 🇸🇸', position: 163, color: 'sky' },
{ id: 'e0b9ba99-d891-4f6c-95df-1f92a0a7e12e', value: 'SPAIN', label: 'Spain 🇪🇸', position: 164, color: 'purple' },
{ id: '18378ce8-e480-4fd9-b90d-379d5daf7ecb', value: 'SRI_LANKA', label: 'Sri Lanka 🇱🇰', position: 165, color: 'orange' },
{ id: '10943324-5ae5-47db-bb2f-5d4bdeafd0b0', value: 'SUDAN', label: 'Sudan 🇸🇩', position: 166, color: 'yellow' },
{ id: 'bfd244e4-87c0-4dca-89b0-a1b581f1aa65', value: 'SURINAME', label: 'Suriname 🇸🇷', position: 167, color: 'red' },
{ id: '6a8f9119-c413-4ece-b432-2ce2e80580a1', value: 'SWEDEN', label: 'Sweden 🇸🇪', position: 168, color: 'pink' },
{ id: '866a8e2d-60a7-40a0-8541-35a9cfa20c09', value: 'SWITZERLAND', label: 'Switzerland 🇨🇭', position: 169, color: 'gray' },
{ id: '93758334-0ebc-4884-bfac-c0dbeec23a35', value: 'SYRIA', label: 'Syria 🇸🇾', position: 170, color: 'blue' },
{ id: 'c785e424-7a6f-4f27-91bc-aadb617525b0', value: 'TAIWAN', label: 'Taiwan 🇹🇼', position: 171, color: 'turquoise' },
{ id: '15ab8a5b-5989-4e3b-92cb-00b88cf7b7f6', value: 'TAJIKISTAN', label: 'Tajikistan 🇹🇯', position: 172, color: 'green' },
{ id: '476a55dd-8c69-4711-8e40-54cbe41d5c3a', value: 'TANZANIA', label: 'Tanzania 🇹🇿', position: 173, color: 'sky' },
{ id: 'bfcbef16-36c4-4350-a614-7ad065276104', value: 'THAILAND', label: 'Thailand 🇹🇭', position: 174, color: 'purple' },
{ id: '6872c804-9baa-498f-9dc6-b5894b64dece', value: 'TIMOR_LESTE', label: 'Timor-Leste 🇹🇱', position: 175, color: 'orange' },
{ id: 'b6429782-b6e1-4404-a5c4-8a44f3818147', value: 'TOGO', label: 'Togo 🇹🇬', position: 176, color: 'yellow' },
{ id: 'f4a7b7f9-aa58-4d23-adac-a96ed2c61ba1', value: 'TONGA', label: 'Tonga 🇹🇴', position: 177, color: 'red' },
{ id: 'a1377467-7a21-40af-861f-4b362b30fcb4', value: 'TRINIDAD_AND_TOBAGO', label: 'Trinidad & Tobago 🇹🇹', position: 178, color: 'pink' },
{ id: '7779c7d6-c7e1-4d6c-8282-31f659988c22', value: 'TUNISIA', label: 'Tunisia 🇹🇳', position: 179, color: 'gray' },
{ id: '5858f81e-629a-4b04-98e8-6441f3984044', value: 'TURKEY', label: 'Turkey 🇹🇷', position: 180, color: 'blue' },
{ id: 'cfdae353-6782-4dde-b229-d4e2c577fbed', value: 'TURKMENISTAN', label: 'Turkmenistan 🇹🇲', position: 181, color: 'turquoise' },
{ id: '755d859a-a8d4-45e0-aa6e-77afcedb29d0', value: 'TUVALU', label: 'Tuvalu 🇹🇻', position: 182, color: 'green' },
{ id: '3b325d46-b8e8-4cb9-9318-53f20baf50bd', value: 'UGANDA', label: 'Uganda 🇺🇬', position: 183, color: 'sky' },
{ id: '10f32cd9-0a6b-4829-913d-50f519dcfc3a', value: 'UKRAINE', label: 'Ukraine 🇺🇦', position: 184, color: 'purple' },
{ id: '0776427b-0dc1-49ef-b1c8-bb7dbba5a1e9', value: 'UNITED_ARAB_EMIRATES', label: 'UAE 🇦🇪', position: 185, color: 'orange' },
{ id: 'edaf21dc-60d4-4550-8b58-2f7a19eedc92', value: 'UNITED_KINGDOM', label: 'UK 🇬🇧', position: 186, color: 'yellow' },
{ id: '553004cd-aeed-4025-aa71-574efcade200', value: 'UNITED_STATES', label: 'USA 🇺🇸', position: 187, color: 'red' },
{ id: 'f925d857-5cb7-467d-bc65-e5c04ae5a238', value: 'URUGUAY', label: 'Uruguay 🇺🇾', position: 188, color: 'pink' },
{ id: '9b50ea41-2b8f-44d1-beeb-f4f4428289c4', value: 'UZBEKISTAN', label: 'Uzbekistan 🇺🇿', position: 189, color: 'gray' },
{ id: 'a7a7cbfd-6999-42e8-874e-c2bfbe6ca567', value: 'VANUATU', label: 'Vanuatu 🇻🇺', position: 190, color: 'blue' },
{ id: '7de0a670-8f04-444c-86dc-d7be00cc8b4e', value: 'VATICAN', label: 'Vatican 🇻🇦', position: 191, color: 'turquoise' },
{ id: '9acc531c-7f17-4b67-804a-035c3c873cce', value: 'VENEZUELA', label: 'Venezuela 🇻🇪', position: 192, color: 'green' },
{ id: '540b908e-f6b6-4f70-bb9c-c86dc5becbe7', value: 'VIETNAM', label: 'Vietnam 🇻🇳', position: 193, color: 'sky' },
{ id: 'a70a3610-9a9b-4fe0-9e97-10d3e60a3d92', value: 'YEMEN', label: 'Yemen 🇾🇪', position: 194, color: 'purple' },
{ id: 'fde30bd3-b2f1-4331-a255-9601259e807e', value: 'ZAMBIA', label: 'Zambia 🇿🇲', position: 195, color: 'orange' },
{ id: 'a83060bf-f55b-4efe-9e0c-b856ad5446c4', value: 'ZIMBABWE', label: 'Zimbabwe 🇿🇼', position: 196, color: 'yellow' },
],
},
{
universalIdentifier: '560503de-6330-4c1d-af97-a8dee125f2ad',
type: FieldType.MULTI_SELECT,
name: 'region',
label: 'Region',
icon: 'IconWorld',
isNullable: true,
options: [
{ id: '735d7a75-c032-48b3-8480-c46e15b7e511', value: 'EUROPE', label: 'Europe', position: 0, color: 'blue' },
{ id: '78eb8d0c-841e-48dd-8425-d8546cbcfe25', value: 'US', label: 'US', position: 1, color: 'red' },
{ id: 'b0339081-52dd-4101-b7e7-83ed6ad6ca4f', value: 'LATAM', label: 'LATAM', position: 2, color: 'green' },
{ id: 'f02bd583-87b8-48d1-97db-9395056c96e2', value: 'MENA', label: 'MENA', position: 3, color: 'orange' },
{ id: '5205c25f-a300-4967-b5f3-033c0515a28a', value: 'APAC', label: 'APAC', position: 4, color: 'yellow' },
{ id: '7f55cf70-8c81-4f68-83bc-270d215f0a4e', value: 'AFRICA', label: 'Africa', position: 5, color: 'pink' },
],
},
{
universalIdentifier: '243e9808-c070-4163-8603-ded12b03923c',
type: FieldType.CURRENCY,
name: 'projectBudgetMin',
label: 'Project Budget Min',
icon: 'IconCoin',
isNullable: true,
},
{
universalIdentifier: 'ced87a97-cb2a-43cb-a6fc-4a1eff2892ba',
type: FieldType.CURRENCY,
name: 'projectBudgetTypical',
label: 'Project Budget Typical',
icon: 'IconCoins',
isNullable: true,
},
{
universalIdentifier: '6a095709-7620-495f-b6e0-790743e412d5',
type: FieldType.CURRENCY,
name: 'hourlyRate',
label: 'Hourly Rate',
icon: 'IconCurrencyDollar',
isNullable: true,
},
{
universalIdentifier: '640bbf33-45d7-4174-a862-dbe611ab8d1a',
type: FieldType.LINKS,
name: 'linkedin',
label: 'LinkedIn',
icon: 'IconBrandLinkedin',
isNullable: true,
},
{
universalIdentifier: '40d730e3-2785-45c8-aa5f-cc724b1b08e0',
type: FieldType.LINKS,
name: 'profilePicture',
label: 'Profile Picture',
icon: 'IconPhoto',
isNullable: true,
},
{
universalIdentifier: 'a0000008-0000-4000-8000-000000000008',
type: FieldType.LINKS,
name: 'calendarLink',
label: 'Calendar Link',
icon: 'IconCalendar',
isNullable: true,
},
{
universalIdentifier: 'a0000009-0000-4000-8000-000000000009',
type: FieldType.TEXT,
name: 'introduction',
label: 'Introduction',
icon: 'IconFileText',
isNullable: true,
},
{
universalIdentifier: 'a0000010-0000-4000-8000-000000000010',
type: FieldType.DATE_TIME,
name: 'lastMatchAt',
label: 'Last Match At',
icon: 'IconClock',
isNullable: true,
},
],
});
@@ -1,56 +0,0 @@
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineRole } from 'twenty-sdk/define';
import {
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
PARTNER_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
// PLACEHOLDER role for external partners. Twenty has no row-level filtering yet,
// so anyone assigned this role currently sees every record — do not hand out
// until RLP ships and we can scope to records owned by the assigned user.
export default defineRole({
universalIdentifier: PARTNER_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Partner',
description:
'PLACEHOLDER. External partner self-service role. Sees ALL Partner/Opportunity records today because Twenty does not yet support row-level record filtering. When RLP ships, scope these permissions to records owned by the assigned user. DO NOT assign to real external partners until then.',
icon: 'IconBuildingStore',
canBeAssignedToUsers: true,
canUpdateAllSettings: false,
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
objectPermissions: [
{
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
});
@@ -1,55 +0,0 @@
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineRole } from 'twenty-sdk/define';
import {
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
TWENTY_PARTNER_OPS_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
// Internal role for the Twenty partner-ops team. Scoped strictly to the CRM
// objects needed to run the matching workflow — no settings, no destroy.
export default defineRole({
universalIdentifier: TWENTY_PARTNER_OPS_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Twenty Partner Ops',
description:
'Internal Twenty teammate role for managing partners and matched deals. Full read/write on Partner, Company, Person, Opportunity. No access to Tasks/Notes/Workflows. No settings access.',
icon: 'IconUsersGroup',
canBeAssignedToUsers: true,
canUpdateAllSettings: false,
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
objectPermissions: [
{
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
});
@@ -1,420 +0,0 @@
// Import partners, opportunities and partner quotes FROM the TFT workspace INTO
// this (local) workspace. Idempotent: re-running upserts by natural key
// (partner=slug, opportunity=tftOpportunityId, quote=name).
//
// TFT (source) is read over RAW GraphQL fetch: its custom fields/filters are not
// in the SDK's generated (local) genql schema, so CoreApiClient cannot build
// queries for them. The local (target) workspace is written via CoreApiClient,
// exactly like seed.ts. Two separate credential sets, no collision:
// TFT -> TFT_API_URL / TFT_API_KEY (raw fetch)
// local-> TWENTY_PARTNERS_API_URL / TWENTY_PARTNERS_API_KEY (CoreApiClient)
//
// The local server rate-limits API calls (~100 / 60s), so existence checks are
// batched into one `in` query per object (like seed.ts) and writes are paced.
//
// DRY-RUN BY DEFAULT: reads everything, writes nothing, and reports both what it
// WOULD upsert and the distinct TFT SELECT values it saw (flagging any value not
// covered by a local field option, which would otherwise fail a real write).
// Set IMPORT_APPLY=1 to actually write to the local workspace.
//
// TFT_API_URL=https://twentyfortwenty.twenty.com TFT_API_KEY=<tft key> \
// TWENTY_PARTNERS_API_URL=http://localhost:2020 TWENTY_PARTNERS_API_KEY=<local key> \
// [IMPORT_APPLY=1] \
// tsx src/scripts/import-from-tft.ts
import { config } from 'dotenv';
config({ path: '.env.local' });
import { CoreApiClient } from 'twenty-client-sdk/core';
const requireEnv = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name} env var`);
return value;
};
const APPLY = process.env.IMPORT_APPLY === '1';
// Raw GraphQL against TFT. The generated CoreApiClient is bound to the LOCAL
// schema and cannot serialise TFT's custom fields/filters, so read TFT untyped.
const tftQuery = async (query: string): Promise<any> => {
const url = `${requireEnv('TFT_API_URL').replace(/\/$/, '')}/graphql`;
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${requireEnv('TFT_API_KEY')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
});
const json: any = await response.json();
if (json.errors?.length) {
throw new Error(`TFT query failed: ${JSON.stringify(json.errors)}`);
}
return json.data;
};
// TFT opportunity.stage -> our matchStatus (the 4 early stages collapse to TO_BE_MATCHED)
const STAGE_TO_MATCH_STATUS: Record<string, string> = {
INTRODUCED_TO_A_PARTNER: 'INTRODUCED_TO_A_PARTNER',
WORKING_WITH_A_PARTNER: 'WORKING_WITH_A_PARTNER',
WON: 'WON',
LOST: 'LOST',
RECONNECT_LATER: 'RECONNECT_LATER',
IDENTIFIED: 'TO_BE_MATCHED',
MET: 'TO_BE_MATCHED',
SOLUTIONING: 'TO_BE_MATCHED',
ADVANCED: 'TO_BE_MATCHED',
};
// TFT person.partnerStage -> our Partner.validationStage
const PARTNER_STAGE_TO_VALIDATION: Record<string, string> = {
APPLICATION: 'APPLICATION',
POTENTIAL_PARTNER: 'POTENTIAL',
PARTNER: 'VALIDATED',
FORMER_PARTNER: 'FORMER',
REJECTED: 'REJECTED',
};
// Local SELECT option sets, for preflight coverage checks (a TFT value not in
// these would fail a real write). Keep in sync with src/objects + src/fields.
const LOCAL_OPTIONS: Record<string, Set<string>> = {
partnerTier: new Set(['NEW', 'INTERMEDIATE', 'ADVANCED']),
partnerScope: new Set(['APPS', 'DATA_MODEL', 'DATA_MIGRATION', 'HOSTING_ENVIRONMENT', 'WORKFLOWS']),
typeOfTeam: new Set(['SOLO', 'AGENCY']),
hostingType: new Set(['CLOUD', 'SELF_HOSTING']),
subscriptionType: new Set(['PRO', 'ORG', 'ENT']),
subscriptionFrequency: new Set(['MONTHLY', 'ANNUAL']),
quoteStatus: new Set(['WIP', 'INTERVIEW_SCHEDULED', 'UNDER_CUSTOMER_PARTNER_REVIEW', 'APPROVED', 'REJECTED']),
};
const slugify = (s: string): string =>
s
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const edges = (result: any, key: string): any[] =>
(result?.[key]?.edges ?? []).map((e: any) => e.node);
const uniq = (values: (string | undefined | null)[]): string[] =>
[...new Set(values.filter((v): v is string => !!v))];
async function main() {
console.log(`[import] mode: ${APPLY ? 'APPLY (writing to local)' : 'DRY-RUN (no writes)'}`);
const local = new CoreApiClient({
url: `${requireEnv('TWENTY_PARTNERS_API_URL').replace(/\/$/, '')}/graphql`,
headers: { Authorization: `Bearer ${requireEnv('TWENTY_PARTNERS_API_KEY')}` },
});
// Pace writes to stay under the local server's ~100 req/60s limit.
let writes = 0;
const pace = async () => {
if (APPLY && writes++ > 0) await new Promise((r) => setTimeout(r, 750));
};
// Distinct TFT values seen, for the preflight coverage report.
const seen: Record<string, Set<string>> = {
partnerStage: new Set(),
partnerTier: new Set(),
partnerScope: new Set(),
typeOfTeam: new Set(),
oppStage: new Set(),
hostingType: new Set(),
subscriptionType: new Set(),
subscriptionFrequency: new Set(),
quoteStatus: new Set(),
typeCustom: new Set(),
};
const note = (bucket: string, value?: string | null) => {
if (value) seen[bucket].add(value);
};
// ---------------------------------------------------------------------
// 1. Read all three TFT datasets (raw fetch; not rate-limited by local).
// ---------------------------------------------------------------------
const tftPeople = edges(
await tftQuery(`query {
people(first: 500, filter: { partnerStage: { in: ["APPLICATION","POTENTIAL_PARTNER","PARTNER","FORMER_PARTNER","REJECTED"] } }) {
edges { node {
id
name { firstName lastName }
emails { primaryEmail }
city jobTitle
linkedinLink { primaryLinkUrl }
partnerStage partnerTier partnerScope partnerTypeOfTeam partnerIsAvailable partnerSkills
partnerBudgetMinimum { amountMicros currencyCode }
partnerBudgetAverage { amountMicros currencyCode }
company { id name domainName { primaryLinkUrl } }
} }
}
}`),
'people',
);
const tftOpps = edges(
await tftQuery(`query {
opportunities(first: 500) {
edges { node {
id name numberOfSeats useCase hostingType subscriptionType subscriptionFrequence lostReason stage
amount { amountMicros currencyCode }
closeDate
company { id name domainName { primaryLinkUrl } }
partner { id }
} }
}
}`),
'opportunities',
);
const tftContent = edges(
await tftQuery(`query {
customerContents(first: 500) {
edges { node { id name status approvalDate typeCustom partnerPerson { id } } }
}
}`),
'customerContents',
);
const quotes = tftContent.filter((c: any) => Array.isArray(c.typeCustom) && c.typeCustom.includes('PARTNER_QUOTE'));
console.log(`[import] TFT: ${tftPeople.length} partner-people, ${tftOpps.length} opportunities, ${quotes.length} partner quotes`);
const personSlug = (p: any): string =>
slugify([p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner') || p.id;
// ---------------------------------------------------------------------
// 2. Batched existence lookups against local (one `in` query per object).
// ---------------------------------------------------------------------
const partnerSlugs = uniq(tftPeople.map(personSlug));
const partnerIdBySlug = new Map<string, string>(
partnerSlugs.length
? edges(
await local.query({
partners: { __args: { filter: { slug: { in: partnerSlugs } }, first: 500 }, edges: { node: { id: true, slug: true } } },
} as any),
'partners',
).map((n: any) => [n.slug, n.id])
: [],
);
const companyNames = uniq([...tftPeople.map((p: any) => p.company?.name), ...tftOpps.map((o: any) => o.company?.name)]);
const companyIdByName = new Map<string, string>(
companyNames.length
? edges(
await local.query({
companies: { __args: { filter: { name: { in: companyNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
} as any),
'companies',
).map((n: any) => [n.name, n.id])
: [],
);
const oppTftIds = uniq(tftOpps.filter((o: any) => o.name).map((o: any) => o.id));
const oppIdByTftId = new Map<string, string>(
oppTftIds.length
? edges(
await local.query({
opportunities: { __args: { filter: { tftOpportunityId: { in: oppTftIds } }, first: 500 }, edges: { node: { id: true, tftOpportunityId: true } } },
} as any),
'opportunities',
).map((n: any) => [n.tftOpportunityId, n.id])
: [],
);
const quoteNames = uniq(quotes.map((q: any) => q.name || 'Partner quote'));
const quoteIdByName = new Map<string, string>(
quoteNames.length
? edges(
await local.query({
partnerQuotes: { __args: { filter: { name: { in: quoteNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
} as any),
'partnerQuotes',
).map((n: any) => [n.name, n.id])
: [],
);
// ---------------------------------------------------------------------
// 3. Upsert. Writes are APPLY-gated and paced; in dry-run we resolve new
// ids to synthetic placeholders so relation mapping still exercises.
// ---------------------------------------------------------------------
const upsertCompany = async (name?: string, domain?: string): Promise<string | undefined> => {
if (!name) return undefined;
if (companyIdByName.has(name)) return companyIdByName.get(name);
let id = `dry:company:${name}`;
if (APPLY) {
await pace();
const created: any = await local.mutation({
createCompany: {
__args: { data: { name, ...(domain ? { domainName: { primaryLinkUrl: domain } } : {}) } },
id: true,
},
} as any);
id = created.createCompany.id;
}
companyIdByName.set(name, id);
return id;
};
const budgetCurrency = (amount: any) =>
amount?.amountMicros != null ? { amountMicros: amount.amountMicros, currencyCode: amount.currencyCode ?? 'USD' } : undefined;
// -- Partners (upsert by slug) --
const localPartnerIdByTftPersonId = new Map<string, string>();
let partnersCreated = 0;
let partnersUpdated = 0;
for (const p of tftPeople) {
note('partnerStage', p.partnerStage);
note('partnerTier', p.partnerTier);
(Array.isArray(p.partnerScope) ? p.partnerScope : []).forEach((s: string) => note('partnerScope', s));
note('typeOfTeam', p.partnerTypeOfTeam);
const slug = personSlug(p);
const companyId = await upsertCompany(p.company?.name, p.company?.domainName?.primaryLinkUrl);
const data: Record<string, unknown> = {
name: [p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner',
slug,
validationStage: PARTNER_STAGE_TO_VALIDATION[p.partnerStage] ?? 'APPLICATION',
availability: p.partnerIsAvailable ? 'AVAILABLE' : 'UNAVAILABLE',
...(p.partnerTier ? { partnerTier: p.partnerTier } : {}),
...(Array.isArray(p.partnerScope) && p.partnerScope.length ? { partnerScope: p.partnerScope } : {}),
...(p.partnerTypeOfTeam ? { typeOfTeam: p.partnerTypeOfTeam } : {}),
...(Array.isArray(p.partnerSkills) && p.partnerSkills.length ? { skills: p.partnerSkills } : {}),
...(p.city ? { city: p.city } : {}),
...(budgetCurrency(p.partnerBudgetMinimum) ? { projectBudgetMin: budgetCurrency(p.partnerBudgetMinimum) } : {}),
...(budgetCurrency(p.partnerBudgetAverage) ? { projectBudgetTypical: budgetCurrency(p.partnerBudgetAverage) } : {}),
...(p.linkedinLink?.primaryLinkUrl ? { linkedin: { primaryLinkUrl: p.linkedinLink.primaryLinkUrl } } : {}),
...(companyId && APPLY ? { companyId } : {}),
};
let partnerId = partnerIdBySlug.get(slug);
if (partnerId) {
if (APPLY) {
await pace();
await local.mutation({ updatePartner: { __args: { id: partnerId, data }, id: true } } as any);
}
partnersUpdated++;
} else {
if (APPLY) {
await pace();
const created: any = await local.mutation({ createPartner: { __args: { data }, id: true } } as any);
partnerId = created.createPartner.id;
} else {
partnerId = `dry:partner:${slug}`;
}
partnerIdBySlug.set(slug, partnerId as string);
partnersCreated++;
}
localPartnerIdByTftPersonId.set(p.id, partnerId as string);
}
console.log(`[import] partners created=${partnersCreated} updated=${partnersUpdated}`);
// -- Opportunities (upsert by tftOpportunityId) --
let oppsCreated = 0;
let oppsUpdated = 0;
let oppsPartnerLinked = 0;
for (const o of tftOpps) {
if (!o.name) continue;
note('oppStage', o.stage);
note('hostingType', o.hostingType);
note('subscriptionType', o.subscriptionType);
note('subscriptionFrequency', o.subscriptionFrequence);
const companyId = await upsertCompany(o.company?.name, o.company?.domainName?.primaryLinkUrl);
const partnerId = o.partner?.id ? localPartnerIdByTftPersonId.get(o.partner.id) : undefined;
if (partnerId) oppsPartnerLinked++;
const data: Record<string, unknown> = {
name: o.name,
tftOpportunityId: o.id,
matchStatus: STAGE_TO_MATCH_STATUS[o.stage] ?? 'TO_BE_MATCHED',
...(o.numberOfSeats != null ? { numberOfSeats: o.numberOfSeats } : {}),
...(o.useCase ? { useCase: o.useCase } : {}),
...(o.hostingType ? { hostingType: o.hostingType } : {}),
...(o.subscriptionType ? { subscriptionType: o.subscriptionType } : {}),
...(o.subscriptionFrequence ? { subscriptionFrequency: o.subscriptionFrequence } : {}),
...(o.lostReason ? { lostReason: o.lostReason } : {}),
...(o.amount?.amountMicros != null ? { amount: { amountMicros: o.amount.amountMicros, currencyCode: o.amount.currencyCode ?? 'USD' } } : {}),
...(o.closeDate ? { closeDate: o.closeDate } : {}),
...(companyId && APPLY ? { companyId } : {}),
...(partnerId && APPLY ? { partnerId } : {}),
};
const existingId = oppIdByTftId.get(o.id);
if (existingId) {
if (APPLY) {
await pace();
await local.mutation({ updateOpportunity: { __args: { id: existingId, data }, id: true } } as any);
}
oppsUpdated++;
} else {
if (APPLY) {
await pace();
await local.mutation({ createOpportunity: { __args: { data }, id: true } } as any);
}
oppsCreated++;
}
}
console.log(`[import] opportunities created=${oppsCreated} updated=${oppsUpdated} (partner-linked=${oppsPartnerLinked})`);
// -- Partner quotes (upsert by name) --
let quotesCreated = 0;
let quotesUpdated = 0;
tftContent.forEach((c: any) => (Array.isArray(c.typeCustom) ? c.typeCustom : []).forEach((t: string) => note('typeCustom', t)));
for (const q of quotes) {
note('quoteStatus', q.status);
const name = q.name || 'Partner quote';
const partnerId = q.partnerPerson?.id ? localPartnerIdByTftPersonId.get(q.partnerPerson.id) : undefined;
const data: Record<string, unknown> = {
name,
...(q.status ? { status: q.status } : {}),
...(q.approvalDate ? { approvalDate: q.approvalDate } : {}),
...(partnerId && APPLY ? { partnerId } : {}),
};
const existingId = quoteIdByName.get(name);
if (existingId) {
if (APPLY) {
await pace();
await local.mutation({ updatePartnerQuote: { __args: { id: existingId, data }, id: true } } as any);
}
quotesUpdated++;
} else {
if (APPLY) {
await pace();
await local.mutation({ createPartnerQuote: { __args: { data }, id: true } } as any);
}
quotesCreated++;
}
}
console.log(`[import] partner quotes created=${quotesCreated} updated=${quotesUpdated}`);
// ---------------------------------------------------------------------
// 4. Preflight: distinct TFT values vs local option coverage.
// ---------------------------------------------------------------------
const report = (label: string, bucket: string, optionKey?: string) => {
const values = [...seen[bucket]].sort();
const allowed = optionKey ? LOCAL_OPTIONS[optionKey] : undefined;
const uncovered = allowed ? values.filter((v) => !allowed.has(v)) : [];
console.log(
`[preflight] ${label}: ${values.join(', ') || '(none)'}` +
(uncovered.length ? ` ⚠️ NOT IN LOCAL OPTIONS: ${uncovered.join(', ')}` : ''),
);
};
console.log('--- preflight: distinct TFT values seen ---');
report('partnerStage (-> validationStage map)', 'partnerStage');
report('partnerTier', 'partnerTier', 'partnerTier');
report('partnerScope', 'partnerScope', 'partnerScope');
report('typeOfTeam', 'typeOfTeam', 'typeOfTeam');
report('opp stage (-> matchStatus map)', 'oppStage');
report('hostingType', 'hostingType', 'hostingType');
report('subscriptionType', 'subscriptionType', 'subscriptionType');
report('subscriptionFrequency', 'subscriptionFrequency', 'subscriptionFrequency');
report('quote status', 'quoteStatus', 'quoteStatus');
report('customerContent typeCustom', 'typeCustom');
const unmappedStages = [...seen.partnerStage].filter((s) => !(s in PARTNER_STAGE_TO_VALIDATION));
const unmappedOpps = [...seen.oppStage].filter((s) => !(s in STAGE_TO_MATCH_STATUS));
if (unmappedStages.length) console.log(`[preflight] ⚠️ partnerStage not mapped: ${unmappedStages.join(', ')}`);
if (unmappedOpps.length) console.log(`[preflight] ⚠️ opp stage not mapped: ${unmappedOpps.join(', ')}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -1,195 +0,0 @@
// Single demo seed for the twenty-partners app. Idempotent UPSERT by natural key.
// Covers: partners across ALL validationStage values, companies + people,
// opportunities across ALL 10 matchStatus values, and partner quotes across ALL
// 5 statuses (each linked to a partner + opportunity).
//
// Run from this app directory, against a running Twenty server with the app
// installed (deploy first; do NOT run `yarn test` after — its teardown wipes the app).
// Credentials from shell env or a gitignored .env.local (TWENTY_PARTNERS_API_URL/KEY).
//
// yarn twenty dev --once
// tsx src/scripts/seed.ts
import { config } from 'dotenv';
config({ path: '.env.local' });
import { CoreApiClient } from 'twenty-client-sdk/core';
const requireEnv = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name} env var`);
return value;
};
const CAL = 'https://calendly.com/placeholder';
type Partner = {
slug: string;
name: string;
validationStage: string;
availability: string;
introduction: string;
calendarLink: string;
deploymentExpertise: string[];
region: string[];
languagesSpoken: string[];
partnerTier: string;
partnerScope: string[];
typeOfTeam: string;
country: string;
};
const PARTNERS: Partner[] = [
{ slug: 'nine-dots-ventures', name: 'Nine Dots Ventures', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Boutique CRM implementer for real-estate workflows and WhatsApp automation.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['EUROPE', 'MENA'], languagesSpoken: ['ENGLISH', 'FRENCH', 'ARABIC'], partnerTier: 'ADVANCED', partnerScope: ['DATA_MODEL', 'WORKFLOWS', 'APPS'], typeOfTeam: 'AGENCY', country: 'FRANCE' },
{ slug: 'elevate-consulting', name: 'Elevate Consulting', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Revenue-operations partner for B2B SaaS teams scaling seed to Series C.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['US', 'LATAM'], languagesSpoken: ['ENGLISH', 'SPANISH'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MIGRATION', 'DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'UNITED_STATES' },
{ slug: 'w3villa-technologies', name: 'W3Villa Technologies', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Engineering-heavy partner running large self-hosted Twenty deployments.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'MENA'], languagesSpoken: ['ENGLISH', 'HINDI'], partnerTier: 'ADVANCED', partnerScope: ['HOSTING_ENVIRONMENT', 'APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'INDIA' },
{ slug: 'act-education', name: 'Act Education', validationStage: 'VALIDATED', availability: 'UNAVAILABLE', introduction: 'CRM partner for European education providers; compliance-first self-hosting.', calendarLink: CAL, deploymentExpertise: ['SELF_HOST'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'GERMAN'], partnerTier: 'NEW', partnerScope: ['HOSTING_ENVIRONMENT', 'DATA_MODEL'], typeOfTeam: 'SOLO', country: 'GERMANY' },
{ slug: 'netzero-systems', name: 'NetZero Systems', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'LATAM go-to-market partner for climate-tech and renewable-energy companies.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['LATAM', 'US'], languagesSpoken: ['ENGLISH', 'SPANISH', 'PORTUGUESE'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'BRAZIL' },
{ slug: 'meridian-craft', name: 'Meridian Craft', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'APAC implementation studio for fintech and logistics; English + Chinese.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'AFRICA'], languagesSpoken: ['ENGLISH', 'CHINESE', 'MALAY'], partnerTier: 'ADVANCED', partnerScope: ['APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'SINGAPORE' },
{ slug: 'applicant-studio', name: 'Applicant Studio', validationStage: 'APPLICATION', availability: 'UNAVAILABLE', introduction: 'New applicant; awaiting first review.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'FRENCH'], partnerTier: 'NEW', partnerScope: ['DATA_MODEL'], typeOfTeam: 'SOLO', country: 'FRANCE' },
{ slug: 'rising-crm', name: 'Rising CRM', validationStage: 'POTENTIAL', availability: 'AVAILABLE', introduction: 'Promising applicant in evaluation.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['US'], languagesSpoken: ['ENGLISH'], partnerTier: 'NEW', partnerScope: ['WORKFLOWS', 'APPS'], typeOfTeam: 'AGENCY', country: 'UNITED_STATES' },
{ slug: 'legacy-partners', name: 'Legacy Partners', validationStage: 'FORMER', availability: 'UNAVAILABLE', introduction: 'Former partner; no longer active in the program.', calendarLink: CAL, deploymentExpertise: ['SELF_HOST'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'GERMAN'], partnerTier: 'INTERMEDIATE', partnerScope: ['HOSTING_ENVIRONMENT'], typeOfTeam: 'AGENCY', country: 'UNITED_KINGDOM' },
{ slug: 'declined-co', name: 'Declined Co', validationStage: 'REJECTED', availability: 'UNAVAILABLE', introduction: 'Application rejected after review.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['MENA'], languagesSpoken: ['ENGLISH', 'ARABIC'], partnerTier: 'NEW', partnerScope: ['APPS'], typeOfTeam: 'SOLO', country: 'UNITED_ARAB_EMIRATES' },
];
const COMPANIES = [
{ name: 'Acme Real Estate', domain: 'https://acmerealestate.example' },
{ name: 'Helix Bio', domain: 'https://helixbio.example' },
{ name: 'Sunrise Logistics', domain: 'https://sunriselogistics.example' },
];
const PERSONS = [
{ firstName: 'Camille', lastName: 'Durand', companyName: 'Acme Real Estate', email: 'camille@acmerealestate.example', city: 'Paris' },
{ firstName: 'Maya', lastName: 'Patel', companyName: 'Helix Bio', email: 'maya@helixbio.example', city: 'Boston' },
{ firstName: 'Wei', lastName: 'Chen', companyName: 'Sunrise Logistics', email: 'wei@sunriselogistics.example', city: 'Singapore' },
];
type Opp = {
name: string;
companyName: string;
matchStatus: string;
partnerSlug?: string;
numberOfSeats?: number;
hostingType?: string;
subscriptionType?: string;
subscriptionFrequency?: string;
};
// One+ opportunity for every matchStatus value (all 10 covered).
const OPPORTUNITIES: Opp[] = [
{ name: 'Acme RE — Q3 renewal', companyName: 'Acme Real Estate', matchStatus: 'TO_BE_MATCHED', numberOfSeats: 20, hostingType: 'CLOUD', subscriptionType: 'PRO', subscriptionFrequency: 'ANNUAL' },
{ name: 'Helix Bio — investor reporting', companyName: 'Helix Bio', matchStatus: 'MANUAL_MATCH', numberOfSeats: 12, hostingType: 'CLOUD', subscriptionType: 'ORG', subscriptionFrequency: 'MONTHLY' },
{ name: 'Helix Bio — pipeline review', companyName: 'Helix Bio', matchStatus: 'AUTO_MATCH', numberOfSeats: 8 },
{ name: 'Acme RE — CRM rollout', companyName: 'Acme Real Estate', matchStatus: 'MATCHED', partnerSlug: 'elevate-consulting', numberOfSeats: 30, hostingType: 'CLOUD', subscriptionType: 'ENT', subscriptionFrequency: 'ANNUAL' },
{ name: 'Sunrise — APAC fleet CRM', companyName: 'Sunrise Logistics', matchStatus: 'INTRODUCED_TO_A_PARTNER', partnerSlug: 'nine-dots-ventures', numberOfSeats: 50, hostingType: 'SELF_HOSTING', subscriptionType: 'ENT', subscriptionFrequency: 'ANNUAL' },
{ name: 'Helix Bio — clinical trials CRM', companyName: 'Helix Bio', matchStatus: 'WORKING_WITH_A_PARTNER', partnerSlug: 'netzero-systems', numberOfSeats: 25, hostingType: 'CLOUD', subscriptionType: 'ORG', subscriptionFrequency: 'MONTHLY' },
{ name: 'Helix Bio — self-host evaluation', companyName: 'Helix Bio', matchStatus: 'IMPLEMENTING', partnerSlug: 'meridian-craft', numberOfSeats: 40, hostingType: 'SELF_HOSTING', subscriptionType: 'ENT', subscriptionFrequency: 'ANNUAL' },
{ name: 'Sunrise — LATAM expansion', companyName: 'Sunrise Logistics', matchStatus: 'WON', partnerSlug: 'nine-dots-ventures', numberOfSeats: 60, hostingType: 'CLOUD', subscriptionType: 'ENT', subscriptionFrequency: 'ANNUAL' },
{ name: 'Acme RE — annual review', companyName: 'Acme Real Estate', matchStatus: 'RECONNECT_LATER', partnerSlug: 'w3villa-technologies', numberOfSeats: 15 },
{ name: 'Sunrise — vendor onboarding', companyName: 'Sunrise Logistics', matchStatus: 'LOST', numberOfSeats: 10, hostingType: 'CLOUD', subscriptionType: 'PRO', subscriptionFrequency: 'MONTHLY' },
];
type Quote = { name: string; status: string; partnerSlug: string; oppName: string };
const QUOTES: Quote[] = [
{ name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — APAC fleet CRM' },
{ name: 'Helix clinical — NetZero quote', status: 'INTERVIEW_SCHEDULED', partnerSlug: 'netzero-systems', oppName: 'Helix Bio — clinical trials CRM' },
{ name: 'Acme rollout — Elevate quote', status: 'UNDER_CUSTOMER_PARTNER_REVIEW', partnerSlug: 'elevate-consulting', oppName: 'Acme RE — CRM rollout' },
{ name: 'Sunrise LATAM — Nine Dots quote', status: 'APPROVED', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — LATAM expansion' },
{ name: 'Helix self-host — Meridian quote', status: 'REJECTED', partnerSlug: 'meridian-craft', oppName: 'Helix Bio — self-host evaluation' },
];
const nodes = (r: any, key: string): any[] => (r?.[key]?.edges ?? []).map((e: any) => e.node);
async function main() {
const client = new CoreApiClient({
url: `${requireEnv('TWENTY_PARTNERS_API_URL').replace(/\/$/, '')}/graphql`,
headers: { Authorization: `Bearer ${requireEnv('TWENTY_PARTNERS_API_KEY')}` },
});
// -- Partners (upsert by slug) --
const existingPartners = nodes(
await client.query({ partners: { __args: { filter: { slug: { in: PARTNERS.map((p) => p.slug) } }, first: 100 }, edges: { node: { id: true, slug: true } } } } as any),
'partners',
);
const partnerIdBySlug = new Map<string, string>(existingPartners.map((n: any) => [n.slug, n.id]));
for (const p of PARTNERS) {
const data = {
name: p.name, slug: p.slug, validationStage: p.validationStage, availability: p.availability,
introduction: p.introduction, calendarLink: { primaryLinkUrl: p.calendarLink },
deploymentExpertise: p.deploymentExpertise, region: p.region, languagesSpoken: p.languagesSpoken,
partnerTier: p.partnerTier, partnerScope: p.partnerScope, typeOfTeam: p.typeOfTeam,
country: p.country,
};
const id = partnerIdBySlug.get(p.slug);
if (id) {
await client.mutation({ updatePartner: { __args: { id, data }, id: true } } as any);
} else {
const r: any = await client.mutation({ createPartner: { __args: { data }, id: true } } as any);
partnerIdBySlug.set(p.slug, r.createPartner.id);
}
}
console.log(`[seed] partners: ${partnerIdBySlug.size}`);
// -- Companies (upsert by name) --
const companyIdByName = new Map<string, string>();
for (const c of COMPANIES) {
const existing = nodes(await client.query({ companies: { __args: { filter: { name: { eq: c.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'companies');
let id = existing[0]?.id;
if (!id) {
const r: any = await client.mutation({ createCompany: { __args: { data: { name: c.name, domainName: { primaryLinkUrl: c.domain } } }, id: true } } as any);
id = r.createCompany.id;
}
companyIdByName.set(c.name, id);
}
// -- People (upsert by firstName+lastName) --
for (const person of PERSONS) {
const existing = nodes(await client.query({ people: { __args: { filter: { name: { firstName: { eq: person.firstName } } }, first: 10 }, edges: { node: { id: true, name: { firstName: true, lastName: true } } } } } as any), 'people');
const match = existing.find((n: any) => n.name?.firstName === person.firstName && n.name?.lastName === person.lastName);
if (!match) {
await client.mutation({ createPerson: { __args: { data: { name: { firstName: person.firstName, lastName: person.lastName }, emails: { primaryEmail: person.email }, city: person.city, companyId: companyIdByName.get(person.companyName) } }, id: true } } as any);
}
}
// -- Opportunities (upsert by name) --
const oppIdByName = new Map<string, string>();
for (const o of OPPORTUNITIES) {
const data: Record<string, unknown> = {
name: o.name, matchStatus: o.matchStatus, companyId: companyIdByName.get(o.companyName),
...(o.partnerSlug ? { partnerId: partnerIdBySlug.get(o.partnerSlug) } : {}),
...(o.numberOfSeats != null ? { numberOfSeats: o.numberOfSeats } : {}),
...(o.hostingType ? { hostingType: o.hostingType } : {}),
...(o.subscriptionType ? { subscriptionType: o.subscriptionType } : {}),
...(o.subscriptionFrequency ? { subscriptionFrequency: o.subscriptionFrequency } : {}),
};
const existing = nodes(await client.query({ opportunities: { __args: { filter: { name: { eq: o.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'opportunities');
let id = existing[0]?.id;
if (id) {
await client.mutation({ updateOpportunity: { __args: { id, data }, id: true } } as any);
} else {
const r: any = await client.mutation({ createOpportunity: { __args: { data }, id: true } } as any);
id = r.createOpportunity.id;
}
oppIdByName.set(o.name, id);
}
console.log(`[seed] opportunities: ${oppIdByName.size}`);
// -- Partner quotes (upsert by name) --
let quoteCount = 0;
for (const q of QUOTES) {
const data = { name: q.name, status: q.status, partnerId: partnerIdBySlug.get(q.partnerSlug), opportunityId: oppIdByName.get(q.oppName) };
const existing = nodes(await client.query({ partnerQuotes: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerQuotes');
if (existing[0]?.id) {
await client.mutation({ updatePartnerQuote: { __args: { id: existing[0].id, data }, id: true } } as any);
} else {
await client.mutation({ createPartnerQuote: { __args: { data }, id: true } } as any);
}
quoteCount++;
}
console.log(`[seed] partner quotes: ${quoteCount}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -1,36 +0,0 @@
import {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
ViewFilterOperand,
ViewType,
defineView,
} from 'twenty-sdk/define';
import {
ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER,
MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
// Opportunities where a partner is engaged (MATCHED or later). Distinct from
// Matches overview, which also includes AUTO_MATCH (in-flight, no partner yet).
export default defineView({
universalIdentifier: ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All matched deals',
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: ViewType.TABLE,
fields: [
{ universalIdentifier: '76f6aea5-0e0b-4787-84f0-430d0799e913', fieldMetadataUniversalIdentifier: '20202020-8609-4f65-a2d9-44009eb422b5', position: 0, isVisible: true },
{ universalIdentifier: 'd9862d49-eff8-4103-9f48-a193cf8e1de2', fieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER, position: 1, isVisible: true },
{ universalIdentifier: '91c42a01-4ec8-4527-b9ac-9bdeb58e7243', fieldMetadataUniversalIdentifier: 'd9eeacaa-2f9e-44cc-b5f6-5e1526256d49', position: 2, isVisible: true },
{ universalIdentifier: 'c9689260-86f5-4e19-a86c-7afc95d4d6fe', fieldMetadataUniversalIdentifier: 'cc6b8a59-f860-493f-8b9a-f138c078fbf1', position: 3, isVisible: true },
{ universalIdentifier: 'd51c2737-26f8-4f27-b078-7bb0cf58c662', fieldMetadataUniversalIdentifier: 'fcf39b0c-0547-415e-806d-b238131ad7cc', position: 4, isVisible: true },
],
filters: [
{
universalIdentifier: '71de9b3a-e59b-4baf-99e6-84fe01e037ee',
fieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
// See filter-syntax note in Task 8.1.
operand: ViewFilterOperand.IS,
value: ['MATCHED', 'INTRODUCED_TO_A_PARTNER', 'WORKING_WITH_A_PARTNER', 'IMPLEMENTING', 'WON', 'RECONNECT_LATER', 'LOST'],
},
],
});
@@ -1,31 +0,0 @@
import {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
ViewType,
defineView,
} from 'twenty-sdk/define';
import {
ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER,
MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
// Default Opportunities view replacement. Surfaces matchStatus + partner alongside
// the standard Opportunity columns. partnerEligible column was dropped as part of
// the match-status redesign.
export default defineView({
universalIdentifier: ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Opportunities',
icon: 'IconTargetArrow',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: ViewType.TABLE,
fields: [
{ universalIdentifier: '62844317-546c-4b65-a292-917bf0b5bfce', fieldMetadataUniversalIdentifier: '20202020-8609-4f65-a2d9-44009eb422b5', position: 0, isVisible: true },
{ universalIdentifier: '295a5b86-0b37-475a-8645-26f7e7a3dd0a', fieldMetadataUniversalIdentifier: '20202020-cbac-457e-b565-adece5fc815f', position: 1, isVisible: true },
{ universalIdentifier: 'ce684df4-4456-427c-b33b-38a34368e380', fieldMetadataUniversalIdentifier: '20202020-6f76-477d-8551-28cd65b2b4b9', position: 2, isVisible: true },
{ universalIdentifier: '9f72d1ce-7c39-418c-95cb-480d1b176821', fieldMetadataUniversalIdentifier: 'd9eeacaa-2f9e-44cc-b5f6-5e1526256d49', position: 3, isVisible: true },
{ universalIdentifier: '5db9ee26-8688-4a5c-9fe8-f76b41d8e80b', fieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER, position: 4, isVisible: true },
{ universalIdentifier: '3727d213-e3f5-43c7-ab05-b0fb2f211273', fieldMetadataUniversalIdentifier: '20202020-527e-44d6-b1ac-c4158d307b97', position: 5, isVisible: true },
{ universalIdentifier: 'c9ad9056-fd3a-448c-b4dc-e95e0c5d22e9', fieldMetadataUniversalIdentifier: '20202020-a63e-4a62-8e63-42a51828f831', position: 6, isVisible: true },
],
});
@@ -1,22 +0,0 @@
import { ViewType, defineView } from 'twenty-sdk/define';
import {
ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineView({
universalIdentifier: ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Partners',
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
fields: [
{ universalIdentifier: '21afcc69-09c5-42eb-a609-26c062de3bd3', fieldMetadataUniversalIdentifier: 'a0000001-0000-4000-8000-000000000001', position: 0, isVisible: true },
{ universalIdentifier: '529912f0-38fb-4821-92d3-8a0a68b9f340', fieldMetadataUniversalIdentifier: '2ca9856f-f54a-4326-9ff3-668fd7da0b50', position: 1, isVisible: true },
{ universalIdentifier: '68c6b96d-8c3d-4a3e-b4cd-3751d035b085', fieldMetadataUniversalIdentifier: 'a0000004-0000-4000-8000-000000000004', position: 2, isVisible: true },
{ universalIdentifier: '8862e4a5-525a-4a0c-8381-93ff0d01ccf0', fieldMetadataUniversalIdentifier: 'a0000007-0000-4000-8000-000000000007', position: 3, isVisible: true },
{ universalIdentifier: '4ebe0b9d-0c2d-4416-b187-150b02473a01', fieldMetadataUniversalIdentifier: 'a0000010-0000-4000-8000-000000000010', position: 4, isVisible: true },
{ universalIdentifier: '52408b5f-5e13-4e3c-af2d-ce50033ec126', fieldMetadataUniversalIdentifier: '560503de-6330-4c1d-af97-a8dee125f2ad', position: 5, isVisible: true },
{ universalIdentifier: '02cc471b-e9ba-4643-b403-40299d6bbbdd', fieldMetadataUniversalIdentifier: 'a0000005-0000-4000-8000-000000000005', position: 6, isVisible: true },
],
});
@@ -1,29 +0,0 @@
import {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
ViewType,
defineView,
} from 'twenty-sdk/define';
import {
MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
MATCHES_OVERVIEW_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
// Full matching funnel: a Kanban grouped by matchStatus, no filter (every
// opportunity appears in its matchStatus column).
export default defineView({
universalIdentifier: MATCHES_OVERVIEW_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Matches overview',
icon: 'IconLayoutKanban',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: ViewType.KANBAN,
mainGroupByFieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{ universalIdentifier: '7a6403c1-7ab9-4c3a-b833-3c028d43140e', fieldMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.name.universalIdentifier, position: 0, isVisible: true },
{ universalIdentifier: '2e718b4b-fde8-4839-9cdf-deb09db0e6b6', fieldMetadataUniversalIdentifier: 'd9eeacaa-2f9e-44cc-b5f6-5e1526256d49', position: 1, isVisible: true },
{ universalIdentifier: '5ae8805c-3d71-4ccc-a2be-38368f32e3e1', fieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER, position: 2, isVisible: true },
{ universalIdentifier: 'cb4e5d2b-7003-4f30-874c-acda310b250c', fieldMetadataUniversalIdentifier: 'fcf39b0c-0547-415e-806d-b238131ad7cc', position: 3, isVisible: true },
{ universalIdentifier: '0fc87e70-7aa1-4c85-9152-d0edff8ae8a4', fieldMetadataUniversalIdentifier: 'cc6b8a59-f860-493f-8b9a-f138c078fbf1', position: 4, isVisible: true },
],
});
@@ -1,29 +0,0 @@
import { ViewFilterOperand, ViewType, defineView } from 'twenty-sdk/define';
import {
PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
// Partners still in the application stage (not yet validated).
export default defineView({
universalIdentifier: PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Partner applications',
icon: 'IconUserPlus',
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
fields: [
{ universalIdentifier: 'b4f505d7-3849-4a74-a27f-1c91733702b5', fieldMetadataUniversalIdentifier: 'a0000001-0000-4000-8000-000000000001', position: 0, isVisible: true },
{ universalIdentifier: '8a39e510-e533-4cd7-9b65-5e16b5f773d0', fieldMetadataUniversalIdentifier: '2ca9856f-f54a-4326-9ff3-668fd7da0b50', position: 1, isVisible: true },
{ universalIdentifier: 'b92d7fd4-4a24-4333-89fe-5d726634d428', fieldMetadataUniversalIdentifier: 'd4fa6461-37b6-49ee-9181-dd482e74a70b', position: 2, isVisible: true },
{ universalIdentifier: '2c34a120-b0f8-421b-9546-6483f1202d9f', fieldMetadataUniversalIdentifier: 'a77d7fa6-c398-47db-af0f-036a5c719f20', position: 3, isVisible: true },
],
filters: [
{
universalIdentifier: '210deb57-cfca-4086-b9da-ca346fbd3126',
fieldMetadataUniversalIdentifier: '2ca9856f-f54a-4326-9ff3-668fd7da0b50',
operand: ViewFilterOperand.IS,
value: ['APPLICATION'],
},
],
});
@@ -1,21 +0,0 @@
import { ViewType, defineView } from 'twenty-sdk/define';
import {
PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
// Index view for partner quotes.
export default defineView({
universalIdentifier: PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Partner quotes',
icon: 'IconFileDollar',
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
fields: [
{ universalIdentifier: 'ad7b3702-b552-4355-afec-1e1e96d9f3df', fieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6', position: 0, isVisible: true },
{ universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 1, isVisible: true },
{ universalIdentifier: '62bb1536-539c-4fb0-95bf-440c5c5da89f', fieldMetadataUniversalIdentifier: '2cbe67e3-24ec-421b-bab5-50f3306c2391', position: 2, isVisible: true },
{ universalIdentifier: 'fbd1f953-1dd2-4d0f-a239-148a0688fbff', fieldMetadataUniversalIdentifier: 'b52d263e-423e-40b0-b82c-29214597c005', position: 3, isVisible: true },
],
});
@@ -1,30 +0,0 @@
import { ViewFilterOperand, ViewType, defineView } from 'twenty-sdk/define';
import {
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
// Validated partners — serves both partner intros and the public website list.
export default defineView({
universalIdentifier: VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Validated partners',
icon: 'IconCircleCheck',
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
fields: [
{ universalIdentifier: '9463bf58-69d5-4309-bc6e-4835df346246', fieldMetadataUniversalIdentifier: 'a0000001-0000-4000-8000-000000000001', position: 0, isVisible: true },
{ universalIdentifier: 'c8fd88c5-ffa9-4944-b5b9-deec3acd3dff', fieldMetadataUniversalIdentifier: 'd4fa6461-37b6-49ee-9181-dd482e74a70b', position: 1, isVisible: true },
{ universalIdentifier: '9cb93542-2a91-4e75-a9f8-4a1866445322', fieldMetadataUniversalIdentifier: '500021ad-ca42-4fd3-8727-392dd26b722a', position: 2, isVisible: true },
{ universalIdentifier: 'd6df98ac-9c6c-46c2-8784-d4e1d6521f75', fieldMetadataUniversalIdentifier: '560503de-6330-4c1d-af97-a8dee125f2ad', position: 3, isVisible: true },
{ universalIdentifier: '375cc871-fdda-42d0-8308-e60174b6d467', fieldMetadataUniversalIdentifier: 'a0000004-0000-4000-8000-000000000004', position: 4, isVisible: true },
],
filters: [
{
universalIdentifier: 'a3b7e215-693d-420c-b444-37d4109ca535',
fieldMetadataUniversalIdentifier: '2ca9856f-f54a-4326-9ff3-668fd7da0b50',
operand: ViewFilterOperand.IS,
value: ['VALIDATED'],
},
],
});
@@ -1,48 +0,0 @@
import {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
ViewFilterOperand,
ViewSortDirection,
ViewType,
defineView,
} from 'twenty-sdk/define';
import {
MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
WAITING_FOR_MATCH_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
// Ops inbox: opportunities awaiting a human matching decision.
// Includes TO_BE_MATCHED (default for new opps) and MANUAL_MATCH
// (opps that auto-match couldn't resolve or that ops opted to handle manually).
export default defineView({
universalIdentifier: WAITING_FOR_MATCH_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Waiting for match',
icon: 'IconClockHour4',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: ViewType.KANBAN,
mainGroupByFieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{ universalIdentifier: 'd74b5eb3-21ee-48fa-b703-4cfd629738b4', fieldMetadataUniversalIdentifier: '20202020-8609-4f65-a2d9-44009eb422b5', position: 0, isVisible: true },
{ universalIdentifier: '50822cf9-c238-4450-ba31-5807011afa65', fieldMetadataUniversalIdentifier: '20202020-cbac-457e-b565-adece5fc815f', position: 1, isVisible: true },
{ universalIdentifier: 'f432a71f-3bd0-495e-b5b1-8a78e155dc5a', fieldMetadataUniversalIdentifier: '20202020-d01b-4132-9b32-123456789abc', position: 2, isVisible: true },
{ universalIdentifier: '9e47592f-9965-4ee7-9c6a-303477b293f4', fieldMetadataUniversalIdentifier: 'cc6b8a59-f860-493f-8b9a-f138c078fbf1', position: 3, isVisible: true },
// matchStatus column (replaces the dropped partnerEligible column)
{ universalIdentifier: '909e1eee-077a-4f23-8c9b-4c8027623a78', fieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER, position: 4, isVisible: true },
],
filters: [
{
universalIdentifier: '93476207-1471-49d9-898c-f8a1d52f468f',
fieldMetadataUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
operand: ViewFilterOperand.IS,
value: ['TO_BE_MATCHED', 'MANUAL_MATCH'],
},
],
sorts: [
{
universalIdentifier: 'a7c5a89e-d9d7-4cf6-a6d2-3ad9f12a7b1f',
fieldMetadataUniversalIdentifier: '20202020-d01b-4132-9b32-123456789abc',
direction: ViewSortDirection.ASC,
},
],
});
@@ -1,42 +0,0 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.integration-test.ts"
],
"references": [
{
"path": "./tsconfig.spec.json"
}
]
}
@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}
@@ -1,40 +0,0 @@
import { loadEnv } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
// Integration tests authenticate to a local Twenty server. Credentials are
// resolved from the shell env, then a gitignored .env.local in this directory
// (see .env.example). No API key is committed; if none is found, global-setup
// fails with a clear message.
const fileEnv = loadEnv('test', process.cwd(), 'TWENTY_');
const TWENTY_API_URL =
process.env.TWENTY_API_URL ?? fileEnv.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_KEY = process.env.TWENTY_API_KEY ?? fileEnv.TWENTY_API_KEY;
// Make env available to globalSetup (runs in the main process); test.env below
// covers the worker processes.
process.env.TWENTY_API_URL = TWENTY_API_URL;
if (TWENTY_API_KEY) {
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
}
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
fileParallelism: false,
include: ['src/**/*.integration-test.ts'],
globalSetup: ['src/__tests__/global-setup.ts'],
env: {
TWENTY_API_URL,
...(TWENTY_API_KEY ? { TWENTY_API_KEY } : {}),
},
},
});
File diff suppressed because it is too large Load Diff

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