Compare commits

..
Author SHA1 Message Date
neo773 ea04a9ab0d move docs to oxfmt 2026-05-21 01:06:20 +05:30
neo773 8049394c97 move to oxfmt 2026-05-21 00:32:03 +05:30
2998 changed files with 69298 additions and 62232 deletions
+1 -2
View File
@@ -21,7 +21,6 @@
"**/*.md",
"**/*.mdx",
"**/seed-project/**/*.mjs",
"packages/twenty-zapier/build/**",
"**/upgrade-version-command/**"
"packages/twenty-zapier/build/**"
]
}
-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,6 +0,0 @@
# Credentials for integration tests (vitest.config.ts) and seed scripts
# (vitest.seed.config.ts). Copy this file to .env.local and fill in the key.
# Get an API key from the Twenty UI: Settings -> APIs & Webhooks.
# .env.local is gitignored; never commit a real key.
TWENTY_API_URL=http://localhost:2020
TWENTY_API_KEY=
@@ -1,42 +0,0 @@
name: CD
on:
push:
branches:
- main
pull_request:
types: [labeled]
permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:2020
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-and-install:
if: >-
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
- name: Install
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
@@ -1,48 +0,0 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty test instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
@@ -1,43 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
# internal planning docs
docs/superpowers/
**/docs/superpowers/
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# typescript
*.tsbuildinfo
*.d.ts
@@ -1 +0,0 @@
24.5.0
@@ -1,37 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
},
"overrides": [
{
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["twenty-shared", "twenty-shared/*"],
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
}
]
}
]
}
}
]
}
@@ -1 +0,0 @@
nodeLinker: node-modules
@@ -1,101 +0,0 @@
# twenty-partners
A Twenty app that turns the CRM into the operating system for the Twenty partner program:
intake partner-eligible deals, match them to vetted marketplace partners, and track the
matching pipeline end-to-end.
Built on [Twenty](https://twenty.com) with [`twenty-sdk`](https://www.npmjs.com/package/twenty-sdk) v2.5.
## What's inside
- **Custom object: `Partner`** — slug, status, availability, served geos, languages spoken,
deployment expertise, Calendly link, last-match timestamp. See `src/objects/partner.object.ts`.
- **Opportunity extensions** — `matchStatus`, `designDocStatus`,
`introSentAt`, `lastRelanceSentAt`, `tftId`, plus a `partner` relation.
- **Logic functions**
- `on-opportunity-auto-match` — fires when `matchStatus` is set to `AUTO_MATCH`. Assigns the longest-idle available partner and flips status to `MATCHED`. If no partner is available, hands off to `MANUAL_MATCH` with an audit Note explaining why.
- `list-available-partners` — surfaces matchable partners for a given opportunity.
- `post-install` — first-run setup.
- **Roles** (`src/roles/`)
- **Twenty Partner Ops** — internal team role, full CRUD on Partner/Company/Person/Opportunity.
- **Partner** — placeholder external-partner role. *Do not assign until Twenty ships
row-level permissions* — it currently grants access to every record.
- **Views** (`src/views/`)
- `Waiting for match` — opportunities awaiting human action (`matchStatus` is `TO_BE_MATCHED` or `MANUAL_MATCH`).
- `Matches overview` — full matching funnel grouped by `matchStatus` (configure Kanban
grouping manually in the UI).
- `Opportunities` — replacement of the native opportunities view with the partner columns.
- `Partners` and `All matched deals` — partner-side index and deal log.
- **Sidebar nav** — surfaced in workflow order: `Waiting for match`, `All partner deals`,
`Matches overview`, `Partners`, `Opportunities`.
- **Seed scripts** (`src/scripts/`) — populate a fresh workspace with realistic demo data.
## Match status pipeline
`matchStatus` is a non-nullable SELECT field with a default of `TO_BE_MATCHED`. The 10 states follow the deal lifecycle:
| Status | Meaning |
| --- | --- |
| `TO_BE_MATCHED` | Default — deal entered, awaiting assignment |
| `MANUAL_MATCH` | Needs a human to pick a partner |
| `AUTO_MATCH` | Triggers automatic partner assignment |
| `MATCHED` | Partner assigned |
| `INTRODUCED_TO_A_PARTNER` | Customer intro sent |
| `WORKING_WITH_A_PARTNER` | Engagement underway |
| `IMPLEMENTING` | Active implementation |
| `WON` | Deal closed won |
| `RECONNECT_LATER` | Paused — reconnect in future |
| `LOST` | Deal closed lost |
## Getting started
Requires a local Twenty server at `http://localhost:2020` and Node `^24.5`.
```bash
yarn install
yarn twenty dev
```
Default dev credentials: `tim@apple.dev` / `tim@apple.dev`.
Run `yarn twenty help` for the full CLI reference.
## Common commands
| Command | What it does |
| --- | --- |
| `yarn twenty dev` | Start the dev server and sync the app on file changes |
| `yarn twenty server status` | Check the local Twenty server |
| `yarn lint` / `yarn lint:fix` | Run oxlint |
| `yarn test` | Run integration tests (`vitest.config.ts`) |
## Seeding demo data
Two idempotent seed scripts. Both run via the `vitest.seed.config.ts` config that skips
the global app uninstall/reinstall.
```bash
# 1. Marketplace partners (run first — pipeline seed wires opportunities to these by slug)
yarn vitest run --config vitest.seed.config.ts src/scripts/seed-marketplace-partners.ts
# 2. Pipeline demo: 3 companies, 3 people, 15 opportunities spread across matchStatus values
yarn vitest run --config vitest.seed.config.ts src/scripts/seed-pipeline-demo.ts
```
Both scripts skip records that already exist (by `slug`, `name`, or `firstName+lastName`),
so they are safe to re-run.
## Known limitations
Current SDK gaps blocking further polish:
- Custom Partner record page layout (RECORD_TABLE has no relation scoping).
- Native Opportunities view column-order override.
- Kanban view configuration from app code (`ViewType.KANBAN` is currently ignored).
- App and field descriptions.
## Learn more
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)
- [`twenty-sdk` on npm](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)
@@ -1,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
@@ -1764,18 +1764,6 @@ type WorkspaceUrls {
subdomainUrl: String!
}
type ApplicationRegistrationVariableDTO {
id: UUID!
key: String!
value: String
description: String!
isSecret: Boolean!
isRequired: Boolean!
isFilled: Boolean!
createdAt: DateTime!
updatedAt: DateTime!
}
type BillingTrialPeriod {
duration: Float!
isCreditCardRequired: Boolean!
@@ -1980,6 +1968,18 @@ type RotateClientSecret {
clientSecret: String!
}
type ApplicationRegistrationVariableDTO {
id: UUID!
key: String!
value: String
description: String!
isSecret: Boolean!
isRequired: Boolean!
isFilled: Boolean!
createdAt: DateTime!
updatedAt: DateTime!
}
type Relation {
type: RelationType!
sourceObjectMetadata: Object!
@@ -3294,6 +3294,7 @@ type Mutation {
updateWorkspace(data: UpdateWorkspaceInput!): Workspace!
deleteCurrentWorkspace: Workspace!
checkCustomDomainValidRecords: DomainValidRecords
installApplication(appRegistrationId: String!, version: String): Boolean!
runWorkspaceMigration(workspaceMigration: WorkspaceMigrationInput!): Boolean!
uninstallApplication(universalIdentifier: String!): Boolean!
createOIDCIdentityProvider(input: SetupOIDCSsoInput!): SetupSso!
@@ -3314,8 +3315,7 @@ type Mutation {
deleteEmailingDomain(id: String!): Boolean!
verifyEmailingDomain(id: String!): EmailingDomain!
createOneAppToken(input: CreateOneAppTokenInput!): AppToken!
installMarketplaceApp(universalIdentifier: String!, version: String): Boolean! @deprecated(reason: "Use installApplication instead")
installApplication(universalIdentifier: String!, version: String): Application!
installMarketplaceApp(universalIdentifier: String!, version: String): Boolean!
syncMarketplaceCatalog: Boolean!
createDevelopmentApplication(universalIdentifier: String!, name: String!): DevelopmentApplication!
generateApplicationToken(applicationId: UUID!): ApplicationTokenPair!
@@ -1401,19 +1401,6 @@ export interface WorkspaceUrls {
__typename: 'WorkspaceUrls'
}
export interface ApplicationRegistrationVariableDTO {
id: Scalars['UUID']
key: Scalars['String']
value?: Scalars['String']
description: Scalars['String']
isSecret: Scalars['Boolean']
isRequired: Scalars['Boolean']
isFilled: Scalars['Boolean']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'ApplicationRegistrationVariableDTO'
}
export interface BillingTrialPeriod {
duration: Scalars['Float']
isCreditCardRequired: Scalars['Boolean']
@@ -1622,6 +1609,19 @@ export interface RotateClientSecret {
__typename: 'RotateClientSecret'
}
export interface ApplicationRegistrationVariableDTO {
id: Scalars['UUID']
key: Scalars['String']
value?: Scalars['String']
description: Scalars['String']
isSecret: Scalars['Boolean']
isRequired: Scalars['Boolean']
isFilled: Scalars['Boolean']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'ApplicationRegistrationVariableDTO'
}
export interface Relation {
type: RelationType
sourceObjectMetadata: Object
@@ -2827,6 +2827,7 @@ export interface Mutation {
updateWorkspace: Workspace
deleteCurrentWorkspace: Workspace
checkCustomDomainValidRecords?: DomainValidRecords
installApplication: Scalars['Boolean']
runWorkspaceMigration: Scalars['Boolean']
uninstallApplication: Scalars['Boolean']
createOIDCIdentityProvider: SetupSso
@@ -2847,9 +2848,7 @@ export interface Mutation {
deleteEmailingDomain: Scalars['Boolean']
verifyEmailingDomain: EmailingDomain
createOneAppToken: AppToken
/** @deprecated Use installApplication instead */
installMarketplaceApp: Scalars['Boolean']
installApplication: Application
syncMarketplaceCatalog: Scalars['Boolean']
createDevelopmentApplication: DevelopmentApplication
generateApplicationToken: ApplicationTokenPair
@@ -4333,20 +4332,6 @@ export interface WorkspaceUrlsGenqlSelection{
__scalar?: boolean | number
}
export interface ApplicationRegistrationVariableDTOGenqlSelection{
id?: boolean | number
key?: boolean | number
value?: boolean | number
description?: boolean | number
isSecret?: boolean | number
isRequired?: boolean | number
isFilled?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface BillingTrialPeriodGenqlSelection{
duration?: boolean | number
isCreditCardRequired?: boolean | number
@@ -4568,6 +4553,20 @@ export interface RotateClientSecretGenqlSelection{
__scalar?: boolean | number
}
export interface ApplicationRegistrationVariableDTOGenqlSelection{
id?: boolean | number
key?: boolean | number
value?: boolean | number
description?: boolean | number
isSecret?: boolean | number
isRequired?: boolean | number
isFilled?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface RelationGenqlSelection{
type?: boolean | number
sourceObjectMetadata?: ObjectGenqlSelection
@@ -5880,6 +5879,7 @@ export interface MutationGenqlSelection{
updateWorkspace?: (WorkspaceGenqlSelection & { __args: {data: UpdateWorkspaceInput} })
deleteCurrentWorkspace?: WorkspaceGenqlSelection
checkCustomDomainValidRecords?: DomainValidRecordsGenqlSelection
installApplication?: { __args: {appRegistrationId: Scalars['String'], version?: (Scalars['String'] | null)} }
runWorkspaceMigration?: { __args: {workspaceMigration: WorkspaceMigrationInput} }
uninstallApplication?: { __args: {universalIdentifier: Scalars['String']} }
createOIDCIdentityProvider?: (SetupSsoGenqlSelection & { __args: {input: SetupOIDCSsoInput} })
@@ -5900,9 +5900,7 @@ export interface MutationGenqlSelection{
deleteEmailingDomain?: { __args: {id: Scalars['String']} }
verifyEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {id: Scalars['String']} })
createOneAppToken?: (AppTokenGenqlSelection & { __args: {input: CreateOneAppTokenInput} })
/** @deprecated Use installApplication instead */
installMarketplaceApp?: { __args: {universalIdentifier: Scalars['String'], version?: (Scalars['String'] | null)} }
installApplication?: (ApplicationGenqlSelection & { __args: {universalIdentifier: Scalars['String'], version?: (Scalars['String'] | null)} })
syncMarketplaceCatalog?: boolean | number
createDevelopmentApplication?: (DevelopmentApplicationGenqlSelection & { __args: {universalIdentifier: Scalars['String'], name: Scalars['String']} })
generateApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationId: Scalars['UUID']} })
@@ -7185,14 +7183,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const ApplicationRegistrationVariableDTO_possibleTypes: string[] = ['ApplicationRegistrationVariableDTO']
export const isApplicationRegistrationVariableDTO = (obj?: { __typename?: any } | null): obj is ApplicationRegistrationVariableDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationRegistrationVariableDTO"')
return ApplicationRegistrationVariableDTO_possibleTypes.includes(obj.__typename)
}
const BillingTrialPeriod_possibleTypes: string[] = ['BillingTrialPeriod']
export const isBillingTrialPeriod = (obj?: { __typename?: any } | null): obj is BillingTrialPeriod => {
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingTrialPeriod"')
@@ -7377,6 +7367,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const ApplicationRegistrationVariableDTO_possibleTypes: string[] = ['ApplicationRegistrationVariableDTO']
export const isApplicationRegistrationVariableDTO = (obj?: { __typename?: any } | null): obj is ApplicationRegistrationVariableDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationRegistrationVariableDTO"')
return ApplicationRegistrationVariableDTO_possibleTypes.includes(obj.__typename)
}
const Relation_possibleTypes: string[] = ['Relation']
export const isRelation = (obj?: { __typename?: any } | null): obj is Relation => {
if (!obj?.__typename) throw new Error('__typename is missing in "isRelation"')
@@ -51,11 +51,11 @@ export default {
163,
167,
169,
173,
174,
175,
182,
185,
188,
181,
184,
187,
201,
226,
262,
@@ -3434,38 +3434,6 @@ export default {
1
]
},
"ApplicationRegistrationVariableDTO": {
"id": [
3
],
"key": [
1
],
"value": [
1
],
"description": [
1
],
"isSecret": [
6
],
"isRequired": [
6
],
"isFilled": [
6
],
"createdAt": [
4
],
"updatedAt": [
4
],
"__typename": [
1
]
},
"BillingTrialPeriod": {
"duration": [
11
@@ -3485,10 +3453,10 @@ export default {
1
],
"type": [
174
173
],
"status": [
175
174
],
"issuer": [
1
@@ -3501,7 +3469,7 @@ export default {
"SSOIdentityProviderStatus": {},
"AuthProviders": {
"sso": [
173
172
],
"google": [
6
@@ -3538,10 +3506,10 @@ export default {
3
],
"authProviders": [
176
175
],
"authBypassProviders": [
177
176
],
"logo": [
1
@@ -3589,7 +3557,7 @@ export default {
1
],
"modelFamily": [
182
181
],
"modelFamilyLabel": [
1
@@ -3604,7 +3572,7 @@ export default {
11
],
"nativeCapabilities": [
180
179
],
"isDeprecated": [
6
@@ -3640,7 +3608,7 @@ export default {
1
],
"trialPeriods": [
172
171
],
"__typename": [
1
@@ -3648,7 +3616,7 @@ export default {
},
"Support": {
"supportDriver": [
185
184
],
"supportFrontChatId": [
1
@@ -3674,7 +3642,7 @@ export default {
},
"Captcha": {
"provider": [
188
187
],
"siteKey": [
1
@@ -3711,7 +3679,7 @@ export default {
169
],
"metadata": [
190
189
],
"__typename": [
1
@@ -3736,13 +3704,13 @@ export default {
1
],
"authProviders": [
176
175
],
"billing": [
183
182
],
"aiModels": [
181
180
],
"signInPrefilled": [
6
@@ -3763,25 +3731,25 @@ export default {
6
],
"support": [
184
183
],
"isAttachmentPreviewEnabled": [
6
],
"sentry": [
186
185
],
"captcha": [
187
186
],
"api": [
189
188
],
"canManageFeatureFlags": [
6
],
"publicFeatureFlags": [
191
190
],
"isMicrosoftMessagingEnabled": [
6
@@ -3820,7 +3788,7 @@ export default {
6
],
"maintenance": [
192
191
],
"__typename": [
1
@@ -3859,7 +3827,7 @@ export default {
1
],
"versionDistribution": [
195
194
],
"__typename": [
1
@@ -3904,6 +3872,38 @@ export default {
1
]
},
"ApplicationRegistrationVariableDTO": {
"id": [
3
],
"key": [
1
],
"value": [
1
],
"description": [
1
],
"isSecret": [
6
],
"isRequired": [
6
],
"isFilled": [
6
],
"createdAt": [
4
],
"updatedAt": [
4
],
"__typename": [
1
]
},
"Relation": {
"type": [
201
@@ -4095,7 +4095,7 @@ export default {
3
],
"type": [
174
173
],
"issuer": [
1
@@ -4104,7 +4104,7 @@ export default {
1
],
"status": [
175
174
],
"__typename": [
1
@@ -4123,7 +4123,7 @@ export default {
},
"FindAvailableSSOIDP": {
"type": [
174
173
],
"id": [
3
@@ -4135,7 +4135,7 @@ export default {
1
],
"status": [
175
174
],
"workspace": [
218
@@ -4149,7 +4149,7 @@ export default {
3
],
"type": [
174
173
],
"issuer": [
1
@@ -4158,7 +4158,7 @@ export default {
1
],
"status": [
175
174
],
"__typename": [
1
@@ -4166,7 +4166,7 @@ export default {
},
"SSOConnection": {
"type": [
174
173
],
"id": [
3
@@ -4178,7 +4178,7 @@ export default {
1
],
"status": [
175
174
],
"__typename": [
1
@@ -4529,13 +4529,13 @@ export default {
},
"UsageAnalytics": {
"usageByUser": [
194
193
],
"usageByOperationType": [
194
193
],
"usageByModel": [
194
193
],
"timeSeries": [
251
@@ -6293,7 +6293,7 @@ export default {
}
],
"findApplicationRegistrationByClientId": [
198,
197,
{
"clientId": [
1,
@@ -6323,7 +6323,7 @@ export default {
}
],
"findApplicationRegistrationStats": [
196,
195,
{
"id": [
1,
@@ -6332,7 +6332,7 @@ export default {
}
],
"findApplicationRegistrationVariables": [
171,
199,
{
"applicationRegistrationId": [
1,
@@ -6356,7 +6356,7 @@ export default {
75
],
"getPublicWorkspaceDataByDomain": [
178,
177,
{
"origin": [
1
@@ -6364,7 +6364,7 @@ export default {
}
],
"getPublicWorkspaceDataById": [
179,
178,
{
"id": [
3,
@@ -8197,7 +8197,7 @@ export default {
}
],
"createApplicationRegistration": [
197,
196,
{
"input": [
443,
@@ -8224,7 +8224,7 @@ export default {
}
],
"rotateApplicationRegistrationClientSecret": [
199,
198,
{
"id": [
1,
@@ -8388,6 +8388,18 @@ export default {
"checkCustomDomainValidRecords": [
228
],
"installApplication": [
6,
{
"appRegistrationId": [
1,
"String!"
],
"version": [
1
]
}
],
"runWorkspaceMigration": [
6,
{
@@ -8601,18 +8613,6 @@ export default {
]
}
],
"installApplication": [
58,
{
"universalIdentifier": [
1,
"String!"
],
"version": [
1
]
}
],
"syncMarketplaceCatalog": [
6
],
@@ -11010,7 +11010,7 @@ export default {
3
],
"status": [
175
174
],
"__typename": [
1
+1 -5
View File
@@ -86,11 +86,7 @@ RUN if [ -d /app/packages/twenty-front/build ]; then \
FROM node:24.15.0-alpine3.23@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS twenty-server
RUN apk add --no-cache \
'curl>=8.19.0-r0' \
'nghttp2-libs>=1.69.0-r0' \
'postgresql18-client>=18.4-r0' \
jq
RUN apk add --no-cache curl jq postgresql-client
COPY ./packages/twenty-docker/twenty/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "../../node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf",
"printWidth": 80,
"sortPackageJson": false,
"ignorePatterns": [
"dist/**",
"build/**",
".next/**",
"coverage/**",
"generated/**",
".cache/**",
"node_modules/**",
],
}
+8
View File
@@ -3,18 +3,21 @@
## What Was Migrated
### Documentation Files
- **69 MDX files** copied from twenty-website to twenty-docs
- **45 User Guide articles**
- **22 Developer documentation articles**
- **2 Getting Started guides** (existing)
### Images & Assets
- **81 images** copied to `public/images/`
- User guide screenshots
- Developer documentation images
- Logo and branding assets
### Navigation Structure
- Complete `mint.json` configuration with tabs and nested navigation
- User Guide tab with 11 sections
- Developers tab with 6 sections
@@ -22,12 +25,15 @@
## Components Converted
### Custom Components → Mintlify Equivalents
- `<ArticleWarning>``<Warning>`
- `<ArticleLink href="...">text</ArticleLink>``[text](...)`
- `<ArticleEditContent>` → Removed (not needed)
### Still Need Manual Review
Some components may need additional conversion:
- `<ArticleTabs>` - Mintlify uses `<Tabs>` component
- Embedded iframes/videos - May need adjustment
- Custom styled elements - Review for Mintlify compatibility
@@ -63,6 +69,7 @@ packages/twenty-docs/
## Testing
Start the local Mintlify dev server:
```bash
npx nx run twenty-docs:dev
```
@@ -72,6 +79,7 @@ Open http://localhost:3000 to preview all migrated documentation.
## Deployment
To deploy to Mintlify:
1. Push changes to GitHub
2. Connect the repo in Mintlify dashboard
3. Set subdirectory to `packages/twenty-docs`
+2
View File
@@ -9,6 +9,7 @@ Visit the documentation at [docs.twenty.com](https://docs.twenty.com)
## 📚 Content
This repository contains:
- **User Guide** (46 pages) - Complete guide for Twenty users
- **Developers** (24 pages) - Technical documentation for developers
- **Twenty UI** (25 pages) - UI component library documentation
@@ -57,6 +58,7 @@ Your content here...
1. Place images in the `/images/` directory
2. Reference them in MDX: `![Alt text](/images/your-image.png)`
3. Or use Mintlify Frame component:
```mdx
<Frame>
<img src="/images/your-image.png" alt="Description" />
@@ -2,7 +2,6 @@
title: Best Practices
---
This document outlines the best practices you should follow when working on the backend.
## Follow a modular approach
@@ -21,4 +20,3 @@ You should also expose services that you want to use in other modules. Exposing
When you declare a variable as `any`, TypeScript's type checker doesn't perform any type checking, making it possible to assign any type of values to the variable. TypeScript uses type inference to determine the type of variable based on the value. By declaring it as `any`, TypeScript can no longer infer the type. This makes it hard to catch type-related errors during development, leading to runtime errors and makes the code less maintainable, less reliable, and harder to understand for others.
This is why everything should have a type. So if you create a new object with a first name and last name, you should create an interface or type that contains a first name and last name that defines the shape of the object you are manipulating.
@@ -2,21 +2,22 @@
title: Custom Objects
---
Objects are structures that allow you to store data (records, attributes, and values) specific to an organization. Twenty provides both standard and custom objects.
Standard objects are in-built objects with a set of attributes available for all users. Examples of standard objects in Twenty include Company and Person. Standard objects have standard fields that are also available for all Twenty users, like Company.displayName.
Custom objects are objects that you can create to store information that is unique to your organization. They are not built-in; members of your workspace can create and customize custom objects to hold information that standard objects aren't suitable for.
## High-level schema
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/custom-object-schema.png" alt="High level schema" />
<div style={{ textAlign: 'center' }}>
<img
src="/images/docs/server/custom-object-schema.png"
alt="High level schema"
/>
</div>
<br/>
<br />
## How it works
@@ -27,15 +28,21 @@ Custom objects come from metadata tables that determine the shape, name, and typ
- **Field**: Outlines an Object's fields and connects to the Object.
To add a custom object, the workspaceMember will query the /metadata API. This updates the metadata accordingly and computes a GraphQL schema based on the metadata, storing it in a GQL cache for later use.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/add-custom-objects.jpeg" alt="Query the /metadata API to add custom objects" />
<div style={{ textAlign: 'center' }}>
<img
src="/images/docs/server/add-custom-objects.jpeg"
alt="Query the /metadata API to add custom objects"
/>
</div>
<br/>
<br />
To fetch data, the process involves making queries through the /graphql endpoint and passing them through the Query Resolver.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/custom-object-schema.png" alt="Query the /graphql endpoint to fetch data" />
<div style={{ textAlign: 'center' }}>
<img
src="/images/docs/server/custom-object-schema.png"
alt="Query the /graphql endpoint to fetch data"
/>
</div>
@@ -2,7 +2,6 @@
title: Feature Flags
---
Feature flags are used to hide experimental features. For Twenty, they are set on workspace level and not on a user level.
## Adding a new feature flag
@@ -24,8 +23,6 @@ enum FeatureFlagKeys {
}
```
To apply a feature flag on a **backend** feature use:
```ts
@@ -40,12 +37,10 @@ To apply a feature flag on a **frontend** feature use:
const isFeatureNameEnabled = useIsFeatureEnabled('IS_FEATURENAME_ENABLED');
```
## Configure feature flags for the deployment
Change the corresponding record in the Table `core.featureFlag`:
| id | key | workspaceId | value |
|----------|--------------------------|---------------|--------|
| Random | `IS_FEATURENAME_ENABLED` | WorkspaceID | `true` |
| id | key | workspaceId | value |
| ------ | ------------------------ | ----------- | ------ |
| Random | `IS_FEATURENAME_ENABLED` | WorkspaceID | `true` |
@@ -3,7 +3,6 @@ title: Folder Architecture
info: A detailed look into our server folder architecture
---
The backend directory structure is as follows:
```
@@ -84,7 +83,6 @@ workspace
└───workspace.factory.ts
```
The root of the workspace directory includes the `workspace.factory.ts`, a file containing the `createGraphQLSchema` function. This function generates workspace-specific schema by using the metadata to tailor a schema for individual workspaces. By separating the schema and resolver construction, we use the `makeExecutableSchema` function, which combines these discrete elements.
This strategy is not just about organization, but also helps with optimization, such as caching generated type definitions to enhance performance and scalability.
@@ -96,6 +94,7 @@ Generates the GraphQL schema, and includes:
#### Factories:
Specialised constructors to generate GraphQL-related constructs.
- The type.factory translates field metadata into GraphQL types using `TypeMapperService`.
- The type-definition.factory creates GraphQL input or output objects derived from `objectMetadata`.
@@ -124,4 +123,3 @@ Each factory in this directory is responsible for producing a distinct resolver
### Workspace Query Runner
Runs the generated queries on the database and parses the result.
@@ -2,7 +2,6 @@
title: Message Queue
---
Queues facilitate async operations to be performed. They can be used for performing background tasks such as sending a welcome email on register.
Each use case will have its own queue class extended from `MessageQueueServiceBase`.
@@ -16,9 +15,12 @@ Currently, we only support `bull-mq`[bull-mq](https://bullmq.io/) as the queue d
4. Add worker class with token based injection just like producer.
### Example usage
```ts
class Resolver {
constructor(@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService) {}
constructor(
@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService,
) {}
async onSomeAction() {
//business logic
@@ -28,7 +30,9 @@ class Resolver {
//async worker
class CustomWorker {
constructor(@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService) {
constructor(
@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService,
) {
this.initWorker();
}
@@ -39,5 +43,3 @@ class CustomWorker {
}
}
```
@@ -1,9 +1,8 @@
---
title: Backend Commands
icon: "terminal"
icon: 'terminal'
---
## Useful commands
These commands should be executed from packages/twenty-server folder.
@@ -33,6 +32,7 @@ npx nx run twenty-server:lint # pass --fix to fix lint errors
npx nx run twenty-server:test:unit # run unit tests
npx nx run twenty-server:test:integration # run integration tests
```
Note: you can run `npx nx run twenty-server:test:integration:with-db-reset` in case you need to reset the database before running the integration tests.
### Resetting the database
@@ -59,26 +59,29 @@ Prisma was the first ORM we used. But in order to allow users to create custom f
Here's what the tech stack now looks like.
**Core**
- [NestJS](https://nestjs.com/)
- [TypeORM](https://typeorm.io/)
- [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server)
**Database**
- [Postgres](https://www.postgresql.org/)
**Third-party integrations**
- [Sentry](https://sentry.io/welcome/) for tracking bugs
**Testing**
- [Jest](https://jestjs.io/)
**Tooling**
- [Yarn](https://yarnpkg.com/)
- [Oxlint](https://oxc.rs/docs/guide/usage/linter.html)
**Development**
- [AWS EKS](https://aws.amazon.com/eks/)
@@ -2,7 +2,6 @@
title: Zapier App
---
Effortlessly sync Twenty with 3000+ apps using [Zapier](https://zapier.com/). Automate tasks, boost productivity, and supercharge your customer relationships!
## About Zapier
@@ -36,6 +35,7 @@ From the `packages/twenty-zapier` folder, run:
```bash
cp .env.example .env
```
Run the application locally, go to [http://localhost:3000/settings/api-webhooks](http://localhost:3000/settings/api-webhooks), and generate an API key.
Replace the **YOUR_API_KEY** value in the `.env` file with the API key you just generated.
@@ -49,28 +49,37 @@ Make sure to run `yarn build` before any `zapier` command.
</Warning>
### Test
```bash
yarn test
```
### Lint
```bash
yarn format
```
### Watch and compile as you edit code
```bash
yarn watch
```
### Validate your Zapier app
```bash
yarn validate
```
### Deploy your Zapier app
```bash
yarn deploy
```
### List all Zapier CLI commands
```bash
zapier
```
@@ -1,10 +1,9 @@
---
title: Bugs, Requests & Pull Requests
icon: "bug"
icon: 'bug'
info: Report issues, request features, and contribute code
---
## Reporting Bugs
To report a bug, please [create an issue on GitHub](https://github.com/twentyhq/twenty/issues/new).
@@ -29,12 +28,14 @@ Contributing code to Twenty starts with a pull request (PR).
1. Fork the repository on GitHub
2. Clone your fork:
```bash
git clone https://github.com/YOUR_USERNAME/twenty.git
cd twenty
```
3. Add upstream remote:
```bash
git remote add upstream https://github.com/twentyhq/twenty.git
```
@@ -46,6 +47,7 @@ git checkout -b feature/your-feature-name
```
Use descriptive branch names:
- `feature/add-export-button`
- `fix/login-redirect-issue`
- `docs/update-api-guide`
@@ -60,6 +62,7 @@ Use descriptive branch names:
### Submit Your PR
1. Push your branch:
```bash
git push origin feature/your-feature-name
```
@@ -74,4 +77,3 @@ git push origin feature/your-feature-name
- [ ] Tests pass locally
- [ ] Documentation is updated
- [ ] PR description explains the changes
@@ -1,9 +1,8 @@
---
title: Best Practices
icon: "star"
icon: 'star'
---
This document outlines the best practices you should follow when working on the frontend.
## State management
@@ -34,13 +33,10 @@ export const MyComponent = () => {
return (
<div>
<input
value={myAtom}
onChange={(e) => setMyAtom(e.target.value)}
/>
<input value={myAtom} onChange={(e) => setMyAtom(e.target.value)} />
</div>
);
}
};
```
### Do not use `useRef` to store state
@@ -91,7 +87,7 @@ export const PageComponent = () => {
const [someDependency] = useAtomState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
if (someDependency !== data) {
setData(someDependency);
}
}, [someDependency]);
@@ -99,9 +95,7 @@ export const PageComponent = () => {
return <div>{data}</div>;
};
export const App = () => (
<PageComponent />
);
export const App = () => <PageComponent />;
```
```tsx
@@ -118,7 +112,7 @@ export const PageData = () => {
const [someDependency] = useAtomState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
if (someDependency !== data) {
setData(someDependency);
}
}, [someDependency]);
@@ -169,6 +163,7 @@ Make sure you remove all `console.logs` before pushing the code to production.
Variable names ought to precisely depict the purpose or function of the variable.
#### The issue with generic names
Generic names in programming are not ideal because they lack specificity, leading to ambiguity and reduced code readability. Such names fail to convey the variable or function's purpose, making it challenging for developers to understand the code's intent without deeper investigation. This can result in increased debugging time, higher susceptibility to errors, and difficulties in maintenance and collaboration. Meanwhile, descriptive naming makes the code self-explanatory and easier to navigate, enhancing code quality and developer productivity.
```tsx
@@ -184,7 +179,7 @@ const [email, setEmail] = useState('');
#### Some words to avoid in variable names
- dummy
- dummy
### Event handlers
@@ -287,14 +282,11 @@ When importing, opt for the designated aliases rather than specifying complete o
```
**Usage**
```tsx
// ❌ Bad, specifies the entire relative path
import {
CatalogDecorator
} from '../../../../../testing/decorators/CatalogDecorator';
import {
ComponentDecorator
} from '../../../../../testing/decorators/ComponentDecorator';
import { CatalogDecorator } from '../../../../../testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '../../../../../testing/decorators/ComponentDecorator';
```
```tsx
@@ -323,9 +315,6 @@ const validationSchema = z
type Form = z.infer<typeof validationSchema>;
```
## Breaking Changes
Always perform thorough manual testing before proceeding to guarantee that modifications havent caused disruptions elsewhere, given that tests have not yet been extensively integrated.
@@ -1,10 +1,9 @@
---
title: Folder Architecture
icon: "folder-tree"
icon: 'folder-tree'
info: A detailed look into our folder architecture
---
In this guide, you will explore the details of the project directory structure and how it contributes to the organization and maintainability of Twenty.
By following this folder architecture convention, it's easier to find the files related to specific features and ensure that the application is scalable and maintainable.
@@ -94,7 +93,6 @@ React's built-in state management still handles state within a component.
Should just contain reusable pure functions. Otherwise, create custom hooks in the `hooks` folder.
## UI
Contains all the reusable UI components used in the application.
@@ -110,4 +108,3 @@ You can import other module code from any module except for the `ui` folder. Thi
### Internal
Each part (hooks, states, ...) of a module can have an `internal` folder, which contains parts that are just used within the module.
@@ -1,9 +1,8 @@
---
title: Frontend Commands
icon: "terminal"
icon: 'terminal'
---
## Useful commands
### Starting the app
@@ -17,7 +16,9 @@ npx nx start twenty-front
```bash
npx nx run twenty-front:graphql:generate --configuration=metadata
```
OR
```bash
npx nx run twenty-front:graphql:generate
```
@@ -88,5 +89,3 @@ See [best practices](/developers/contribute/capabilities/frontend-development/be
Jest is mainly for testing utility functions, and not components themselves.
Storybook is for testing the behavior of isolated components, as well as displaying the design system.
@@ -2,7 +2,6 @@
title: Hotkeys
---
## Introduction
When you need to listen to a hotkey, you would normally use the `onKeyDown` event listener.
@@ -20,6 +19,7 @@ You place it in a component, and it will listen to the hotkeys only when the com
## How to listen for hotkeys in practice?
There are two steps involved in setting up hotkey listening :
1. Set the [hotkey scope](#what-is-a-hotkey-scope-) that will listen to hotkeys
2. Use the `useScopedHotkeys` hook to listen to hotkeys
@@ -28,6 +28,7 @@ Setting up hotkey scopes is required even in simple pages, because other UI elem
## Use cases for hotkeys
In general, you'll have two use cases that require hotkeys :
1. In a page or a component mounted in a page
2. In a modal-type component that takes the focus due to a user action
@@ -88,9 +89,7 @@ const ExamplePageWithModal = () => {
const handleOpenModalClick = () => {
// 1. Set the hotkey scope when user opens the modal
setShowModal(true);
setHotkeyScopeAndMemorizePreviousScope(
ExampleHotkeyScopes.ExampleModal,
);
setHotkeyScopeAndMemorizePreviousScope(ExampleHotkeyScopes.ExampleModal);
};
const handleModalClose = () => {
@@ -99,11 +98,13 @@ const ExamplePageWithModal = () => {
goBackToPreviousHotkeyScope();
};
return <div>
<h1>My page with a modal</h1>
<button onClick={handleOpenModalClick}>Open modal</button>
{showModal && <MyModalComponent onClose={handleModalClose} />}
</div>;
return (
<div>
<h1>My page with a modal</h1>
<button onClick={handleOpenModalClick}>Open modal</button>
{showModal && <MyModalComponent onClose={handleModalClose} />}
</div>
);
};
```
@@ -117,7 +118,7 @@ const MyDropdownComponent = ({ onClose }: { onClose: () => void }) => {
useScopedHotkeys(
Key.Escape,
() => {
onClose()
onClose();
},
ExampleHotkeyScopes.ExampleModal,
);
@@ -6,4 +6,3 @@ description: Browse Twenty's UI component library
View our complete component library and documentation in Storybook.
[Open Storybook →](https://storybook.twenty.com)
@@ -1,9 +1,8 @@
---
title: Style Guide
icon: "paintbrush"
icon: 'paintbrush'
---
This document includes the rules to follow when writing code.
The goal here is to have a consistent codebase, which is easy to read and easy to maintain.
@@ -33,7 +32,7 @@ export default MyComponent;
// ✅ Good, easy to read, easy to import with code completion
export function MyComponent() {
return <div>Hello World</div>;
};
}
```
### Props
@@ -51,7 +50,9 @@ type MyComponentProps = {
name: string;
};
export const MyComponent = ({ name }: MyComponentProps) => <div>Hello {name}</div>;
export const MyComponent = ({ name }: MyComponentProps) => (
<div>Hello {name}</div>
);
```
#### Refrain from using `React.FC` or `React.FunctionComponent` to define prop types
@@ -92,7 +93,7 @@ Avoid using single variable prop spreading in JSX elements, like `{...props}`. T
*/
const MyComponent = (props: OwnProps) => {
return <OtherComponent {...props} />;
}
};
```
```tsx
@@ -105,6 +106,7 @@ const MyComponent = ({ prop1, prop2, prop3 }: MyComponentProps) => {
```
Rationale:
- At a glance, it's clearer which props the code passes down, making it easier to understand and maintain.
- It helps to prevent tight coupling between components via their props.
- Linting tools make it easier to identify misspelled or unused props when you list props explicitly.
@@ -158,9 +160,9 @@ You can see why TypeScript recommends avoiding enums [here](https://www.typescri
```tsx
// ❌ Bad, utilizes an enum
enum Color {
Red = "red",
Green = "green",
Blue = "blue",
Red = 'red',
Green = 'green',
Blue = 'blue',
}
let color = Color.Red;
@@ -169,7 +171,7 @@ let color = Color.Red;
```tsx
// ✅ Good, utilizes a string literal
let color: "red" | "green" | "blue" = "red";
let color: 'red' | 'green' | 'blue' = 'red';
```
#### GraphQL and internal libraries
@@ -237,7 +239,6 @@ Avoid using `px` or `rem` values directly within the styled components. The nece
Refrain from introducing new colors; instead, use the existing palette from the theme. Should there be a situation where the palette does not align, please leave a comment so that the team can rectify it.
```tsx
// ❌ Bad, directly specifies style values without utilizing the theme
const StyledButton = styled.button`
@@ -256,9 +257,10 @@ const StyledButton = styled.button`
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-left: ${({ theme }) => theme.spacing(1)};
border-radius: ${({ theme }) => theme.border.rounded};
border-radius: ${({ theme }) => theme.border.rounded};
`;
```
## Enforcing No-Type Imports
Avoid type imports. To enforce this standard, an Oxlint rule checks for and reports any type imports. This helps maintain consistency and readability in the TypeScript code.
@@ -3,7 +3,6 @@ title: Work with Figma
info: Learn how you can collaborate with Twenty's Figma
---
Figma is a collaborative interface design tool that aids in bridging the communication barrier between designers and developers.
This guide explains how you can collaborate with Figma.
@@ -11,7 +10,7 @@ This guide explains how you can collaborate with Figma.
1. **Access the shared link:** You can access the project's Figma file [here](https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty).
2. **Sign in:** If you're not already signed in, Figma will prompt you to do so.
Key features are only available to logged-in users, such as the developer mode and the ability to select a dedicated frame.
Key features are only available to logged-in users, such as the developer mode and the ability to select a dedicated frame.
<Warning>
@@ -19,13 +18,12 @@ You will not be able to collaborate effectively without an account.
</Warning>
## Figma structure
On the left sidebar, you can access the different pages of Twenty's Figma. This is how they're organized:
- **Components page:** This is the first page. The designer uses it to create and organize the reusable design elements used throughout the design file. For example, buttons, icons, symbols, or any other reusable components. It serves to maintain consistency across the design.
- **Main page:** The second page is the main page, which shows the complete user interface of the project. You can press ***Play*** to use the full app prototype.
- **Main page:** The second page is the main page, which shows the complete user interface of the project. You can press **_Play_** to use the full app prototype.
- **Features pages:** The other pages are typically dedicated to features in progress. They contain the design of specific features or modules of the application or website. They are typically still in progress.
## Useful Tips
@@ -61,4 +59,3 @@ It's part of our recommended extensions.
1. **Using Comments:** You are welcome to use the comment feature by clicking on the bubble icon in the left part of the toolbar.
2. **Cursor chat:** A nice feature of Figma is the Cursor chat. Just press `;` on Mac and `/` on Windows to send a message if you see someone else using Figma as the same time as you.
@@ -1,24 +1,25 @@
---
title: Local Setup
icon: "laptop-code"
description: "The guide for contributors (or curious developers) who want to run Twenty locally."
icon: 'laptop-code'
description: 'The guide for contributors (or curious developers) who want to run Twenty locally.'
---
## Prerequisites
<Tabs>
<Tab title="Linux and macOS">
Before you can install and use Twenty, make sure you install the following on your computer:
- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
- [Node v24.5.0](https://nodejs.org/en/download)
- [yarn v4](https://yarnpkg.com/getting-started/install)
- [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md)
<Warning>
`npm` won't work, you should use `yarn` instead. Yarn is now shipped with Node.js, so you don't need to install it separately.
You only have to run `corepack enable` to enable Yarn if you haven't done it yet.
`npm` won't work, you should use `yarn` instead. Yarn is now shipped with
Node.js, so you don't need to install it separately. You only have to run
`corepack enable` to enable Yarn if you haven't done it yet.
</Warning>
</Tab>
@@ -26,10 +27,12 @@ You only have to run `corepack enable` to enable Yarn if you haven't done it yet
<Tab title="Windows (WSL)">
1. Install WSL
Open PowerShell as Administrator and run:
Open PowerShell as Administrator and run:
```powershell
wsl --install
```
You should now see a prompt to restart your computer. If not, restart it manually.
Upon restart, a PowerShell window will open and install Ubuntu. This may take up some time.
@@ -48,7 +51,8 @@ git config --global user.email "youremail@domain.com"
3. Install nvm, node.js and yarn
<Warning>
Use `nvm` to install the correct `node` version. The `.nvmrc` ensures all contributors use the same version.
Use `nvm` to install the correct `node` version. The `.nvmrc` ensures all
contributors use the same version.
</Warning>
```bash
@@ -56,6 +60,7 @@ sudo apt-get install curl
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
```
Close and reopen your terminal to use nvm. Then run the following commands.
```bash
@@ -115,6 +120,7 @@ You should run all commands in the following steps from the root of the project.
```bash
make -C packages/twenty-docker postgres-on-docker
```
</Tab>
<Tab title="Mac OS">
**Option 1 (preferred):** To provision your database locally with `brew`:
@@ -171,6 +177,7 @@ You should run all commands in the following steps from the root of the project.
```bash
make -C packages/twenty-docker postgres-on-docker
```
</Tab>
<Tab title="Windows (WSL)">
All the following steps are to be run in the WSL terminal (within your virtual machine)
@@ -188,6 +195,7 @@ You should run all commands in the following steps from the root of the project.
```bash
make -C packages/twenty-docker postgres-on-docker
```
</Tab>
</Tabs>
@@ -196,6 +204,7 @@ You can now access the database at `localhost:5432`.
If you used the Docker option above, the default credentials are user `postgres` and password `postgres`. For native PostgreSQL installations, use the credentials and roles configured on your machine.
## Step 4: Set up a Redis Database (cache)
Twenty requires a Redis cache to provide the best performance.
<Tabs>
@@ -207,6 +216,7 @@ Twenty requires a Redis cache to provide the best performance.
```bash
make -C packages/twenty-docker redis-on-docker
```
</Tab>
<Tab title="Mac OS">
**Option 1 (preferred):** To provision your Redis locally with `brew`:
@@ -222,6 +232,7 @@ Twenty requires a Redis cache to provide the best performance.
```bash
make -C packages/twenty-docker redis-on-docker
```
</Tab>
<Tab title="Windows (WSL)">
**Option 1:** To provision your Redis locally:
@@ -231,6 +242,7 @@ Twenty requires a Redis cache to provide the best performance.
```bash
make -C packages/twenty-docker redis-on-docker
```
</Tab>
</Tabs>
@@ -241,48 +253,61 @@ If you need a client GUI, we recommend [Redis Insight](https://redis.io/insight/
Use environment variables or `.env` files to configure your project. More info [here](/developers/self-host/capabilities/setup).
Copy the `.env.example` files in `/front` and `/server`:
```bash
cp ./packages/twenty-front/.env.example ./packages/twenty-front/.env
cp ./packages/twenty-server/.env.example ./packages/twenty-server/.env
```
<Info>
**Multi-Workspace Mode:** By default, Twenty runs in single-workspace mode where only one workspace can be created. To enable multi-workspace support (useful for testing subdomain-based features), set `IS_MULTIWORKSPACE_ENABLED=true` in your server `.env` file. See [Multi-Workspace Mode](/developers/self-host/capabilities/setup#multi-workspace-mode) for details.
**Multi-Workspace Mode:** By default, Twenty runs in single-workspace mode
where only one workspace can be created. To enable multi-workspace support
(useful for testing subdomain-based features), set
`IS_MULTIWORKSPACE_ENABLED=true` in your server `.env` file. See
[Multi-Workspace
Mode](/developers/self-host/capabilities/setup#multi-workspace-mode) for
details.
</Info>
## Step 6: Installing dependencies
To build Twenty server and seed some data into your database, run the following command:
```bash
yarn
```
Note that `npm` or `pnpm` won't work
## Step 7: Running the project
<Tabs>
<Tab title="Linux">
Depending on your Linux distribution, Redis server might be started automatically.
If not, check the [Redis installation guide](https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/) for your distro.
Depending on your Linux distribution, Redis server might be started
automatically. If not, check the [Redis installation
guide](https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/)
for your distro.
</Tab>
<Tab title="Mac OS">
Redis should already be running. If not, run:
```bash
brew services start redis
```
Redis should already be running. If not, run: ```bash brew services start
redis ```
</Tab>
<Tab title="Windows (WSL)">
Depending on your Linux distribution, Redis server might be started automatically.
If not, check the [Redis installation guide](https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/) for your distro.
Depending on your Linux distribution, Redis server might be started
automatically. If not, check the [Redis installation
guide](https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/)
for your distro.
</Tab>
</Tabs>
Set up your database with the following command:
```bash
npx nx database:reset twenty-server
```
Start the server, the worker and the frontend services:
```bash
npx nx start twenty-server
npx nx worker twenty-server
@@ -290,6 +315,7 @@ npx nx start twenty-front
```
Alternatively, you can start all services at once:
```bash
npx nx start
```
@@ -307,8 +333,6 @@ You can log in using the default demo account: `tim@apple.dev` (password: `tim@a
- The GraphQL API can be accessed at [http://localhost:3000/graphql](http://localhost:3000/graphql)
- The REST API can be reached at [http://localhost:3000/rest](http://localhost:3000/rest)
## Troubleshooting
If you encounter any problem, check [Troubleshooting](/developers/self-host/capabilities/troubleshooting) for solutions.
@@ -1,6 +1,6 @@
---
title: Commands
icon: "terminal"
icon: 'terminal'
description: Useful commands for developing Twenty.
---
@@ -2,6 +2,7 @@
title: Contribute
description: Contribute to Twenty's open-source development.
---
<Frame>
<img src="/images/user-guide/github/github-header.png" alt="AI" />
</Frame>
@@ -21,11 +22,18 @@ Twenty is open-source and welcomes contributions from the community. Whether you
## Getting Started
<CardGroup cols={2}>
<Card title="Bug Reports & Requests" icon="bug" href="/developers/contribute/capabilities/bug-and-requests">
<Card
title="Bug Reports & Requests"
icon="bug"
href="/developers/contribute/capabilities/bug-and-requests"
>
Report issues or request features
</Card>
<Card title="Frontend Development" icon="browser" href="/developers/contribute/capabilities/frontend-development">
<Card
title="Frontend Development"
icon="browser"
href="/developers/contribute/capabilities/frontend-development"
>
Contribute to the UI
</Card>
</CardGroup>
@@ -1,6 +1,6 @@
---
title: Style Guide
icon: "paintbrush"
icon: 'paintbrush'
description: Code conventions and best practices for contributing to Twenty.
---
@@ -20,7 +20,7 @@ export default MyComponent;
// ✅ Good
export function MyComponent() {
return <div>Hello World</div>;
};
}
```
### Props
@@ -32,7 +32,9 @@ type MyComponentProps = {
name: string;
};
export const MyComponent = ({ name }: MyComponentProps) => <div>Hello {name}</div>;
export const MyComponent = ({ name }: MyComponentProps) => (
<div>Hello {name}</div>
);
```
### No single-variable prop spreading
@@ -42,7 +44,9 @@ export const MyComponent = ({ name }: MyComponentProps) => <div>Hello {name}</di
const MyComponent = (props: MyComponentProps) => <Other {...props} />;
// ✅ Good
const MyComponent = ({ prop1, prop2 }: MyComponentProps) => <Other {...{ prop1, prop2 }} />;
const MyComponent = ({ prop1, prop2 }: MyComponentProps) => (
<Other {...{ prop1, prop2 }} />
);
```
## State Management
@@ -75,7 +79,9 @@ export const myAtomState = createAtomState<string>({
export const Page = () => {
const [data, setData] = useAtomState(dataState);
const [dep] = useAtomState(depState);
useEffect(() => { setData(dep); }, [dep]);
useEffect(() => {
setData(dep);
}, [dep]);
return <div>{data}</div>;
};
@@ -83,7 +89,9 @@ export const Page = () => {
export const PageData = () => {
const [data, setData] = useAtomState(dataState);
const [dep] = useAtomState(depState);
useEffect(() => { setData(dep); }, [dep]);
useEffect(() => {
setData(dep);
}, [dep]);
return <></>;
};
export const Page = () => {
@@ -1,6 +1,6 @@
---
title: APIs
icon: "plug"
icon: 'plug'
description: REST and GraphQL APIs generated from your workspace schema.
---
@@ -26,10 +26,10 @@ Both are available as REST and GraphQL. GraphQL adds batch upserts and the abili
## Base URLs
| Environment | Base URL |
|-------------|----------|
| Cloud | `https://api.twenty.com/` |
| Self-Hosted | `https://{your-domain}/` |
| Environment | Base URL |
| ----------- | ------------------------- |
| Cloud | `https://api.twenty.com/` |
| Self-Hosted | `https://{your-domain}/` |
## Authentication
@@ -49,7 +49,7 @@ Both REST and GraphQL support batching up to 60 records per request — create,
## Rate limits
| Limit | Value |
|-------|-------|
| Requests | 100 per minute |
| Limit | Value |
| ---------- | ------------------- |
| Requests | 100 per minute |
| Batch size | 60 records per call |
@@ -1,15 +1,15 @@
---
title: Application Config
description: Declare your app's identity, default role, variables, and marketplace metadata with defineApplication.
icon: "rocket"
icon: 'rocket'
---
Every app must have exactly one `defineApplication` call. It declares:
- **Identity** — universal identifier, display name, description.
- **Permissions** — which role its logic functions and front components run under.
- **Variables** *(optional)* — keyvalue pairs exposed to your code as environment variables.
- **Pre-install / post-install hooks** *(optional)* — see [Logic Functions](/developers/extend/apps/logic/logic-functions).
- **Variables** _(optional)_ — keyvalue pairs exposed to your code as environment variables.
- **Pre-install / post-install hooks** _(optional)_ — see [Logic Functions](/developers/extend/apps/logic/logic-functions).
```ts src/application-config.ts
import { defineApplication } from 'twenty-sdk/define';
@@ -30,6 +30,7 @@ export default defineApplication({
```
Notes:
- `universalIdentifier` fields are deterministic IDs you own. Generate them once and keep them stable across syncs.
- `applicationVariables` become environment variables for your functions and front components. In logic functions (server-side), they are available as `process.env.VARIABLE_NAME`. In front components, use `getApplicationVariable('VARIABLE_NAME')` from `twenty-sdk/front-component`. Variables marked with `isSecret: true` are only injected into logic functions. Front components receive only non-secret variables.
- The default role is detected automatically from the role file marked with [`defineApplicationRole()`](/developers/extend/apps/config/roles) — you do not need to reference it from `defineApplication()`.
@@ -50,14 +51,14 @@ When you scaffold a new app, the CLI creates a starter role file at `src/roles/d
If you plan to [publish your app](/developers/extend/apps/operations/publishing), these optional fields control how it appears in the marketplace:
| Field | Description |
|-------|-------------|
| `author` | Author or company name |
| `category` | App category for marketplace filtering |
| `logoUrl` | Path to your app logo (e.g., `public/logo.png`) |
| `screenshots` | Array of screenshot paths (e.g., `public/screenshot-1.png`) |
| Field | Description |
| ------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `author` | Author or company name |
| `category` | App category for marketplace filtering |
| `logoUrl` | Path to your app logo (e.g., `public/logo.png`) |
| `screenshots` | Array of screenshot paths (e.g., `public/screenshot-1.png`) |
| `aboutDescription` | Longer markdown description for the "About" tab. If omitted, the marketplace uses the package's `README.md` from npm |
| `websiteUrl` | Link to your website |
| `termsUrl` | Link to terms of service |
| `emailSupport` | Support email address |
| `issueReportUrl` | Link to issue tracker |
| `websiteUrl` | Link to your website |
| `termsUrl` | Link to terms of service |
| `emailSupport` | Support email address |
| `issueReportUrl` | Link to issue tracker |
@@ -1,7 +1,7 @@
---
title: Install Hooks
description: Run logic before or after the install — seed data, back up records, validate the upgrade.
icon: "wrench"
icon: 'wrench'
---
Install hooks are special logic functions that run during the install or upgrade lifecycle. They share the same handler runtime as regular [logic functions](/developers/extend/apps/logic/logic-functions) and receive an `InstallPayload`, but they're declared with their own define functions — `definePostInstallLogicFunction()` and `definePreInstallLogicFunction()` — and live outside the normal trigger model (HTTP, cron, database events).
@@ -25,10 +25,16 @@ Each app may define **at most one pre-install** and **at most one post-install**
A post-install function runs automatically once your app has finished installing on a workspace. The server executes it **after** the app's metadata has been synchronized and the SDK client has been generated, so the workspace is fully ready to use and the new schema is in place. Typical use cases include seeding default data, creating initial records, configuring workspace settings, or provisioning resources on third-party services.
```ts src/logic-functions/post-install.ts
import { definePostInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';
import {
definePostInstallLogicFunction,
type InstallPayload,
} from 'twenty-sdk/define';
const handler = async (payload: InstallPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
console.log(
'Post install logic function executed successfully!',
payload.previousVersion,
);
};
export default definePostInstallLogicFunction({
@@ -49,11 +55,12 @@ yarn twenty dev:function:exec --postInstall
```
Key points:
- Post-install functions use `definePostInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `toolTriggerSettings`, `workflowActionTriggerSettings`).
- The handler receives an `InstallPayload` with `{ previousVersion?: string; newVersion: string }` — `newVersion` is the version being installed, and `previousVersion` is the version that was previously installed (or `undefined` on a fresh install). Use these values to distinguish fresh installs from upgrades and to run version-specific migration logic.
- **When the hook runs**: on fresh installs only, by default. Pass `shouldRunOnVersionUpgrade: true` if you also want it to run when the app is upgraded from a previous version. When omitted, the flag defaults to `false` and upgrades skip the hook.
- **Execution model — async by default, sync opt-in**: the `shouldRunSynchronously` flag controls *how* post-install is executed.
- `shouldRunSynchronously: false` *(default)* — the hook is **enqueued on the message queue** with `retryLimit: 3` and runs asynchronously in a worker. The install response returns as soon as the job is enqueued, so a slow or failing handler does not block the caller. The worker will retry up to three times. **Use this for long-running jobs** — seeding large datasets, calling slow third-party APIs, provisioning external resources, anything that might exceed a reasonable HTTP response window.
- **Execution model — async by default, sync opt-in**: the `shouldRunSynchronously` flag controls _how_ post-install is executed.
- `shouldRunSynchronously: false` _(default)_ — the hook is **enqueued on the message queue** with `retryLimit: 3` and runs asynchronously in a worker. The install response returns as soon as the job is enqueued, so a slow or failing handler does not block the caller. The worker will retry up to three times. **Use this for long-running jobs** — seeding large datasets, calling slow third-party APIs, provisioning external resources, anything that might exceed a reasonable HTTP response window.
- `shouldRunSynchronously: true` — the hook is executed **inline during the install flow** (same executor as pre-install). The install request blocks until the handler finishes, and if it throws, the install caller receives a `POST_INSTALL_ERROR`. No automatic retries. **Use this for fast, must-complete-before-response work** — for example, emitting a validation error to the user, or quick setup that the client will rely on immediately after the install call returns. Keep in mind the metadata migration has already been applied by the time post-install runs, so a sync-mode failure does **not** roll back the schema changes — it only surfaces the error.
- Make sure your handler is idempotent. In async mode the queue may retry up to three times; in either mode the hook may run again on upgrades when `shouldRunOnVersionUpgrade: true`.
- The environment variables `APPLICATION_ID`, `APP_ACCESS_TOKEN`, and `API_URL` are available inside the handler (same as any other logic function), so you can call the Twenty API with an application access token scoped to your app.
@@ -68,10 +75,16 @@ Key points:
A pre-install function runs automatically during installation, **before the workspace metadata migration is applied**. It shares the same payload shape as post-install (`InstallPayload`), but it is positioned earlier in the install flow so it can prepare state that the upcoming migration depends on — typical uses include backing up data, validating compatibility with the new schema, or archiving records that are about to be restructured or dropped.
```ts src/logic-functions/pre-install.ts
import { definePreInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';
import {
definePreInstallLogicFunction,
type InstallPayload,
} from 'twenty-sdk/define';
const handler = async (payload: InstallPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
console.log(
'Pre install logic function executed successfully!',
payload.previousVersion,
);
};
export default definePreInstallLogicFunction({
@@ -91,6 +104,7 @@ yarn twenty dev:function:exec --preInstall
```
Key points:
- Pre-install functions use `definePreInstallLogicFunction()` — same specialized config as post-install, just attached to a different lifecycle slot.
- Both pre- and post-install handlers receive the same `InstallPayload` type: `{ previousVersion?: string; newVersion: string }`. Import it once and reuse it for both hooks.
- **When the hook runs**: positioned just before the workspace metadata migration (`synchronizeFromManifest`). Before executing, the server runs a purely additive "pared-down sync" that registers the **new** version's pre-install function in the workspace metadata — nothing else is touched — and then executes it. Because this sync is additive-only, the previous version's objects, fields, and data are still intact when your handler runs: you can safely read and back up pre-migration state.
@@ -115,7 +129,10 @@ Pre-install is always **synchronous** (it blocks the install and can abort it).
Example — seed a default `PostCard` record after install:
```ts src/logic-functions/post-install.ts
import { definePostInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';
import {
definePostInstallLogicFunction,
type InstallPayload,
} from 'twenty-sdk/define';
import { createClient } from './generated/client';
const handler = async ({ previousVersion }: InstallPayload): Promise<void> => {
@@ -137,7 +154,7 @@ export default definePostInstallLogicFunction({
});
```
**Use `pre-install` when a migration would otherwise destroy or corrupt existing data.** Because pre-install runs against the *previous* schema and its failure rolls back the upgrade, it is the right place for anything risky:
**Use `pre-install` when a migration would otherwise destroy or corrupt existing data.** Because pre-install runs against the _previous_ schema and its failure rolls back the upgrade, it is the right place for anything risky:
- **Backing up data that is about to be dropped or restructured** — e.g. you are removing a field in v2 and need to copy its values into another field or export them to storage before the migration runs.
- **Archiving records that a new constraint would invalidate** — e.g. a field is becoming `NOT NULL` and you need to delete or fix rows with null values first.
@@ -147,10 +164,16 @@ export default definePostInstallLogicFunction({
Example — archive records before a destructive migration:
```ts src/logic-functions/pre-install.ts
import { definePreInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';
import {
definePreInstallLogicFunction,
type InstallPayload,
} from 'twenty-sdk/define';
import { createClient } from './generated/client';
const handler = async ({ previousVersion, newVersion }: InstallPayload): Promise<void> => {
const handler = async ({
previousVersion,
newVersion,
}: InstallPayload): Promise<void> => {
// Only the 1.x → 2.x upgrade drops the legacy `notes` field.
if (!previousVersion?.startsWith('1.') || !newVersion.startsWith('2.')) {
return;
@@ -179,7 +202,8 @@ const handler = async ({ previousVersion, newVersion }: InstallPayload): Promise
export default definePreInstallLogicFunction({
universalIdentifier: 'a1b2c3d4-5678-90ab-cdef-1234567890ab',
name: 'pre-install',
description: 'Backs up legacy notes into description before the v2 migration.',
description:
'Backs up legacy notes into description before the v2 migration.',
timeoutSeconds: 300,
shouldRunOnVersionUpgrade: true,
handler,
@@ -188,18 +212,20 @@ export default definePreInstallLogicFunction({
**Rule of thumb:**
| You want to... | Use |
|---|---|
| Seed default data, configure the workspace, register external resources | `post-install` |
| You want to... | Use |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| Seed default data, configure the workspace, register external resources | `post-install` |
| Run long-running seeding or third-party calls that shouldn't block the install response | `post-install` (default — `shouldRunSynchronously: false`, with worker retries) |
| Run fast setup that the caller will rely on immediately after the install call returns | `post-install` with `shouldRunSynchronously: true` |
| Read or back up data that the upcoming migration would lose | `pre-install` |
| Reject an upgrade that would corrupt existing data | `pre-install` (throw from the handler) |
| Run reconciliation on every upgrade | `post-install` with `shouldRunOnVersionUpgrade: true` |
| Do one-off setup on the first install only | `post-install` with `shouldRunOnVersionUpgrade: false` (default) |
| Run fast setup that the caller will rely on immediately after the install call returns | `post-install` with `shouldRunSynchronously: true` |
| Read or back up data that the upcoming migration would lose | `pre-install` |
| Reject an upgrade that would corrupt existing data | `pre-install` (throw from the handler) |
| Run reconciliation on every upgrade | `post-install` with `shouldRunOnVersionUpgrade: true` |
| Do one-off setup on the first install only | `post-install` with `shouldRunOnVersionUpgrade: false` (default) |
<Note>
If in doubt, default to **post-install**. Only reach for pre-install when the migration itself is destructive and you need to intercept the previous state before it is gone.
If in doubt, default to **post-install**. Only reach for pre-install when the
migration itself is destructive and you need to intercept the previous state
before it is gone.
</Note>
</Accordion>

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