snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
/e2e/.auth
/e2e/.results
/playwright-report
/test-results
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
*.d.ts

View File

@@ -0,0 +1 @@
24.5.0

View File

@@ -0,0 +1,37 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
},
"overrides": [
{
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["twenty-shared", "twenty-shared/*"],
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
}
]
}
]
}
}
]
}

View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -0,0 +1,30 @@
{
"name": "real-estate",
"version": "0.7.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2",
"twenty": ">=2.19.0"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.13.0",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"twenty-client-sdk": "2.31.0",
"twenty-sdk": "2.31.0",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,11 @@
import { defineApplication } from 'twenty-sdk/define';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'0dc70098-ce83-430a-bb37-d5b5f2790b99';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Real Estate',
description:
'Demo real estate CRM: properties, showings, and buyer/seller/agent roles',
});

View File

@@ -0,0 +1,29 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { SHOWING_UNIVERSAL_IDENTIFIER } from '../objects/showing.object';
export const AGENT_ON_SHOWING_ID = 'e25d8e44-2210-4765-8723-46a3228295ab';
export const AGENT_SHOWINGS_ON_PERSON_ID =
'aed60deb-9d96-41fd-821a-446175b3016d';
export default defineField({
universalIdentifier: AGENT_ON_SHOWING_ID,
objectUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'agent',
label: 'Agent',
icon: 'IconUserStar',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: AGENT_SHOWINGS_ON_PERSON_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'agentId',
},
});

View File

@@ -0,0 +1,26 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { SHOWING_UNIVERSAL_IDENTIFIER } from '../objects/showing.object';
import {
AGENT_ON_SHOWING_ID,
AGENT_SHOWINGS_ON_PERSON_ID,
} from './agent-on-showing.field';
export default defineField({
universalIdentifier: AGENT_SHOWINGS_ON_PERSON_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'agentShowings',
label: 'Showings as agent',
icon: 'IconCalendarEvent',
relationTargetObjectMetadataUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: AGENT_ON_SHOWING_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

View File

@@ -0,0 +1,30 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
export const BUYER_ON_OPPORTUNITY_ID = 'b64e08c3-4263-4100-add5-8f700c85c741';
export const BUYER_OPPORTUNITIES_ON_PERSON_ID =
'6a882d40-1987-49b2-91f4-4ed80900be57';
export default defineField({
universalIdentifier: BUYER_ON_OPPORTUNITY_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.RELATION,
name: 'buyer',
label: 'Buyer',
icon: 'IconUser',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
BUYER_OPPORTUNITIES_ON_PERSON_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'buyerId',
},
});

View File

@@ -0,0 +1,28 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { SHOWING_UNIVERSAL_IDENTIFIER } from '../objects/showing.object';
export const BUYER_ON_SHOWING_ID = '6be576de-50cb-4e3f-b12d-d2538b84d4cc';
export const SHOWINGS_ON_PERSON_ID = '69c1792d-fab7-4b9b-a606-a995782019cd';
export default defineField({
universalIdentifier: BUYER_ON_SHOWING_ID,
objectUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'buyer',
label: 'Buyer',
icon: 'IconUser',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: SHOWINGS_ON_PERSON_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'buyerId',
},
});

View File

@@ -0,0 +1,26 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
BUYER_ON_OPPORTUNITY_ID,
BUYER_OPPORTUNITIES_ON_PERSON_ID,
} from './buyer-on-opportunity.field';
export default defineField({
universalIdentifier: BUYER_OPPORTUNITIES_ON_PERSON_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'buyerOpportunities',
label: 'Opportunities as buyer',
icon: 'IconTargetArrow',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: BUYER_ON_OPPORTUNITY_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

View File

@@ -0,0 +1,27 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { PROPERTY_UNIVERSAL_IDENTIFIER } from '../objects/property.object';
import {
LISTED_PROPERTIES_ON_PERSON_ID,
LISTING_AGENT_ON_PROPERTY_ID,
} from './listing-agent-on-property.field';
export default defineField({
universalIdentifier: LISTED_PROPERTIES_ON_PERSON_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'listedProperties',
label: 'Listed properties',
icon: 'IconHome',
relationTargetObjectMetadataUniversalIdentifier:
PROPERTY_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: LISTING_AGENT_ON_PROPERTY_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

View File

@@ -0,0 +1,31 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { PROPERTY_UNIVERSAL_IDENTIFIER } from '../objects/property.object';
export const LISTING_AGENT_ON_PROPERTY_ID =
'e958682f-cdf6-4930-9758-743014a5d64a';
export const LISTED_PROPERTIES_ON_PERSON_ID =
'0d8e78f1-f27e-4164-baeb-7f80b77dd9bc';
export default defineField({
universalIdentifier: LISTING_AGENT_ON_PROPERTY_ID,
objectUniversalIdentifier: PROPERTY_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'listingAgent',
label: 'Listing agent',
icon: 'IconUserStar',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
LISTED_PROPERTIES_ON_PERSON_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'listingAgentId',
},
});

View File

@@ -0,0 +1,26 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { PROPERTY_UNIVERSAL_IDENTIFIER } from '../objects/property.object';
import {
OPPORTUNITIES_ON_PROPERTY_ID,
PROPERTY_ON_OPPORTUNITY_ID,
} from './property-on-opportunity.field';
export default defineField({
universalIdentifier: OPPORTUNITIES_ON_PROPERTY_ID,
objectUniversalIdentifier: PROPERTY_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'opportunities',
label: 'Opportunities',
icon: 'IconTargetArrow',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PROPERTY_ON_OPPORTUNITY_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

View File

@@ -0,0 +1,38 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
export const BUYER_STAGE_FIELD_ID = '268886e5-6083-42a1-a18b-3b183bd0af12';
export enum BuyerStage {
COMPLETING_PROFILE = 'COMPLETING_PROFILE',
SHOWING = 'SHOWING',
OFFER_MADE = 'OFFER_MADE',
CLOSING = 'CLOSING',
WON = 'WON',
LOST = 'LOST',
}
export const BUYER_STAGE_OPTIONS = [
{ id: 'e1bada75-8b25-4001-9c42-1ef0bcdc39c6', value: BuyerStage.COMPLETING_PROFILE, label: 'Completing profile', position: 0, color: 'gray' as const },
{ id: '668e4af7-f967-409e-a625-33ae4cab3e16', value: BuyerStage.SHOWING, label: 'Showing', position: 1, color: 'blue' as const },
{ id: '5556a6de-e158-4e67-8ba0-2cfe670f53db', value: BuyerStage.OFFER_MADE, label: 'Offer made', position: 2, color: 'orange' as const },
{ id: '2289bfc5-dcc7-4348-b001-dbf63eeaef8d', value: BuyerStage.CLOSING, label: 'Closing', position: 3, color: 'purple' as const },
{ id: '98bde945-071e-4207-a0dd-3c3445edf26f', value: BuyerStage.WON, label: 'Won', position: 4, color: 'green' as const },
{ id: 'a00947c7-47de-4fa5-946c-0a141c7cb33f', value: BuyerStage.LOST, label: 'Lost', position: 5, color: 'red' as const },
];
export default defineField({
universalIdentifier: BUYER_STAGE_FIELD_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'buyerStage',
label: 'Buyer stage',
description: 'Where the buyer is in their journey',
icon: 'IconRoute',
isNullable: true,
options: BUYER_STAGE_OPTIONS,
});

View File

@@ -0,0 +1,30 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { SHOWING_UNIVERSAL_IDENTIFIER } from '../objects/showing.object';
export const OPPORTUNITY_ON_SHOWING_ID =
'69fa9f30-dc06-4c2c-b6e5-79351c7a52c8';
export const SHOWINGS_ON_OPPORTUNITY_ID =
'4f296937-b62c-4e43-9316-6278148909c4';
export default defineField({
universalIdentifier: OPPORTUNITY_ON_SHOWING_ID,
objectUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'opportunity',
label: 'Opportunity',
icon: 'IconTargetArrow',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: SHOWINGS_ON_OPPORTUNITY_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'opportunityId',
},
});

View File

@@ -0,0 +1,20 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
export const BUDGET_MAX_FIELD_UNIVERSAL_IDENTIFIER =
'85717714-a03b-4f7a-a13e-504f4e60d588';
export default defineField({
universalIdentifier: BUDGET_MAX_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.CURRENCY,
name: 'budgetMax',
label: 'Budget max',
description: 'Buyer maximum budget',
icon: 'IconCurrencyEuro',
isNullable: true,
});

View File

@@ -0,0 +1,20 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
export const BUDGET_MIN_FIELD_UNIVERSAL_IDENTIFIER =
'46ebae6d-27ab-425f-b60e-1feac27cc45b';
export default defineField({
universalIdentifier: BUDGET_MIN_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.CURRENCY,
name: 'budgetMin',
label: 'Budget min',
description: 'Buyer minimum budget',
icon: 'IconCurrencyEuro',
isNullable: true,
});

View File

@@ -0,0 +1,19 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
export const DESIRED_AREA_FIELD_UNIVERSAL_IDENTIFIER =
'073ed8cf-7e08-4325-bd3a-c033155a4a4d';
export default defineField({
universalIdentifier: DESIRED_AREA_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.TEXT,
name: 'desiredArea',
label: 'Desired area',
description: 'Neighborhood or area the buyer is looking in',
icon: 'IconMapSearch',
});

View File

@@ -0,0 +1,20 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
export const PRE_APPROVED_FIELD_UNIVERSAL_IDENTIFIER =
'21e0c04d-845e-4639-b9f6-2d35177ae05a';
export default defineField({
universalIdentifier: PRE_APPROVED_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.BOOLEAN,
name: 'preApproved',
label: 'Pre-approved',
description: 'Whether the buyer has financing pre-approval',
icon: 'IconCheck',
defaultValue: false,
});

View File

@@ -0,0 +1,49 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
enum PersonType {
BUYER = 'BUYER',
SELLER = 'SELLER',
AGENT = 'AGENT',
}
export const PERSON_TYPE_FIELD_UNIVERSAL_IDENTIFIER =
'ea97d102-34e9-40bb-bbad-556a6456016a';
export default defineField({
universalIdentifier: PERSON_TYPE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.SELECT,
name: 'personType',
label: 'Person type',
description: 'Role of this person in the real estate flow',
icon: 'IconUserCog',
isNullable: true,
options: [
{
id: 'e6bdb2bf-c5d4-48ae-8781-30caf34e313f',
value: PersonType.BUYER,
label: 'Buyer',
position: 0,
color: 'blue',
},
{
id: '3312451e-bd22-4e01-bec2-ee30ecd60cf0',
value: PersonType.SELLER,
label: 'Seller',
position: 1,
color: 'purple',
},
{
id: 'e3975fa7-d15f-4e4c-88f5-6de99cd20f4e',
value: PersonType.AGENT,
label: 'Agent',
position: 2,
color: 'green',
},
],
});

View File

@@ -0,0 +1,28 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { PROPERTY_UNIVERSAL_IDENTIFIER } from '../objects/property.object';
import {
PROPERTIES_FOR_SALE_ON_PERSON_ID,
SELLER_CONTACT_ON_PROPERTY_ID,
} from './seller-contact-on-property.field';
export default defineField({
universalIdentifier: PROPERTIES_FOR_SALE_ON_PERSON_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'propertiesForSale',
label: 'Properties for sale',
icon: 'IconHome',
relationTargetObjectMetadataUniversalIdentifier:
PROPERTY_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
SELLER_CONTACT_ON_PROPERTY_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

View File

@@ -0,0 +1,31 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { PROPERTY_UNIVERSAL_IDENTIFIER } from '../objects/property.object';
export const PROPERTY_ON_OPPORTUNITY_ID =
'899dff13-3962-468c-ac8d-cdb10eac7621';
export const OPPORTUNITIES_ON_PROPERTY_ID =
'f3b50c66-2096-486b-bedc-2261c537b5dd';
export default defineField({
universalIdentifier: PROPERTY_ON_OPPORTUNITY_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.RELATION,
name: 'property',
label: 'Property',
icon: 'IconHome',
relationTargetObjectMetadataUniversalIdentifier:
PROPERTY_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: OPPORTUNITIES_ON_PROPERTY_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'propertyId',
},
});

View File

@@ -0,0 +1,28 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
} from 'twenty-sdk/define';
import { PROPERTY_UNIVERSAL_IDENTIFIER } from '../objects/property.object';
import { SHOWING_UNIVERSAL_IDENTIFIER } from '../objects/showing.object';
export const PROPERTY_ON_SHOWING_ID = '833124c5-4c9d-4921-aae9-fc5a235cc04e';
export const SHOWINGS_ON_PROPERTY_ID = 'ff848464-396a-4feb-a2b7-3aa28c1e3857';
export default defineField({
universalIdentifier: PROPERTY_ON_SHOWING_ID,
objectUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'property',
label: 'Property',
icon: 'IconHome',
relationTargetObjectMetadataUniversalIdentifier:
PROPERTY_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: SHOWINGS_ON_PROPERTY_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'propertyId',
},
});

View File

@@ -0,0 +1,31 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { PROPERTY_UNIVERSAL_IDENTIFIER } from '../objects/property.object';
export const SELLER_CONTACT_ON_PROPERTY_ID =
'5471ea86-b2bf-4b9e-925f-a4df1d8819b3';
export const PROPERTIES_FOR_SALE_ON_PERSON_ID =
'eccff600-5ab9-46cd-9243-fe8c76ddb144';
export default defineField({
universalIdentifier: SELLER_CONTACT_ON_PROPERTY_ID,
objectUniversalIdentifier: PROPERTY_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'sellerContact',
label: 'Seller',
icon: 'IconUserDollar',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
PROPERTIES_FOR_SALE_ON_PERSON_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'sellerContactId',
},
});

View File

@@ -0,0 +1,30 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
export const SELLER_ON_OPPORTUNITY_ID = 'c3e755f5-2648-448f-8886-c81db4e3a97e';
export const SELLER_OPPORTUNITIES_ON_PERSON_ID =
'9e617a78-d853-4f46-9947-d0f28dd2cd48';
export default defineField({
universalIdentifier: SELLER_ON_OPPORTUNITY_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.RELATION,
name: 'seller',
label: 'Seller',
icon: 'IconUserDollar',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
SELLER_OPPORTUNITIES_ON_PERSON_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'sellerId',
},
});

View File

@@ -0,0 +1,26 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
SELLER_ON_OPPORTUNITY_ID,
SELLER_OPPORTUNITIES_ON_PERSON_ID,
} from './seller-on-opportunity.field';
export default defineField({
universalIdentifier: SELLER_OPPORTUNITIES_ON_PERSON_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'sellerOpportunities',
label: 'Opportunities as seller',
icon: 'IconTargetArrow',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: SELLER_ON_OPPORTUNITY_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

View File

@@ -0,0 +1,26 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { SHOWING_UNIVERSAL_IDENTIFIER } from '../objects/showing.object';
import {
OPPORTUNITY_ON_SHOWING_ID,
SHOWINGS_ON_OPPORTUNITY_ID,
} from './opportunity-on-showing.field';
export default defineField({
universalIdentifier: SHOWINGS_ON_OPPORTUNITY_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.RELATION,
name: 'showings',
label: 'Showings',
icon: 'IconCalendarEvent',
relationTargetObjectMetadataUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: OPPORTUNITY_ON_SHOWING_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

View File

@@ -0,0 +1,26 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { SHOWING_UNIVERSAL_IDENTIFIER } from '../objects/showing.object';
import {
BUYER_ON_SHOWING_ID,
SHOWINGS_ON_PERSON_ID,
} from './buyer-on-showing.field';
export default defineField({
universalIdentifier: SHOWINGS_ON_PERSON_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'showings',
label: 'Showings',
icon: 'IconCalendarEvent',
relationTargetObjectMetadataUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: BUYER_ON_SHOWING_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

View File

@@ -0,0 +1,21 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { PROPERTY_UNIVERSAL_IDENTIFIER } from '../objects/property.object';
import { SHOWING_UNIVERSAL_IDENTIFIER } from '../objects/showing.object';
import {
PROPERTY_ON_SHOWING_ID,
SHOWINGS_ON_PROPERTY_ID,
} from './property-on-showing.field';
export default defineField({
universalIdentifier: SHOWINGS_ON_PROPERTY_ID,
objectUniversalIdentifier: PROPERTY_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'showings',
label: 'Showings',
icon: 'IconCalendarEvent',
relationTargetObjectMetadataUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PROPERTY_ON_SHOWING_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

View File

@@ -0,0 +1,224 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { definePostInstallLogicFunction } from 'twenty-sdk/define';
import { BuyerStage } from '../fields/opportunity-buyer-stage.field';
const eur = (amount: number) => ({
amountMicros: amount * 1_000_000,
currencyCode: 'EUR',
});
const FIRST = [
'Emma', 'Lucas', 'Chloe', 'Louis', 'Jade', 'Hugo', 'Lea', 'Nathan',
'Manon', 'Enzo', 'Camille', 'Gabriel', 'Sarah', 'Raphael', 'Ines',
'Arthur', 'Zoe', 'Paul', 'Julie', 'Adam', 'Alice', 'Theo', 'Lina',
'Noah', 'Eva', 'Tom', 'Rose', 'Sacha', 'Nina', 'Leo',
];
const LAST = [
'Martin', 'Bernard', 'Dubois', 'Thomas', 'Robert', 'Richard', 'Petit',
'Durand', 'Leroy', 'Moreau', 'Simon', 'Laurent', 'Lefevre', 'Michel',
'Garcia',
];
const lastFor = (i: number) => LAST[i % LAST.length];
const AGENT_COUNT = 4;
const SELLER_COUNT = 12;
const BUYER_COUNT = 12;
const PROPERTY_COUNT = 30;
const agentFirst = FIRST.slice(0, AGENT_COUNT);
const sellerFirst = FIRST.slice(AGENT_COUNT, AGENT_COUNT + SELLER_COUNT);
const buyerFirst = FIRST.slice(
AGENT_COUNT + SELLER_COUNT,
AGENT_COUNT + SELLER_COUNT + BUYER_COUNT,
);
const AREAS = [
'Le Marais', 'Montmartre', 'Belleville', 'Bastille', 'Latin Quarter',
'Saint-Germain', 'Canal Saint-Martin', 'Batignolles', 'Butte-aux-Cailles',
'Pigalle', 'Oberkampf', 'Nation',
];
const STREETS = [
'Rue de Rivoli', 'Avenue Montaigne', 'Rue des Martyrs', 'Villa Leandre',
'Boulevard Haussmann', 'Rue Oberkampf', 'Rue du Bac', 'Avenue Foch',
'Rue de la Pompe', 'Quai de Valmy', 'Rue Lepic', 'Cours Mirabeau',
];
const CITIES: [string, string][] = [
['Paris', '75001'], ['Paris', '75008'], ['Lyon', '69002'],
['Bordeaux', '33000'], ['Marseille', '13001'], ['Nice', '06000'],
];
const TYPES = ['APARTMENT', 'HOUSE', 'CONDO', 'LAND'];
const STATUSES = ['ACTIVE', 'ACTIVE', 'ACTIVE', 'UNDER_OFFER', 'COMING_SOON', 'SOLD'];
const SHOWING_STATUSES = ['COMPLETED', 'SCHEDULED', 'COMPLETED', 'NO_SHOW', 'CANCELLED'];
const BUYER_STAGE_CYCLE = [
BuyerStage.COMPLETING_PROFILE,
BuyerStage.SHOWING,
BuyerStage.OFFER_MADE,
BuyerStage.CLOSING,
BuyerStage.WON,
BuyerStage.LOST,
];
const STANDARD_STAGE_BY_BUYER_STAGE: Record<BuyerStage, string> = {
[BuyerStage.COMPLETING_PROFILE]: 'NEW',
[BuyerStage.SHOWING]: 'SCREENING',
[BuyerStage.OFFER_MADE]: 'PROPOSAL',
[BuyerStage.CLOSING]: 'PROPOSAL',
[BuyerStage.WON]: 'CUSTOMER',
[BuyerStage.LOST]: 'NEW',
};
const handler = async () => {
const client = new CoreApiClient();
const peopleData = [
...agentFirst.map((firstName, i) => ({
name: { firstName, lastName: lastFor(i) },
personType: 'AGENT',
})),
...sellerFirst.map((firstName, i) => ({
name: { firstName, lastName: lastFor(AGENT_COUNT + i) },
personType: 'SELLER',
})),
...buyerFirst.map((firstName, b) => ({
name: { firstName, lastName: lastFor(AGENT_COUNT + SELLER_COUNT + b) },
personType: 'BUYER',
budgetMin: eur(200_000 + b * 40_000),
budgetMax: eur(200_000 + b * 40_000 + 150_000 + (b % 3) * 50_000),
preApproved: b % 2 === 0,
desiredArea: AREAS[b % AREAS.length],
})),
];
const { createPeople } = (await client.mutation({
createPeople: {
__args: { data: peopleData as any },
id: true,
name: { firstName: true },
},
} as any)) as any;
const personId = (firstName: string): string =>
createPeople.find((person: any) => person.name?.firstName === firstName).id;
const propertyMeta = Array.from({ length: PROPERTY_COUNT }, (_, i) => {
const [city, postcode] = CITIES[i % CITIES.length];
const street = STREETS[i % STREETS.length];
const number = 3 + i * 7;
return {
name: `${number} ${street}`,
number,
street,
city,
postcode,
priceEur: 180_000 + i * 38_000 + (i % 3) * 15_000,
status: STATUSES[i % STATUSES.length],
propertyType: TYPES[i % TYPES.length],
bedrooms: 1 + (i % 5),
bathrooms: 1 + (i % 3),
surfaceSqm: 42 + i * 4,
agentFirst: agentFirst[i % AGENT_COUNT],
sellerFirst: sellerFirst[i % SELLER_COUNT],
};
});
const propertiesData = propertyMeta.map((m) => ({
name: m.name,
propertyAddress: {
addressStreet1: `${m.number} ${m.street}`,
addressCity: m.city,
addressPostcode: m.postcode,
addressCountry: 'France',
},
price: eur(m.priceEur),
status: m.status,
propertyType: m.propertyType,
bedrooms: m.bedrooms,
bathrooms: m.bathrooms,
surfaceSqm: m.surfaceSqm,
listingAgentId: personId(m.agentFirst),
sellerContactId: personId(m.sellerFirst),
}));
const { createProperties } = (await client.mutation({
createProperties: {
__args: { data: propertiesData as any },
id: true,
name: true,
},
} as any)) as any;
const propertyId = (name: string): string =>
createProperties.find((property: any) => property.name === name).id;
// One opportunity per buyer: the buyer's pipeline card, staged by buyerStage.
const opportunitiesData = buyerFirst.map((firstName, b) => {
const buyerStage = BUYER_STAGE_CYCLE[b % BUYER_STAGE_CYCLE.length];
const meta = propertyMeta[(b * 2) % PROPERTY_COUNT];
return {
name: `${firstName} ${lastFor(AGENT_COUNT + SELLER_COUNT + b)}`,
buyerStage,
stage: STANDARD_STAGE_BY_BUYER_STAGE[buyerStage],
amount: eur(Math.round(meta.priceEur * 0.97)),
closeDate: `2026-${String(9 + (b % 3)).padStart(2, '0')}-15T00:00:00.000Z`,
buyerId: personId(firstName),
sellerId: personId(meta.sellerFirst),
propertyId: propertyId(meta.name),
};
});
const { createOpportunities } = (await client.mutation({
createOpportunities: {
__args: { data: opportunitiesData as any },
id: true,
name: true,
},
} as any)) as any;
const opportunityId = (name: string): string =>
createOpportunities.find((opportunity: any) => opportunity.name === name).id;
const showingsData = buyerFirst.flatMap((firstName, b) => {
const opportunityName = `${firstName} ${lastFor(AGENT_COUNT + SELLER_COUNT + b)}`;
return [0, 1].map((k) => {
const propertyIndex = (b * 2 + k * 11) % PROPERTY_COUNT;
const meta = propertyMeta[propertyIndex];
const status = SHOWING_STATUSES[(b + k) % SHOWING_STATUSES.length];
const month = 7 + ((b + k) % 2);
const day = String(3 + ((b * 3 + k) % 25)).padStart(2, '0');
return {
name: `${meta.name} / ${lastFor(AGENT_COUNT + SELLER_COUNT + b)}`,
status,
scheduledAt: `2026-0${month}-${day}T1${k + 2}:00:00.000Z`,
propertyId: propertyId(meta.name),
buyerId: personId(firstName),
agentId: personId(meta.agentFirst),
opportunityId: opportunityId(opportunityName),
...(status === 'COMPLETED'
? { interestLevel: `RATING_${2 + ((b + k) % 4)}` }
: {}),
};
});
});
await client.mutation({
createShowings: {
__args: { data: showingsData as any },
id: true,
},
} as any);
console.log(
`Seeded ${peopleData.length} people, ${propertiesData.length} properties, ${showingsData.length} showings, ${opportunitiesData.length} opportunities.`,
);
return {};
};
export default definePostInstallLogicFunction({
universalIdentifier: '8adff1aa-c3a5-41ba-8071-d86c1195ffd8',
name: 'post-install',
description: 'Seeds demo people, properties, showings and opportunities.',
timeoutSeconds: 120,
shouldRunSynchronously: true,
handler,
});

View File

@@ -0,0 +1,14 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import { AGENCY_DASHBOARD_PAGE_LAYOUT_ID } from '../page-layouts/agency-dashboard.page-layout';
export default defineNavigationMenuItem({
universalIdentifier: 'ab130995-9090-49c7-9285-e9f1569802be',
name: 'Agency Overview',
icon: 'IconChartBar',
position: 2,
type: NavigationMenuItemType.PAGE_LAYOUT,
pageLayoutUniversalIdentifier: AGENCY_DASHBOARD_PAGE_LAYOUT_ID,
});

View File

@@ -0,0 +1,12 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import { PROPERTY_UNIVERSAL_IDENTIFIER } from '../objects/property.object';
export default defineNavigationMenuItem({
universalIdentifier: 'f034a868-8acc-4aa9-8c1d-86eba3d89fbe',
position: 0,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: PROPERTY_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,12 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import { SHOWING_UNIVERSAL_IDENTIFIER } from '../objects/showing.object';
export default defineNavigationMenuItem({
universalIdentifier: '075cab08-9a87-401a-b3b2-47f8e0504113',
position: 1,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,171 @@
import { defineObject, FieldType } from 'twenty-sdk/define';
enum PropertyStatus {
COMING_SOON = 'COMING_SOON',
ACTIVE = 'ACTIVE',
UNDER_OFFER = 'UNDER_OFFER',
SOLD = 'SOLD',
}
enum PropertyType {
APARTMENT = 'APARTMENT',
HOUSE = 'HOUSE',
CONDO = 'CONDO',
LAND = 'LAND',
}
export const PROPERTY_UNIVERSAL_IDENTIFIER =
'f1d4bc63-38e4-467f-ad87-ffc0feaf5f01';
export const PROPERTY_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'4bb99253-6ae2-4aa8-8f44-708ce3d29c31';
export const PROPERTY_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'97fb5f33-bdc3-4b04-9c32-86d5ff995b0e';
export const PROPERTY_PRICE_FIELD_UNIVERSAL_IDENTIFIER =
'0fc19f9c-4bc5-41e6-8295-628e0258ba15';
export default defineObject({
universalIdentifier: PROPERTY_UNIVERSAL_IDENTIFIER,
nameSingular: 'property',
namePlural: 'properties',
labelSingular: 'Property',
labelPlural: 'Properties',
description: 'A property listing',
icon: 'IconHome',
labelIdentifierFieldMetadataUniversalIdentifier:
PROPERTY_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: PROPERTY_NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
description: 'Listing name, e.g. the street address',
icon: 'IconAbc',
},
{
universalIdentifier: 'b254a4f5-669f-429c-af92-f4df283b132a',
type: FieldType.ADDRESS,
name: 'propertyAddress',
label: 'Address',
icon: 'IconMapPin',
},
{
universalIdentifier: PROPERTY_PRICE_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.CURRENCY,
name: 'price',
label: 'Price',
icon: 'IconCurrencyEuro',
isNullable: true,
},
{
universalIdentifier: PROPERTY_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
name: 'status',
label: 'Status',
icon: 'IconProgress',
defaultValue: `'${PropertyStatus.ACTIVE}'`,
options: [
{
id: '075cab08-9a87-401a-b3b2-47f8e0504112',
value: PropertyStatus.COMING_SOON,
label: 'Coming soon',
position: 0,
color: 'gray',
},
{
id: 'd637ac19-fcf4-4a92-921c-72fe505f2123',
value: PropertyStatus.ACTIVE,
label: 'Active',
position: 1,
color: 'green',
},
{
id: 'f0ab44f3-7939-4e00-92ba-9c29010e6d52',
value: PropertyStatus.UNDER_OFFER,
label: 'Under offer',
position: 2,
color: 'orange',
},
{
id: '67d245aa-6ec9-42c0-a657-83dd842e4107',
value: PropertyStatus.SOLD,
label: 'Sold',
position: 3,
color: 'blue',
},
],
},
{
universalIdentifier: 'f6bf3f42-6c4f-4dba-af51-7db74979c888',
type: FieldType.SELECT,
name: 'propertyType',
label: 'Property type',
icon: 'IconBuildingCommunity',
isNullable: true,
options: [
{
id: '7efcd575-d7d0-46d9-bf26-54a3c1d5ba1e',
value: PropertyType.APARTMENT,
label: 'Apartment',
position: 0,
color: 'blue',
},
{
id: 'b6f3ec35-3cac-4ad3-8f4a-7144edda75e9',
value: PropertyType.HOUSE,
label: 'House',
position: 1,
color: 'green',
},
{
id: '486a439d-af96-4884-8125-5f5ca01999fe',
value: PropertyType.CONDO,
label: 'Condo',
position: 2,
color: 'purple',
},
{
id: '53db1ebd-c816-40c4-85d5-3c17a39669cd',
value: PropertyType.LAND,
label: 'Land',
position: 3,
color: 'brown',
},
],
},
{
universalIdentifier: 'f3254ad1-ac74-459b-bfdc-b443dad61001',
type: FieldType.NUMBER,
name: 'bedrooms',
label: 'Bedrooms',
icon: 'IconBed',
isNullable: true,
},
{
universalIdentifier: 'f1289297-f0d3-4001-bb45-100135d71e88',
type: FieldType.NUMBER,
name: 'bathrooms',
label: 'Bathrooms',
icon: 'IconBath',
isNullable: true,
},
{
universalIdentifier: '6de9a40e-6260-4dbf-8096-7296bc87911e',
type: FieldType.NUMBER,
name: 'surfaceSqm',
label: 'Surface (sqm)',
icon: 'IconRuler2',
isNullable: true,
},
{
universalIdentifier: 'eb610e48-e621-43c8-8f1b-934b1af42a03',
type: FieldType.FILES,
name: 'photos',
label: 'Photos',
icon: 'IconPhoto',
isNullable: true,
universalSettings: { maxNumberOfValues: 10 },
},
],
});

View File

@@ -0,0 +1,112 @@
import { defineObject, FieldType } from 'twenty-sdk/define';
enum ShowingStatus {
SCHEDULED = 'SCHEDULED',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
NO_SHOW = 'NO_SHOW',
}
export const SHOWING_UNIVERSAL_IDENTIFIER =
'548f2632-741b-4ff2-987c-b1c906b9ba1b';
export const SHOWING_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'5fda67f0-0552-4056-86db-6cc531840d00';
export const SHOWING_SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'f83082b5-a65f-4dde-8836-0e32ecef8653';
export const SHOWING_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'9a7efb7e-1c82-49ec-b58b-d1bda0859fd5';
export const SHOWING_INTEREST_LEVEL_FIELD_UNIVERSAL_IDENTIFIER =
'e27872da-9a4b-4973-a894-7bcf3b7d7773';
export default defineObject({
universalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER,
nameSingular: 'showing',
namePlural: 'showings',
labelSingular: 'Showing',
labelPlural: 'Showings',
description: 'A property visit booked for a buyer',
icon: 'IconCalendarEvent',
labelIdentifierFieldMetadataUniversalIdentifier:
SHOWING_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: SHOWING_NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
description: 'Showing label, e.g. property / buyer',
icon: 'IconAbc',
},
{
universalIdentifier: SHOWING_SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'scheduledAt',
label: 'Scheduled at',
icon: 'IconClock',
isNullable: true,
},
{
universalIdentifier: SHOWING_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
name: 'status',
label: 'Status',
icon: 'IconProgress',
defaultValue: `'${ShowingStatus.SCHEDULED}'`,
options: [
{
id: 'f9337370-6b1d-44c2-9f7e-5dfeae5dc43f',
value: ShowingStatus.SCHEDULED,
label: 'Scheduled',
position: 0,
color: 'blue',
},
{
id: 'f4dcdaa5-e587-4afb-b264-1b3e1d34a987',
value: ShowingStatus.COMPLETED,
label: 'Completed',
position: 1,
color: 'green',
},
{
id: 'a0be44a0-64bb-4937-9b09-7d367274a658',
value: ShowingStatus.CANCELLED,
label: 'Cancelled',
position: 2,
color: 'gray',
},
{
id: 'b0769e7d-4f71-41e7-aa2c-e04780efe6e0',
value: ShowingStatus.NO_SHOW,
label: 'No show',
position: 3,
color: 'red',
},
],
},
{
universalIdentifier: '377e4a00-8398-413a-8662-526236ff2d65',
type: FieldType.RICH_TEXT,
name: 'feedback',
label: 'Feedback',
description: 'Buyer reaction after the visit',
icon: 'IconMessage',
isNullable: true,
},
{
universalIdentifier: SHOWING_INTEREST_LEVEL_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.RATING,
name: 'interestLevel',
label: 'Interest level',
icon: 'IconStar',
isNullable: true,
options: [
{ id: '885a6c52-512c-47b1-a160-a609dcbd82bb', value: 'RATING_1', label: '1', position: 0 },
{ id: '0dc70098-ce83-430a-bb37-d5b5f2790b90', value: 'RATING_2', label: '2', position: 1 },
{ id: '0dc70098-ce83-430a-bb37-d5b5f2790b91', value: 'RATING_3', label: '3', position: 2 },
{ id: '0dc70098-ce83-430a-bb37-d5b5f2790b92', value: 'RATING_4', label: '4', position: 3 },
{ id: '0dc70098-ce83-430a-bb37-d5b5f2790b93', value: 'RATING_5', label: '5', position: 4 },
],
},
],
});

View File

@@ -0,0 +1,244 @@
import {
AggregateOperations,
definePageLayout,
PageLayoutTabLayoutMode,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import {
PROPERTY_NAME_FIELD_UNIVERSAL_IDENTIFIER,
PROPERTY_PRICE_FIELD_UNIVERSAL_IDENTIFIER,
PROPERTY_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
PROPERTY_UNIVERSAL_IDENTIFIER,
} from '../objects/property.object';
import {
SHOWING_NAME_FIELD_UNIVERSAL_IDENTIFIER,
SHOWING_UNIVERSAL_IDENTIFIER,
} from '../objects/showing.object';
import { LISTING_AGENT_ON_PROPERTY_ID } from '../fields/listing-agent-on-property.field';
import { AGENT_ON_SHOWING_ID } from '../fields/agent-on-showing.field';
import { BUYER_STAGE_FIELD_ID } from '../fields/opportunity-buyer-stage.field';
export const AGENCY_DASHBOARD_PAGE_LAYOUT_ID =
'a5de312d-d97f-4dad-bce8-e8110f6b86c7';
const opportunityFields =
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields;
const opportunityObjectId =
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier;
// Chart-config values the SDK doesn't export as enums (passed as string literals).
const BAR = {
layout: 'VERTICAL',
primaryAxisOrderBy: 'FIELD_ASC',
axisNameDisplay: 'NONE',
color: 'auto',
timezone: 'UTC',
firstDayOfTheWeek: 1,
} as const;
export default definePageLayout({
universalIdentifier: AGENCY_DASHBOARD_PAGE_LAYOUT_ID,
name: 'Agency Overview',
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: '62f7f9e3-75c9-4a60-9ae9-151b417a2c4c',
title: 'Overview',
position: 0,
icon: 'IconChartBar',
layoutMode: PageLayoutTabLayoutMode.GRID,
widgets: [
{
universalIdentifier: '7934653e-a3d4-47d6-a624-a7216a7e18ca',
title: 'Portfolio value',
type: 'GRAPH',
objectUniversalIdentifier: PROPERTY_UNIVERSAL_IDENTIFIER,
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 0,
column: 0,
rowSpan: 2,
columnSpan: 3,
},
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
PROPERTY_PRICE_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.SUM,
displayDataLabel: true,
timezone: 'UTC',
firstDayOfTheWeek: 1,
},
},
{
universalIdentifier: '30e59360-b245-480b-b74f-382ef7c19976',
title: 'Pipeline value',
type: 'GRAPH',
objectUniversalIdentifier: opportunityObjectId,
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 0,
column: 3,
rowSpan: 2,
columnSpan: 3,
},
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
opportunityFields.amount.universalIdentifier,
aggregateOperation: AggregateOperations.SUM,
displayDataLabel: true,
timezone: 'UTC',
firstDayOfTheWeek: 1,
},
},
{
universalIdentifier: 'b2a949ff-36ba-47d2-a3af-a0ac986745ca',
title: 'Properties sold',
type: 'GRAPH',
objectUniversalIdentifier: PROPERTY_UNIVERSAL_IDENTIFIER,
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 0,
column: 6,
rowSpan: 2,
columnSpan: 3,
},
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
PROPERTY_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
displayDataLabel: true,
timezone: 'UTC',
firstDayOfTheWeek: 1,
filter: {
recordFilters: [
{
fieldMetadataUniversalIdentifier:
PROPERTY_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
operand: 'IS',
value: '["SOLD"]',
},
],
},
},
},
{
universalIdentifier: '80f0d9e5-07d8-461c-9188-e6cd7beed9f5',
title: 'Showings',
type: 'GRAPH',
objectUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER,
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 0,
column: 9,
rowSpan: 2,
columnSpan: 3,
},
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
SHOWING_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
displayDataLabel: true,
timezone: 'UTC',
firstDayOfTheWeek: 1,
},
},
{
universalIdentifier: '81baf0f8-1db9-4e60-be4f-a2304e56a714',
title: 'Properties by status',
type: 'GRAPH',
objectUniversalIdentifier: PROPERTY_UNIVERSAL_IDENTIFIER,
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 2,
column: 0,
rowSpan: 5,
columnSpan: 6,
},
configuration: {
configurationType: 'PIE_CHART',
aggregateFieldMetadataUniversalIdentifier:
PROPERTY_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataUniversalIdentifier:
PROPERTY_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
displayLegend: true,
timezone: 'UTC',
firstDayOfTheWeek: 1,
},
},
{
universalIdentifier: '2c8597fb-ef1d-41cf-9788-3ff4c933b2b5',
title: 'Pipeline by stage',
type: 'GRAPH',
objectUniversalIdentifier: opportunityObjectId,
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 2,
column: 6,
rowSpan: 5,
columnSpan: 6,
},
configuration: {
configurationType: 'BAR_CHART',
aggregateFieldMetadataUniversalIdentifier:
opportunityFields.name.universalIdentifier,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
BUYER_STAGE_FIELD_ID,
...BAR,
},
},
{
universalIdentifier: '4f9c41e3-3871-43fc-bdb8-1f42fdfcaecb',
title: 'Listings by agent',
type: 'GRAPH',
objectUniversalIdentifier: PROPERTY_UNIVERSAL_IDENTIFIER,
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 7,
column: 0,
rowSpan: 5,
columnSpan: 6,
},
configuration: {
configurationType: 'BAR_CHART',
aggregateFieldMetadataUniversalIdentifier:
PROPERTY_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
LISTING_AGENT_ON_PROPERTY_ID,
...BAR,
primaryAxisOrderBy: 'VALUE_DESC',
},
},
{
universalIdentifier: '0018b8ef-abd0-4937-8c80-2b86805f4dc5',
title: 'Showings by agent',
type: 'GRAPH',
objectUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER,
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 7,
column: 6,
rowSpan: 5,
columnSpan: 6,
},
configuration: {
configurationType: 'BAR_CHART',
aggregateFieldMetadataUniversalIdentifier:
SHOWING_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
AGENT_ON_SHOWING_ID,
...BAR,
primaryAxisOrderBy: 'VALUE_DESC',
},
},
],
},
],
});

View File

@@ -0,0 +1,54 @@
import {
definePageLayout,
PageLayoutTabLayoutMode,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
const OPPORTUNITY_RECORD_PAGE_FIELDS_VIEW_ID =
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.views
.opportunityRecordPageFields.universalIdentifier;
// Override the Opportunity record page so its Fields widget surfaces the app's
// buyer / seller / property / showings relations (added as view-fields).
export default definePageLayout({
universalIdentifier: '170e0171-b2f3-4c30-a376-6c92fec7ee9d',
name: 'Opportunity Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
tabs: [
{
universalIdentifier: 'a4691ec1-4499-4dd3-a7e1-1cea5faf116f',
title: 'Home',
position: 10,
icon: 'IconHome',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: '29af4140-c86a-4159-9bcc-674ff364f675',
title: 'Fields',
type: 'FIELDS',
configuration: {
configurationType: 'FIELDS',
viewUniversalIdentifier: OPPORTUNITY_RECORD_PAGE_FIELDS_VIEW_ID,
},
},
],
},
{
universalIdentifier: '61db2471-e338-436d-934f-136dc1d8174b',
title: 'Timeline',
position: 20,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: 'b0a3c0b2-600c-418e-8b3f-49f8588bdcbd',
title: 'Timeline',
type: 'TIMELINE',
configuration: { configurationType: 'TIMELINE' },
},
],
},
],
});

View File

@@ -0,0 +1,45 @@
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
import { SHOWING_UNIVERSAL_IDENTIFIER } from '../objects/showing.object';
import { SHOWING_RECORD_PAGE_FIELDS_VIEW_ID } from '../views/showing-record-page-fields.view';
export default definePageLayout({
universalIdentifier: 'dc95166e-85f3-427d-91ef-dbdcdb1cee13',
name: 'Showing Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: 'bd4bcb1f-202a-4bd5-acce-ec1f4788b47e',
title: 'Home',
position: 10,
icon: 'IconHome',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: '56140f7f-9b6c-4573-b701-e8552cf8a1c2',
title: 'Fields',
type: 'FIELDS',
configuration: {
configurationType: 'FIELDS',
viewUniversalIdentifier: SHOWING_RECORD_PAGE_FIELDS_VIEW_ID,
},
},
],
},
{
universalIdentifier: '202334f7-7e4a-47ff-9fbd-83e013c9e322',
title: 'Timeline',
position: 20,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: '75a5ebcf-0962-4eb5-810a-49a9da3b4e39',
title: 'Timeline',
type: 'TIMELINE',
configuration: { configurationType: 'TIMELINE' },
},
],
},
],
});

View File

@@ -0,0 +1,51 @@
import {
defineRole,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { PROPERTY_UNIVERSAL_IDENTIFIER } from '../objects/property.object';
import { SHOWING_UNIVERSAL_IDENTIFIER } from '../objects/showing.object';
export const AGENT_ROLE_UNIVERSAL_IDENTIFIER =
'253f1693-39e2-423f-93ef-87811d0b2298';
const readWrite = {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
};
export default defineRole({
universalIdentifier: AGENT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Agent',
description:
'Works listings, showings and contacts. No access to opportunities.',
icon: 'IconUserStar',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToUsers: true,
canBeAssignedToAgents: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{ objectUniversalIdentifier: PROPERTY_UNIVERSAL_IDENTIFIER, ...readWrite },
{ objectUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER, ...readWrite },
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
...readWrite,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.note.universalIdentifier,
...readWrite,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.task.universalIdentifier,
...readWrite,
},
],
});

View File

@@ -0,0 +1,19 @@
import { defineRole } from 'twenty-sdk/define';
export const BROKER_ROLE_UNIVERSAL_IDENTIFIER =
'f5bf4e0c-a74f-4509-8d9b-802fc246ccc1';
export default defineRole({
universalIdentifier: BROKER_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Broker',
description: 'Full access to the real estate workspace',
icon: 'IconBuildingSkyscraper',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
});

View File

@@ -0,0 +1,14 @@
import { defineApplicationRole } from 'twenty-sdk/define';
export const DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER =
'23eb2a01-f1a9-4364-b2f0-0136c0acb9b2';
export default defineApplicationRole({
universalIdentifier: DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Real Estate default function role',
description: 'Role the Real Estate app operations run as',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});

View File

@@ -0,0 +1,32 @@
import { defineRole } from 'twenty-sdk/define';
import { PROPERTY_UNIVERSAL_IDENTIFIER } from '../objects/property.object';
import { SHOWING_UNIVERSAL_IDENTIFIER } from '../objects/showing.object';
export const SELLER_ROLE_UNIVERSAL_IDENTIFIER =
'f034a868-8acc-4aa9-8c1d-86eba3d89fbf';
const readOnly = {
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
};
export default defineRole({
universalIdentifier: SELLER_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Seller',
description: 'Read-only access to their listing and its showings',
icon: 'IconUserDollar',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToUsers: true,
canBeAssignedToAgents: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{ objectUniversalIdentifier: PROPERTY_UNIVERSAL_IDENTIFIER, ...readOnly },
{ objectUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER, ...readOnly },
],
});

View File

@@ -0,0 +1,35 @@
import {
defineView,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
ViewFilterOperand,
ViewType,
} from 'twenty-sdk/define';
import { PERSON_TYPE_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/person-type.field';
import { LISTED_PROPERTIES_ON_PERSON_ID } from '../fields/listing-agent-on-property.field';
export const AGENTS_VIEW_ID = '799dbc65-52e7-4533-874c-aeac05f599fa';
const personFields = STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.fields;
export default defineView({
universalIdentifier: AGENTS_VIEW_ID,
name: 'Agents',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: ViewType.TABLE,
icon: 'IconUserStar',
position: 10,
fields: [
{ universalIdentifier: 'be978ab0-6e04-4c63-a233-982310142296', fieldMetadataUniversalIdentifier: personFields.name.universalIdentifier, position: 0, isVisible: true, size: 220 },
{ universalIdentifier: '7543b11d-9058-420e-912b-558a9623d806', fieldMetadataUniversalIdentifier: PERSON_TYPE_FIELD_UNIVERSAL_IDENTIFIER, position: 1, isVisible: true, size: 130 },
{ universalIdentifier: 'dde38d0d-2e36-4cc5-9205-3fb2b6a19612', fieldMetadataUniversalIdentifier: LISTED_PROPERTIES_ON_PERSON_ID, position: 2, isVisible: true, size: 260 },
],
filters: [
{
universalIdentifier: '121391c6-c578-4b67-bab0-6e8ce8ead74b',
fieldMetadataUniversalIdentifier: PERSON_TYPE_FIELD_UNIVERSAL_IDENTIFIER,
operand: ViewFilterOperand.IS,
value: ['AGENT'],
},
],
});

View File

@@ -0,0 +1,52 @@
import { defineView, ViewType } from 'twenty-sdk/define';
import {
PROPERTY_NAME_FIELD_UNIVERSAL_IDENTIFIER,
PROPERTY_PRICE_FIELD_UNIVERSAL_IDENTIFIER,
PROPERTY_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
PROPERTY_UNIVERSAL_IDENTIFIER,
} from '../objects/property.object';
import { LISTING_AGENT_ON_PROPERTY_ID } from '../fields/listing-agent-on-property.field';
export const ALL_PROPERTIES_VIEW_ID = '3cae86f2-c7ea-45ee-8292-507dc5aa7dcf';
export default defineView({
universalIdentifier: ALL_PROPERTIES_VIEW_ID,
name: 'All Properties',
objectUniversalIdentifier: PROPERTY_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconHome',
position: 0,
fields: [
{
universalIdentifier: '56b7b9fa-950c-4923-b017-51d6ccef35ed',
fieldMetadataUniversalIdentifier:
PROPERTY_NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 240,
},
{
universalIdentifier: 'b730aca0-0511-4d91-aad8-ea69b90bca8f',
fieldMetadataUniversalIdentifier:
PROPERTY_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
position: 1,
isVisible: true,
size: 150,
},
{
universalIdentifier: '8241c0d5-eebd-4d83-b509-7d69acaf7892',
fieldMetadataUniversalIdentifier:
PROPERTY_PRICE_FIELD_UNIVERSAL_IDENTIFIER,
position: 2,
isVisible: true,
size: 150,
},
{
universalIdentifier: 'bbb411f6-51c0-4716-90d8-b1b4b9b1a23a',
fieldMetadataUniversalIdentifier: LISTING_AGENT_ON_PROPERTY_ID,
position: 3,
isVisible: true,
size: 200,
},
],
});

View File

@@ -0,0 +1,68 @@
import { defineView, ViewType } from 'twenty-sdk/define';
import {
SHOWING_INTEREST_LEVEL_FIELD_UNIVERSAL_IDENTIFIER,
SHOWING_NAME_FIELD_UNIVERSAL_IDENTIFIER,
SHOWING_SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER,
SHOWING_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
SHOWING_UNIVERSAL_IDENTIFIER,
} from '../objects/showing.object';
import { PROPERTY_ON_SHOWING_ID } from '../fields/property-on-showing.field';
import { BUYER_ON_SHOWING_ID } from '../fields/buyer-on-showing.field';
export const ALL_SHOWINGS_VIEW_ID = '618f5217-ad9b-4922-8caa-38413159d872';
export default defineView({
universalIdentifier: ALL_SHOWINGS_VIEW_ID,
name: 'All Showings',
objectUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconCalendarEvent',
position: 0,
fields: [
{
universalIdentifier: '332eaa7b-eb59-428c-98ba-6b7297dd3491',
fieldMetadataUniversalIdentifier: SHOWING_NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 240,
},
{
universalIdentifier: 'd64a810a-6749-49b4-a606-ac6b06a346fd',
fieldMetadataUniversalIdentifier: PROPERTY_ON_SHOWING_ID,
position: 1,
isVisible: true,
size: 200,
},
{
universalIdentifier: '47165894-5959-4368-a187-701182f7c963',
fieldMetadataUniversalIdentifier: BUYER_ON_SHOWING_ID,
position: 2,
isVisible: true,
size: 200,
},
{
universalIdentifier: '1be249ea-285c-4e94-b6bb-0673b69e7638',
fieldMetadataUniversalIdentifier:
SHOWING_SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER,
position: 3,
isVisible: true,
size: 160,
},
{
universalIdentifier: '6dd7ec1f-5d5f-473d-bafd-7c40bf644c80',
fieldMetadataUniversalIdentifier:
SHOWING_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
position: 4,
isVisible: true,
size: 140,
},
{
universalIdentifier: '253f1693-39e2-423f-93ef-87811d0b2297',
fieldMetadataUniversalIdentifier:
SHOWING_INTEREST_LEVEL_FIELD_UNIVERSAL_IDENTIFIER,
position: 5,
isVisible: true,
size: 120,
},
],
});

View File

@@ -0,0 +1,46 @@
import {
defineView,
ViewFilterOperand,
ViewSortDirection,
ViewType,
} from 'twenty-sdk/define';
import {
PROPERTY_NAME_FIELD_UNIVERSAL_IDENTIFIER,
PROPERTY_PRICE_FIELD_UNIVERSAL_IDENTIFIER,
PROPERTY_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
PROPERTY_UNIVERSAL_IDENTIFIER,
} from '../objects/property.object';
import { LISTING_AGENT_ON_PROPERTY_ID } from '../fields/listing-agent-on-property.field';
export const AVAILABLE_PROPERTIES_VIEW_ID =
'6c41e295-bc6f-430f-b526-d1708c5c6153';
export default defineView({
universalIdentifier: AVAILABLE_PROPERTIES_VIEW_ID,
name: 'Available (by price)',
objectUniversalIdentifier: PROPERTY_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconCoin',
position: 1,
fields: [
{ universalIdentifier: '0a80e4b4-f52b-420a-9372-8143ea9d726a', fieldMetadataUniversalIdentifier: PROPERTY_NAME_FIELD_UNIVERSAL_IDENTIFIER, position: 0, isVisible: true, size: 240 },
{ universalIdentifier: 'f53d191c-c9e2-47c1-8e89-b600f77e667e', fieldMetadataUniversalIdentifier: PROPERTY_PRICE_FIELD_UNIVERSAL_IDENTIFIER, position: 1, isVisible: true, size: 150 },
{ universalIdentifier: '3d5d31a7-016a-4d39-8b39-8d850e4c8d90', fieldMetadataUniversalIdentifier: PROPERTY_STATUS_FIELD_UNIVERSAL_IDENTIFIER, position: 2, isVisible: true, size: 150 },
{ universalIdentifier: '0987e9ff-6210-468b-8639-7af8c64f23f6', fieldMetadataUniversalIdentifier: LISTING_AGENT_ON_PROPERTY_ID, position: 3, isVisible: true, size: 200 },
],
filters: [
{
universalIdentifier: '332e0166-af2b-459c-9de0-045adcf9a275',
fieldMetadataUniversalIdentifier: PROPERTY_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
operand: ViewFilterOperand.IS_NOT,
value: ['SOLD'],
},
],
sorts: [
{
universalIdentifier: 'e0c1ee67-a244-462d-aded-6e73fa1030df',
fieldMetadataUniversalIdentifier: PROPERTY_PRICE_FIELD_UNIVERSAL_IDENTIFIER,
direction: ViewSortDirection.DESC,
},
],
});

View File

@@ -0,0 +1,60 @@
import {
defineView,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
ViewFilterOperand,
ViewType,
} from 'twenty-sdk/define';
import {
BUYER_STAGE_FIELD_ID,
BUYER_STAGE_OPTIONS,
} from '../fields/opportunity-buyer-stage.field';
import { PROPERTY_ON_OPPORTUNITY_ID } from '../fields/property-on-opportunity.field';
import { BUYER_ON_OPPORTUNITY_ID } from '../fields/buyer-on-opportunity.field';
export const BUYER_PIPELINE_VIEW_ID = '4efec37d-ab61-4270-b8d0-f9ff6ff435c9';
const opportunityFields =
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields;
const GROUP_IDS = [
'ea85b2b4-4339-4a8d-9f00-b72d436ff315',
'daee17b1-0b3f-4897-905d-7f9f9ffe09ea',
'2c710417-40bc-4e64-902c-04962cec780a',
'2729a148-600e-4bad-b47a-e992ece189ce',
'6dbc865f-d0fc-4659-ae3a-86fc44db6c7a',
'64332ad1-f824-4085-b269-19c9c3f7a97d',
];
export default defineView({
universalIdentifier: BUYER_PIPELINE_VIEW_ID,
name: 'Buyer Pipeline',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: ViewType.KANBAN,
icon: 'IconLayoutKanban',
position: 0,
mainGroupByFieldMetadataUniversalIdentifier: BUYER_STAGE_FIELD_ID,
fields: [
{ universalIdentifier: 'aa354542-400a-4d55-bdbb-a7f014d51242', fieldMetadataUniversalIdentifier: opportunityFields.name.universalIdentifier, position: 0, isVisible: true, size: 200 },
{ universalIdentifier: '4eaeae10-80b8-490b-9f24-9a71beec9ac1', fieldMetadataUniversalIdentifier: BUYER_STAGE_FIELD_ID, position: 1, isVisible: true, size: 150 },
{ universalIdentifier: 'e0e095be-1f73-424c-95d6-e0e01e2fc608', fieldMetadataUniversalIdentifier: BUYER_ON_OPPORTUNITY_ID, position: 2, isVisible: true, size: 180 },
{ universalIdentifier: 'f62c744e-2859-4772-b1c5-1584a2e82c95', fieldMetadataUniversalIdentifier: PROPERTY_ON_OPPORTUNITY_ID, position: 3, isVisible: true, size: 180 },
{ universalIdentifier: '1661234f-bd05-4d8e-8716-ab2b17b0e5df', fieldMetadataUniversalIdentifier: opportunityFields.amount.universalIdentifier, position: 4, isVisible: true, size: 150 },
],
groups: BUYER_STAGE_OPTIONS.map((option, index) => ({
universalIdentifier: GROUP_IDS[index],
fieldValue: option.value,
position: index,
isVisible: true,
})),
// Scope the pipeline to real-estate deals (linked to a property),
// excluding any other opportunities in the workspace.
filters: [
{
universalIdentifier: '57cde77d-5f59-42ca-8722-44fe06689817',
fieldMetadataUniversalIdentifier: PROPERTY_ON_OPPORTUNITY_ID,
operand: ViewFilterOperand.IS_NOT_EMPTY,
value: '',
},
],
});

View File

@@ -0,0 +1,40 @@
import {
defineView,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
ViewFilterOperand,
ViewType,
} from 'twenty-sdk/define';
import { PERSON_TYPE_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/person-type.field';
import { BUDGET_MIN_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/person-budget-min.field';
import { BUDGET_MAX_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/person-budget-max.field';
import { PRE_APPROVED_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/person-pre-approved.field';
import { DESIRED_AREA_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/person-desired-area.field';
export const BUYERS_VIEW_ID = 'd34441a5-51bc-4826-b5cc-828d536de602';
const personFields = STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.fields;
export default defineView({
universalIdentifier: BUYERS_VIEW_ID,
name: 'Buyers',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: ViewType.TABLE,
icon: 'IconUserSearch',
position: 11,
fields: [
{ universalIdentifier: '70f73b71-afc5-44e3-b9d7-8bb89670cd91', fieldMetadataUniversalIdentifier: personFields.name.universalIdentifier, position: 0, isVisible: true, size: 200 },
{ universalIdentifier: '5ab16f37-55f6-4c45-98c4-096ef3087c43', fieldMetadataUniversalIdentifier: DESIRED_AREA_FIELD_UNIVERSAL_IDENTIFIER, position: 1, isVisible: true, size: 160 },
{ universalIdentifier: '2b1766af-894e-4b9a-a974-07953ceea577', fieldMetadataUniversalIdentifier: BUDGET_MIN_FIELD_UNIVERSAL_IDENTIFIER, position: 2, isVisible: true, size: 130 },
{ universalIdentifier: '6dd553b3-1c4b-4cea-9ae4-c600a8215bb4', fieldMetadataUniversalIdentifier: BUDGET_MAX_FIELD_UNIVERSAL_IDENTIFIER, position: 3, isVisible: true, size: 130 },
{ universalIdentifier: 'df024778-8780-4d9b-b78b-316fe5ce519d', fieldMetadataUniversalIdentifier: PRE_APPROVED_FIELD_UNIVERSAL_IDENTIFIER, position: 4, isVisible: true, size: 120 },
],
filters: [
{
universalIdentifier: 'f0550294-9a36-4ffc-a4d8-76ab048a7e6e',
fieldMetadataUniversalIdentifier: PERSON_TYPE_FIELD_UNIVERSAL_IDENTIFIER,
operand: ViewFilterOperand.IS,
value: ['BUYER'],
},
],
});

View File

@@ -0,0 +1,35 @@
import { defineView, ViewType } from 'twenty-sdk/define';
import {
SHOWING_INTEREST_LEVEL_FIELD_UNIVERSAL_IDENTIFIER,
SHOWING_NAME_FIELD_UNIVERSAL_IDENTIFIER,
SHOWING_SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER,
SHOWING_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
SHOWING_UNIVERSAL_IDENTIFIER,
} from '../objects/showing.object';
import { PROPERTY_ON_SHOWING_ID } from '../fields/property-on-showing.field';
import { BUYER_ON_SHOWING_ID } from '../fields/buyer-on-showing.field';
import { AGENT_ON_SHOWING_ID } from '../fields/agent-on-showing.field';
import { OPPORTUNITY_ON_SHOWING_ID } from '../fields/opportunity-on-showing.field';
const SHOWING_FEEDBACK_FIELD_ID = '377e4a00-8398-413a-8662-526236ff2d65';
export const SHOWING_RECORD_PAGE_FIELDS_VIEW_ID =
'aab8b6fa-ba20-4a4c-bd51-e3135e2e5b17';
export default defineView({
universalIdentifier: SHOWING_RECORD_PAGE_FIELDS_VIEW_ID,
name: 'Showing Record Page Fields',
objectUniversalIdentifier: SHOWING_UNIVERSAL_IDENTIFIER,
type: ViewType.FIELDS_WIDGET,
fields: [
{ universalIdentifier: '005556e9-620f-4dd2-8c80-ff3c2ebf34be', fieldMetadataUniversalIdentifier: SHOWING_NAME_FIELD_UNIVERSAL_IDENTIFIER, position: 0, isVisible: true },
{ universalIdentifier: '18573139-b71f-4939-8a6b-0534dcadca4e', fieldMetadataUniversalIdentifier: SHOWING_SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER, position: 1, isVisible: true },
{ universalIdentifier: 'ba643965-84e5-4298-84fa-e743335d1e65', fieldMetadataUniversalIdentifier: SHOWING_STATUS_FIELD_UNIVERSAL_IDENTIFIER, position: 2, isVisible: true },
{ universalIdentifier: '65a7b779-073a-467d-848b-b93bebb22431', fieldMetadataUniversalIdentifier: SHOWING_INTEREST_LEVEL_FIELD_UNIVERSAL_IDENTIFIER, position: 3, isVisible: true },
{ universalIdentifier: 'a6347283-e422-4fa8-aab7-f6ebbdeb462d', fieldMetadataUniversalIdentifier: SHOWING_FEEDBACK_FIELD_ID, position: 4, isVisible: true },
{ universalIdentifier: 'ef43efbf-7660-4a85-bb38-696e9702e2df', fieldMetadataUniversalIdentifier: PROPERTY_ON_SHOWING_ID, position: 5, isVisible: true },
{ universalIdentifier: '7b49387d-494a-4b19-89bb-19cdc31a2bbd', fieldMetadataUniversalIdentifier: BUYER_ON_SHOWING_ID, position: 6, isVisible: true },
{ universalIdentifier: 'dd260533-802a-439d-bc9e-653b47152acc', fieldMetadataUniversalIdentifier: AGENT_ON_SHOWING_ID, position: 7, isVisible: true },
{ universalIdentifier: 'fc77c926-5301-406e-9785-0c55138ea339', fieldMetadataUniversalIdentifier: OPPORTUNITY_ON_SHOWING_ID, position: 8, isVisible: true },
],
});

View File

@@ -0,0 +1,31 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"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"]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
# Credentials for integration tests (vitest.config.ts) and seed scripts
# (vitest.seed.config.ts). Copy this file to .env.local and fill in the key.
# Get an API key from the Twenty UI: Settings -> APIs & Webhooks.
# .env.local is gitignored; never commit a real key.
TWENTY_API_URL=http://localhost:2020
TWENTY_API_KEY=

View File

@@ -0,0 +1,49 @@
# 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
# local graphify index (never commit — run `graphify update .` locally)
graphify-out/
.superpowers/
docs/

View File

@@ -0,0 +1 @@
24.5.0

View File

@@ -0,0 +1,58 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.base.json"],
"plugins": ["react", "typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
],
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/jsx-key": "off",
"react/display-name": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-no-useless-fragment": "off",
"react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}

View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -0,0 +1,149 @@
# Architecture — twenty-partners
Vertical-slice modules, one folder per domain. Adapted from `twentyhq/twenty-eng`
(the largest Twenty SDK app), which migrated flat → modular as it grew. Repo-wide
conventions (kebab-case files, named exports, no `any`, `types` over `interface`,
short `//` comments) live in the root `CLAUDE.md` and still apply — this file only
adds the partners-specific structure.
> **Status:** migration complete — every domain lives under `src/modules/`. The only
> top-level `src/` entries left are `constants/universal-identifiers.ts`, `scripts/`,
> `roles/`, `skills/`, `workflows/`, `__tests__/`, and the two root config files. Add new
> code to a domain module, never a flat top-level `src/<primitive>/` folder.
## Layout
```
src/
application-config.ts # app + application variables — unchanged
default-role.ts # unchanged
constants/universal-identifiers.ts # ⚠️ MUST NOT MOVE (see Invariants)
scripts/ # ⚠️ MUST NOT MOVE — seed/purge/rls/slugify
roles/ # app-wide RLS — stays at root (spans every object)
skills/ # bundled Claude skills — unchanged
workflows/ # bundled workflows — unchanged
__tests__/helpers/ # shared test setup (client, fixtures, cleanup)
modules/
shared/ # cross-cutting: secret-guard (http/), find-or-create, paginate,
# utils, front-components, cross-domain nav folders
<domain>/ # partner · application · opportunity · … (large domains add a <feature>/ tier)
<name>.logic-function.ts # thin SDK entrypoints — sit at the domain/feature root, NOT in a subfolder
objects/ fields/ view-fields/ views/ navigation-menu-items/ page-layouts/ # declarations
constants/ # value lists + declaration UUID maps (NOT types)
services/ # business logic (testable)
graphql/{queries,mutations}/ # the only place raw queries/mutations live
connector/ # OUTBOUND third-party API per folder (<name>.connector.ts · config · types)
mappers/ utils/ types/ front-components/
```
A domain is a self-contained slice: its declarations (`objects`, `fields`, `views`, …)
**and** its logic (`logic-functions` → `services` → `graphql`/`connector`) live together.
Adding a feature means adding a folder, not scattering files across ten flat piles.
**Feature tier for large domains:** when a domain holds several distinct features, insert a
`<feature>/` level — `modules/<domain>/<feature>/<primitive>/` (the twenty-eng standard,
e.g. `modules/code-build/build-task/services/`). It keeps `graphql/` and `services/` from
becoming flat piles. Partner uses `partner/{self-service,marketplace,application-intake,
directory}/…`; opportunity uses `opportunity/{intake,matching}/…`. A small single-feature
domain (application) may keep `<domain>/<primitive>/` directly.
## The dependency rule (the one that matters — never import upward)
```
logic-functions → services → { graphql, connector, mappers } → shared · utils · types
```
- **Entrypoints (`*.logic-function.ts`, at the domain/feature root)** — SDK entrypoint
ONLY. Parse input, call one service, return. No business logic, no raw GraphQL, no
external HTTP. >~40 lines means it's doing a service's job — extract it.
- **`services/`** — all business logic. Testable, no SDK/transport coupling. Must **not**
import a `logic-function`.
- **`graphql/`** — the ONLY place raw queries/mutations live (named typed operations,
not inline `client.query(...)`).
- **`connector/`** — the ONLY place **outbound** third-party APIs are called; one folder
per API (`<name>.connector.ts` / `config.ts` / `types.ts`). **Inbound webhooks are NOT connectors** —
a webhook the app *receives* is an ordinary `logic-functions/` entrypoint guarded by the
shared secret-guard util. (Today the one real connector is Discord; TFT/client-brief/
partner-application are inbound.)
- Never import **logic** sideways between domains — share via `modules/shared/`. (A relation
field naturally referencing its target object's ID constant is a schema reference, not a
logic dependency — that cross-module import is allowed.)
## Naming
`<name>.<primitive>.ts` — e.g. `submit-application.logic-function.ts`,
`resolve-candidacy.service.ts`, `partner.object.ts`. Files are kebab-case (no PascalCase,
even for React components: `profile-picture-upload.tsx`).
Tests co-locate with their subject and split by kind: `<name>.test.ts` = unit (mocked
client, fast, no infra); `<name>.integration-test.ts` = real workspace (needs a live
server + global setup; excluded by tsconfig). The split is a selectable vitest **project**
(`--project unit`), not a file-count mandate — one `vitest.config.ts` with two projects is
preferred. Don't rename a mocked unit test to `.integration-test.ts`. Shared test setup
lives in `src/__tests__/helpers/`.
## Invariants (moving code must not break these)
- The set of `universalIdentifier` UUIDs must stay **byte-identical** across a move —
the SDK tracks primitives by identifier, not path, so a pure `git mv` is safe but a
changed/dropped UUID re-registers or orphans an object.
- **Do not move** `src/constants/universal-identifiers.ts` — the env-toolkit rewrites it
by path per bundle.
- **Do not move** `src/scripts/*` — `package.json` invokes them by path.
- **Keep importers of `src/constants/universal-identifiers.ts` pointing at the root file** —
don't relocate its UUID exports into a module.
- **Never split a relation across domains with a dangling ID import.** Paired relation
fields export/import each other's field-ID constant; keep both sides of a relation in one
domain, or hoist the shared ID into `shared/`.
- One domain per commit when migrating (`git mv` only, imports fixed in the same commit).
## Application review playbook
The Matching Admin Workspace playbook is a standalone page in
`src/modules/partner/application-intake/` (front-component + page layout +
nav item). It must stay in lockstep with real behaviour. When you change any
of the following, update the playbook in the same change:
- Website form fields that land on Partner
- The Partner Applications view (filter, columns, sort)
- `validationStage` values used as the review decision
- Intake side effects the reviewer needs to know (Discord on create;
welcome email on `VALIDATED`, linked from
`WELCOME_EMAIL_WORKFLOW_URL`)
- Marketplace ranking bonuses in
`packages/twenty-website/src/partners-marketplace/completeness-score.ts`
(case study, cover, introduction, service, picture, calendar, rate,
category, ghost rule)
Facts on the page come from the same constants the view and intake use. Do
not leave stale steps. Do not keep a second copy on twenty.com or in a Note.
## Opportunity process playbooks
How to process (Matching Admin) and How to apply (Partner Workspace) are
standalone pages under `src/modules/opportunity/how-to-process/`. They must
stay in lockstep with real operator behaviour. Update the matching page and
its copy test in the same change when you change any of:
- Intake (marketplace brief already Listed + Discord; Twenty Internal
unlisted; `/twenty-lead-brief` writes Design Doc URL)
- Listing (`Listed` flip → Discord; daily digest; Open Briefs membership)
- Intro (`Intro Sent At` turns Listed off; Invited applications)
- Win (set Partner → Won / Declined; Backup stays; unassign reopens Won
to Applied; Deals stages New → Screening → Meeting → Proposal → Customer)
- Apply (Open Briefs listed-only; pitch minimum; one pitch; state meaning)
- Bundled skills (`twenty-lead-brief`, `twenty-partner-shortlist`,
`twenty-partner-intro`): local run, expected output, GitHub URL of
`SKILL.md`
- Match via MCP: the operator can ask the agent to write CRM changes
Pages stay operator language. Skill names, slash triggers, and GitHub URLs
of those bundled skills are allowed on How to process. Open Briefs and
My Applications links are required on How to apply. Do not open Apply
from the standalone page — Apply is pinned on the brief record.
## Where does new code go?
External API call → `connector/`. Raw query/mutation → `graphql/`. Any logic →
`services/`. New SDK trigger/function → a thin `*.logic-function.ts` at the domain/feature
root that calls a service. Shared across domains → `modules/shared/`.

View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -0,0 +1,27 @@
# Twenty Partners
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.
## What's inside
- **Partner object** — slug, status, availability, served geographies, languages spoken, deployment expertise, Calendly link, and last-match timestamp.
- **Opportunity extensions** — match status, design-doc status, intro and relance timestamps, and a relation to the matched partner.
- **Automatic matching** — when an opportunity is set to auto-match, the longest-idle available partner is assigned and the deal is marked matched; if none is available it is handed off for manual matching with an explanatory note.
- **Views** — a waiting-for-match queue, a matching-funnel overview grouped by status, the partner index, and a log of matched deals, all surfaced in the sidebar.
## Match status pipeline
`matchStatus` follows 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 |

View File

@@ -0,0 +1,47 @@
{
"name": "twenty-partners",
"version": "1.8.3",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2",
"twenty": ">=2.31.0"
},
"keywords": [],
"packageManager": "yarn@4.13.0",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration",
"test:watch": "vitest",
"seed": "tsx src/scripts/seed.ts",
"seed:prod": "ENV_FILE=.env.prod tsx src/scripts/seed.ts",
"purge": "tsx src/scripts/purge-soft-deleted.ts",
"purge:prod": "ENV_FILE=.env.prod tsx src/scripts/purge-soft-deleted.ts",
"rls:configure": "tsx src/scripts/configure-partner-rls.ts",
"rls:configure:prod": "ENV_FILE=.env.prod tsx src/scripts/configure-partner-rls.ts"
},
"dependencies": {
"react-markdown": "^10.1.0",
"twenty-client-sdk": "2.31.0",
"twenty-sdk": "2.31.0",
"zod": "^4.1.11"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19",
"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": "^4.0.0"
}
}

View File

@@ -0,0 +1,74 @@
// Seed file: ensures at least one ACTIVE + AVAILABLE partner exists before
// the matching integration tests run. Idempotent — skips if already seeded.
import { CoreApiClient } from 'twenty-client-sdk/core';
import { beforeAll, describe, it } from 'vitest';
describe('seed: marketplace partners', () => {
let client: CoreApiClient;
beforeAll(() => {
client = new CoreApiClient();
});
it('ensures at least one ACTIVE AVAILABLE partner exists', async () => {
const existingResult = await client.query({
partners: {
__args: {
filter: { validationStage: { eq: 'VALIDATED' }, availability: { eq: 'AVAILABLE' } },
first: 1,
},
edges: { node: { id: true } },
},
} as any);
const existing = (existingResult as any).partners.edges;
if (existing.length > 0) {
console.log('[seed] partner already exists, skipping');
return;
}
const PARTNERS = [
{
slug: 'nine-dots-ventures',
name: 'Nine Dots Ventures',
introduction: 'Boutique CRM implementer specialising in real-estate workflows.',
calendlyLink: 'https://calendly.com/placeholder',
deploymentExpertise: ['CLOUD', 'SELF_HOST'],
servedGeos: ['EUROPE', 'MENA'],
languagesSpoken: ['ENGLISH', 'FRENCH'],
},
{
slug: 'elevate-consulting',
name: 'Elevate Consulting',
introduction: 'Revenue-operations partner for B2B SaaS teams.',
calendlyLink: 'https://calendly.com/placeholder',
deploymentExpertise: ['CLOUD'],
servedGeos: ['US', 'LATAM'],
languagesSpoken: ['ENGLISH', 'SPANISH'],
},
];
for (const p of PARTNERS) {
const r = await client.mutation({
createPartner: {
__args: {
data: {
name: p.name,
slug: p.slug,
introduction: p.introduction,
calendarLink: { primaryLinkUrl: p.calendlyLink },
deploymentExpertise: p.deploymentExpertise,
region: p.servedGeos,
languagesSpoken: p.languagesSpoken,
validationStage: 'VALIDATED',
availability: 'AVAILABLE',
},
},
id: true,
name: true,
},
} as any);
console.log('[seed] created', (r as any).createPartner.name);
}
});
});

View File

@@ -0,0 +1,87 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}

View File

@@ -0,0 +1,56 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
// Integration tests authenticate with the workspace API key, which the server
// rate-limits per workspace (API_RATE_LIMITING_LONG_LIMIT requests per window).
// The full suite issues more API calls than one window allows, so later calls
// would fail with "Limit reached". The server refuses a throttled request
// without consuming a token and its bucket refills continuously, so waiting
// briefly frees a token. Transparently retry rate-limited SDK calls after a
// short delay — patching the client prototype once covers every test.
const RATE_LIMIT_MAX_RETRIES = 30;
const RATE_LIMIT_RETRY_DELAY_MS = 700;
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
const isRateLimitError = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error);
return /limit reached|tokens per/i.test(message);
};
type AsyncMethod = (...args: unknown[]) => Promise<unknown>;
const RATE_LIMIT_PATCHED = Symbol.for('twenty-partners.rateLimitRetryPatched');
const patchMethod = (methodName: 'query' | 'mutation' | 'uploadFile'): void => {
const prototype = CoreApiClient.prototype as unknown as Record<
string,
AsyncMethod
>;
const original = prototype[methodName];
if (typeof original !== 'function') return;
prototype[methodName] = async function rateLimitRetry(...args: unknown[]) {
let attempt = 0;
for (;;) {
try {
return await original.apply(this, args);
} catch (error) {
if (!isRateLimitError(error) || attempt >= RATE_LIMIT_MAX_RETRIES) {
throw error;
}
attempt += 1;
await sleep(RATE_LIMIT_RETRY_DELAY_MS);
}
}
};
};
const clientConstructor = CoreApiClient as unknown as Record<symbol, boolean>;
if (!clientConstructor[RATE_LIMIT_PATCHED]) {
patchMethod('query');
patchMethod('mutation');
patchMethod('uploadFile');
clientConstructor[RATE_LIMIT_PATCHED] = true;
}

View File

@@ -0,0 +1,46 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CoreApiClient', () => {
it('should support CRUD on standard objects', async () => {
const client = new CoreApiClient();
const created = await client.mutation({
createNote: {
__args: { data: { title: 'Integration test note' } },
id: true,
},
});
expect(created.createNote.id).toBeDefined();
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
id: true,
},
});
});
});

View File

@@ -0,0 +1,33 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
applicationVariables: {
PARTNER_APPLICATION_SECRET: {
universalIdentifier: '2026a052-9f01-4d18-b6a7-31c3a5b1c7d2',
description:
"Shared secret required in the X-Application-Secret header on POST /partner-applications. Must match the website route's PARTNER_APPLICATION_SECRET env var. Set per-workspace in Settings → Apps → Twenty Partners → Variables.",
isSecret: true,
},
DISCORD_WEBHOOK_URL: {
universalIdentifier: '7056c98a-e7e1-4dba-8a40-b578f30b3479',
description:
'Discord incoming webhook URL. When set, a notification is posted to this channel each time the application form creates a new Partner, each time the public marketplace form submits a client brief (brief details, contact name and company, and the referring partner), and each time a brief is hand-listed on the marketplace. Leave empty to disable all three. Set per-workspace in Settings → Apps → Twenty Partners → Variables.',
isSecret: true,
},
PARTNER_APP_FRONTEND_URL: {
universalIdentifier: '746e7bd8-8934-414e-95f5-cc266a624616',
description:
'Workspace front-end base URL (e.g. https://partners.twenty.com), used to build the clickable Partner and Opportunity record links in Discord notifications. When empty, notifications are still sent but carry no links. Set per-workspace in Settings → Apps → Twenty Partners → Variables.',
isSecret: false,
},
},
});

View File

@@ -0,0 +1,78 @@
export const APP_DISPLAY_NAME = 'Twenty partners';
export const APP_DESCRIPTION = '';
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'e662fc1f-02c1-41ff-b8ba-c95a447b3965';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'ee18c3f3-ebe7-4c56-ad6d-aad555cc32db';
export const PARTNER_OBJECT_UNIVERSAL_IDENTIFIER = '39101b39-1c16-4148-9e82-45dc271bb90d';
export const PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd';
export const PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER =
'91c1bf51-f977-4ca5-9974-954fe23d1c1c';
export const PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER =
'cc953277-db73-45c5-9e6d-3967f9b7756f';
export const PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '3fe15ab5-e38b-4914-af17-2270b210aeb2';
export const POST_INSTALL_FN_UNIVERSAL_IDENTIFIER = 'f92bad2e-5905-4757-96ee-af9869d4ca0c';
export const ON_PARTNER_APPLICATION_CREATED_FN_UNIVERSAL_IDENTIFIER = '43888cce-a2aa-4100-afbc-59a4f978ce53';
export const ON_PARTNER_LINK_CREATED_FN_UNIVERSAL_IDENTIFIER = 'a80a85f8-e32b-429e-82e9-b71cb5da54cd';
export const ON_PARTNER_SERVICE_CREATED_FN_UNIVERSAL_IDENTIFIER = '20129ceb-981e-43da-a25d-0ff6fbe52060';
export const ON_PARTNER_CONTENT_CREATED_FN_UNIVERSAL_IDENTIFIER = '18d0f329-d4a6-4b82-87ea-b78aa1a73e47';
export const ON_WORKSPACE_MEMBER_CREATED_FN_UNIVERSAL_IDENTIFIER = '30c0857b-acd9-4362-b50c-4557d18a13b0';
export const ON_OPPORTUNITY_INTRO_SENT_FN_UNIVERSAL_IDENTIFIER = 'c55a14db-e8ed-4d08-963a-65c44b50bee9';
export const ON_OPPORTUNITY_LISTED_FN_UNIVERSAL_IDENTIFIER = 'ebbb2911-57e5-4edd-bd93-4a28939115c0';
export const INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER = 'fcf39b0c-0547-415e-806d-b238131ad7cc';
// Roles (Task 2)
export const PARTNER_ROLE_UNIVERSAL_IDENTIFIER = 'c3c1dc2e-1a08-4de5-abb7-2139b3d99343';
// Opportunity pipeline views + nav (simplified to partner presence; UIDs reused
// from the previous matching views so they update in place rather than re-create).
export const OPPORTUNITIES_NO_PARTNER_VIEW_UNIVERSAL_IDENTIFIER = 'fe11e738-6bf3-4714-929c-51c76a3fd050';
// Fresh id (the old matches-overview id '5a8fd51a…' kept its stale KANBAN type +
// view groups on re-sync; a new id forces a clean TABLE recreate).
export const OPPORTUNITIES_WITH_PARTNER_VIEW_UNIVERSAL_IDENTIFIER = '053449b0-3d13-400d-afc2-6182a75b6dff';
export const OPPORTUNITIES_NO_PARTNER_NAV_UNIVERSAL_IDENTIFIER = '00be7449-8927-47c8-a6a1-212d9106587f';
export const OPPORTUNITIES_WITH_PARTNER_NAV_UNIVERSAL_IDENTIFIER = '0cf349c9-fcbf-40f8-8e91-142c02bbde9c';
// Kanban board of with-partner opportunities, grouped by match status.
export const OPPORTUNITIES_WITH_PARTNER_BOARD_VIEW_UNIVERSAL_IDENTIFIER = 'a426c594-513d-4252-808f-3cf26d5532b9';
export const OPPORTUNITIES_WITH_PARTNER_BOARD_NAV_UNIVERSAL_IDENTIFIER = '3226b314-15e5-4182-89fe-b33d341f5684';
// Page layout (Task 6)
export const PARTNER_RECORD_PAGE_UNIVERSAL_IDENTIFIER = 'a888b39e-d64a-48ba-a044-d8cb685fad74';
// All Opportunities view + nav
export const ALL_OPPORTUNITIES_VIEW_UNIVERSAL_IDENTIFIER = '6ce1300b-6e91-4c28-83bb-6f692dbc7a98';
export const ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER = '37944f52-cbe5-4814-a1e6-be5b21425870';
// Partner views + nav (harmonization)
export const PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER = 'b57a84ed-d7c1-420d-b0eb-348db0dac612';
export const VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '13cca6a7-b9f1-4103-b011-ea2e39430899';
export const PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b891-6201149510b3';
export const PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER = '13e2334a-6b1e-4080-8c74-d11109990cc1';
export const VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '6aed30c6-d80f-4ac6-aab0-db5bc59e5c4b';
export const PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
export const MY_SERVICES_VIEW_UNIVERSAL_IDENTIFIER =
'04d85f9e-342b-4887-bbee-4d52b1dd5cb3';
export const MY_SERVICES_NAV_UNIVERSAL_IDENTIFIER =
'3cd81fb0-e6b9-4669-90d9-caf3ca6e8841';
export const MY_LINKS_VIEW_UNIVERSAL_IDENTIFIER =
'40def5de-e90a-4542-8512-2b63bae248dc';
export const MY_LINKS_NAV_UNIVERSAL_IDENTIFIER =
'bd7b422c-0201-4bef-9170-0a415150d29b';
export const MY_CASE_STUDIES_VIEW_UNIVERSAL_IDENTIFIER =
'e7c4a9b2-1d3f-4e5a-9b6c-8d7e6f5a4b3c';
export const MY_CASE_STUDIES_NAV_UNIVERSAL_IDENTIFIER =
'f8d5b0c3-2e41-5f6b-8c7d-9e8f7a6b5c4d';
// Opportunity record page (standard side panel Fields widget) — view-field UIDs
// reused from the former custom FIELDS_WIDGET view so sync updates in place.
export const OPPORTUNITY_RECORD_PAGE_IS_LISTED_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
'5f8c539e-82df-4db6-af05-d4c4f9b262b1';
export const OPPORTUNITY_RECORD_PAGE_NEED_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
'd44a8293-f9c5-41f6-9e08-85441475a1ce';
export const OPPORTUNITY_RECORD_PAGE_REQUIREMENTS_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
'3a97a3ce-ecfd-417f-8518-120899f1111e';
export const OPPORTUNITY_RECORD_PAGE_PARTNER_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
'd186d83a-2f6b-4693-b7eb-abc47d110ae8';
export const OPPORTUNITY_RECORD_PAGE_APPLICATIONS_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
'5c286d5d-c849-4210-ba55-7a2e7dc28ff1';

View File

@@ -0,0 +1,17 @@
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`,
canBeAssignedToUsers: false,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});

View File

@@ -0,0 +1,341 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { queryMock, mutationMock } = vi.hoisted(() => ({
queryMock: vi.fn(),
mutationMock: vi.fn(),
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(function () {
return { query: queryMock, mutation: mutationMock };
}),
}));
import { handler } from '../on-application-created.logic-function';
const MEMBER_ID = 'aaaaaaaa-1111-1111-1111-111111111111';
const PARTNER_ID = 'bbbbbbbb-2222-2222-2222-222222222222';
const APPLICATION_ID = 'cccccccc-3333-3333-3333-333333333333';
const OPPORTUNITY_ID = 'dddddddd-4444-4444-4444-444444444444';
const EXISTING_APPLICATION_ID = 'eeeeeeee-5555-5555-5555-555555555555';
const event = (after: Record<string, unknown>) =>
({ properties: { after } }) as never;
describe('on-application-created', () => {
beforeEach(() => {
queryMock.mockReset();
mutationMock.mockReset();
mutationMock.mockResolvedValue({ updateApplication: { id: APPLICATION_ID } });
});
it('does not stamp when the admin-created row already carries partnerUser and has no opportunity', async () => {
const result = await handler(
event({
id: APPLICATION_ID,
partnerId: PARTNER_ID,
partnerUserId: MEMBER_ID,
}),
);
expect(result).toEqual({});
expect(queryMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
});
it('grants opportunity visibility on an apply row that already has partnerUser', async () => {
queryMock
.mockResolvedValueOnce({
opportunities: {
edges: [
{ node: { id: OPPORTUNITY_ID, applicantPartnerUserIds: null } },
],
},
})
.mockResolvedValueOnce({
opportunities: {
edges: [
{
node: {
id: OPPORTUNITY_ID,
applicantPartnerUserIds: [MEMBER_ID],
},
},
],
},
});
const result = await handler(
event({
id: APPLICATION_ID,
partnerId: PARTNER_ID,
partnerUserId: MEMBER_ID,
opportunityId: OPPORTUNITY_ID,
}),
);
expect(result).toEqual({});
expect(mutationMock).toHaveBeenCalledTimes(1);
expect(mutationMock).toHaveBeenCalledWith({
updateOpportunity: {
__args: {
id: OPPORTUNITY_ID,
data: { applicantPartnerUserIds: [MEMBER_ID] },
},
id: true,
},
});
});
it('stamps partnerUser from the partner on an admin-created row', async () => {
queryMock.mockResolvedValue({
partners: { edges: [{ node: { id: PARTNER_ID, partnerUserId: MEMBER_ID } }] },
});
const result = await handler(
event({ id: APPLICATION_ID, partnerId: PARTNER_ID }),
);
expect(result).toEqual({ stamped: MEMBER_ID });
expect(mutationMock).toHaveBeenCalledTimes(1);
const args = mutationMock.mock.calls[0][0].updateApplication.__args;
expect(args.id).toBe(APPLICATION_ID);
expect(args.data).toEqual({ partnerUserId: MEMBER_ID });
});
it('stamps partnerUser and grants opportunity visibility on an invite with an opportunity', async () => {
queryMock
.mockResolvedValueOnce({
partners: {
edges: [{ node: { id: PARTNER_ID, partnerUserId: MEMBER_ID } }],
},
})
.mockResolvedValueOnce({
opportunities: {
edges: [
{ node: { id: OPPORTUNITY_ID, applicantPartnerUserIds: null } },
],
},
})
.mockResolvedValueOnce({
opportunities: {
edges: [
{
node: {
id: OPPORTUNITY_ID,
applicantPartnerUserIds: [MEMBER_ID],
},
},
],
},
});
const result = await handler(
event({
id: APPLICATION_ID,
partnerId: PARTNER_ID,
opportunityId: OPPORTUNITY_ID,
}),
);
expect(result).toEqual({ stamped: MEMBER_ID });
const stampCall = mutationMock.mock.calls.find(
(mutationCall) => mutationCall[0].updateApplication,
)?.[0];
expect(stampCall.updateApplication.__args.data).toEqual({
partnerUserId: MEMBER_ID,
});
const grantCall = mutationMock.mock.calls.find(
(mutationCall) => mutationCall[0].updateOpportunity,
)?.[0];
expect(grantCall).toEqual({
updateOpportunity: {
__args: {
id: OPPORTUNITY_ID,
data: { applicantPartnerUserIds: [MEMBER_ID] },
},
id: true,
},
});
});
it('leaves an admin-created row alone when the partner has no member', async () => {
queryMock.mockResolvedValue({
partners: { edges: [{ node: { id: PARTNER_ID, partnerUserId: null } }] },
});
const result = await handler(
event({ id: APPLICATION_ID, partnerId: PARTNER_ID }),
);
expect(result).toEqual({ skipped: true, reason: 'partner_has_no_user' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('leaves an admin-created row alone when the partner row is absent', async () => {
queryMock.mockResolvedValue({ partners: { edges: [] } });
const result = await handler(event({ id: APPLICATION_ID, partnerId: PARTNER_ID }));
expect(result).toEqual({ skipped: true, reason: 'partner_has_no_user' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('resolves the candidacy when the self-apply row already carries partnerUser', async () => {
queryMock.mockResolvedValue({
partners: { edges: [{ node: { id: PARTNER_ID } }] },
});
const result = await handler(
event({
id: APPLICATION_ID,
partnerUserId: MEMBER_ID,
createdBy: { workspaceMemberId: MEMBER_ID },
}),
);
expect(result).toEqual({ applied: true, partnerId: PARTNER_ID });
const args = mutationMock.mock.calls[0][0].updateApplication.__args;
expect(args.data.partnerId).toBe(PARTNER_ID);
expect(args.data.state).toBe('APPLIED');
});
it('does nothing when createdBy.workspaceMemberId is missing', async () => {
const result = await handler(
event({ id: APPLICATION_ID, createdBy: {} }),
);
expect(result).toEqual({});
expect(queryMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
});
it('does nothing when the member maps to no Partner', async () => {
queryMock.mockResolvedValue({ partners: { edges: [] } });
const result = await handler(
event({
id: APPLICATION_ID,
createdBy: { workspaceMemberId: MEMBER_ID },
}),
);
expect(result).toEqual({});
expect(queryMock).toHaveBeenCalledTimes(1);
expect(mutationMock).not.toHaveBeenCalled();
});
it('updates application with partnerId on the happy path', async () => {
queryMock.mockResolvedValue({
partners: { edges: [{ node: { id: PARTNER_ID } }] },
});
const result = await handler(
event({
id: APPLICATION_ID,
createdBy: { workspaceMemberId: MEMBER_ID },
}),
);
expect(result).toEqual({ applied: true, partnerId: PARTNER_ID });
expect(queryMock).toHaveBeenCalledTimes(1);
expect(mutationMock).toHaveBeenCalledTimes(1);
const call = mutationMock.mock.calls[0][0];
const args = call.updateApplication.__args;
expect(args.id).toBe(APPLICATION_ID);
expect(args.data.partnerId).toBe(PARTNER_ID);
expect(args.data.partnerUserId).toBe(MEMBER_ID);
expect(args.data.state).toBe('APPLIED');
});
it('stamps normally when no duplicate exists for the same opportunity and partner', async () => {
queryMock
.mockResolvedValueOnce({
partners: { edges: [{ node: { id: PARTNER_ID } }] },
})
.mockResolvedValueOnce({ applications: { edges: [] } })
.mockResolvedValueOnce({
opportunities: {
edges: [
{ node: { id: OPPORTUNITY_ID, applicantPartnerUserIds: null } },
],
},
})
.mockResolvedValueOnce({
opportunities: {
edges: [
{
node: {
id: OPPORTUNITY_ID,
applicantPartnerUserIds: [MEMBER_ID],
},
},
],
},
});
const result = await handler(
event({
id: APPLICATION_ID,
opportunityId: OPPORTUNITY_ID,
createdBy: { workspaceMemberId: MEMBER_ID },
}),
);
expect(result).toEqual({ applied: true, partnerId: PARTNER_ID });
const call = mutationMock.mock.calls.find(
(mutationCall) => mutationCall[0].updateApplication,
)?.[0];
expect(call.updateApplication.__args.id).toBe(APPLICATION_ID);
expect(call.updateApplication.__args.data.partnerId).toBe(PARTNER_ID);
expect(call.updateApplication.__args.data.partnerUserId).toBe(MEMBER_ID);
expect(call.updateApplication.__args.data.state).toBe('APPLIED');
const grantCall = mutationMock.mock.calls.find(
(mutationCall) => mutationCall[0].updateOpportunity,
)?.[0];
expect(grantCall).toEqual({
updateOpportunity: {
__args: {
id: OPPORTUNITY_ID,
data: { applicantPartnerUserIds: [MEMBER_ID] },
},
id: true,
},
});
});
it('soft-deletes a duplicate application and keeps the existing one', async () => {
queryMock
.mockResolvedValueOnce({
partners: { edges: [{ node: { id: PARTNER_ID } }] },
})
.mockResolvedValueOnce({
applications: { edges: [{ node: { id: EXISTING_APPLICATION_ID } }] },
});
mutationMock.mockResolvedValue({
deleteApplication: { id: APPLICATION_ID },
});
const result = await handler(
event({
id: APPLICATION_ID,
opportunityId: OPPORTUNITY_ID,
createdBy: { workspaceMemberId: MEMBER_ID },
}),
);
expect(result).toEqual({
duplicate: true,
keptExisting: EXISTING_APPLICATION_ID,
});
expect(queryMock).toHaveBeenCalledTimes(2);
expect(mutationMock).toHaveBeenCalledTimes(1);
expect(mutationMock.mock.calls[0][0].deleteApplication.__args.id).toBe(
APPLICATION_ID,
);
expect(
mutationMock.mock.calls.find((call) => call[0].updateApplication),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { queryMock, mutationMock } = vi.hoisted(() => ({
queryMock: vi.fn(),
mutationMock: vi.fn(),
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(function () {
return { query: queryMock, mutation: mutationMock };
}),
}));
import { handler } from '../on-application-set-name.logic-function';
const event = (after: Record<string, unknown>, updatedFields: string[]) =>
({ properties: { after, updatedFields } }) as never;
describe('on-application-set-name', () => {
beforeEach(() => {
queryMock.mockReset();
mutationMock.mockReset();
mutationMock.mockResolvedValue({ updateApplication: { id: 'a1' } });
});
it('labels the application from partner + opportunity names', async () => {
queryMock.mockResolvedValue({
applications: {
edges: [
{
node: {
id: 'a1',
partner: { name: 'Acme Partners' },
opportunity: { name: 'Q3 Renewal' },
},
},
],
},
});
await handler(event({ id: 'a1' }, ['partnerId']));
expect(mutationMock).toHaveBeenCalledWith({
updateApplication: {
__args: { id: 'a1', data: { name: 'Acme Partners · Q3 Renewal' } },
id: true,
},
});
});
it('ignores updates that touch neither relation', async () => {
await handler(event({ id: 'a1' }, ['pitch']));
expect(queryMock).not.toHaveBeenCalled();
expect(mutationMock).not.toHaveBeenCalled();
});
it('falls back to the placeholder label when the application row is absent', async () => {
queryMock.mockResolvedValue({ applications: { edges: [] } });
await handler(event({ id: 'a1' }, ['partnerId']));
expect(mutationMock).toHaveBeenCalledWith({
updateApplication: {
__args: { id: 'a1', data: { name: 'Unassigned · No brief' } },
id: true,
},
});
});
});

View File

@@ -0,0 +1,321 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { queryMock, mutationMock, metadataQueryMock } = vi.hoisted(() => ({
queryMock: vi.fn(),
mutationMock: vi.fn(),
metadataQueryMock: vi.fn(),
}));
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(function () {
return { query: queryMock, mutation: mutationMock };
}),
}));
vi.mock('twenty-client-sdk/metadata', () => ({
MetadataApiClient: vi.fn(function () {
return { query: metadataQueryMock };
}),
}));
import { MIN_PITCH_LENGTH } from 'src/modules/application/apply/constants/apply-to-brief.constants';
import { applyToBrief } from 'src/modules/application/apply/services/apply-to-brief.service';
const USER_ID = 'user-1';
const WORKSPACE_ID = 'workspace-1';
const WORKSPACE_MEMBER_ID = 'member-1';
const PARTNER_ID = 'partner-1';
const PARTNER_NAME = 'Acme Partners';
const OPPORTUNITY_ID = 'opportunity-1';
const AUTHORIZATION = 'Bearer caller-token';
const APP_TOKEN = `header.${Buffer.from(
JSON.stringify({ workspaceId: WORKSPACE_ID }),
).toString('base64url')}.sig`;
type QueryResponses = {
workspaceMembers?: unknown;
partners?: unknown;
opportunities?: unknown;
applications?: unknown;
};
const defaultResponses = (): QueryResponses => ({
workspaceMembers: { edges: [{ node: { id: WORKSPACE_MEMBER_ID } }] },
partners: { edges: [{ node: { id: PARTNER_ID, name: PARTNER_NAME } }] },
opportunities: {
edges: [
{ node: { id: OPPORTUNITY_ID, name: 'Q3 Renewal', isListed: true } },
],
},
applications: { edges: [] },
});
const respondWith = (overrides: QueryResponses = {}) => {
const responses = { ...defaultResponses(), ...overrides };
queryMock.mockImplementation((selection: Record<string, unknown>) => {
const key = Object.keys(selection)[0] as keyof QueryResponses;
return Promise.resolve({ [key as string]: responses[key] });
});
};
const pitchOf = (length: number) => 'a'.repeat(length);
const event = (
body: unknown,
authorization: string | null = AUTHORIZATION,
) => ({
body,
headers: { authorization: authorization ?? undefined },
});
const validBody = {
opportunityId: OPPORTUNITY_ID,
pitch: pitchOf(MIN_PITCH_LENGTH),
};
describe('applyToBrief', () => {
const originalAppToken = process.env.TWENTY_APP_ACCESS_TOKEN;
afterEach(() => {
if (originalAppToken === undefined)
delete process.env.TWENTY_APP_ACCESS_TOKEN;
else process.env.TWENTY_APP_ACCESS_TOKEN = originalAppToken;
});
beforeEach(() => {
queryMock.mockReset();
mutationMock.mockReset();
metadataQueryMock.mockReset();
process.env.TWENTY_APP_ACCESS_TOKEN = APP_TOKEN;
metadataQueryMock.mockResolvedValue({
currentUser: { id: USER_ID, currentWorkspace: { id: WORKSPACE_ID } },
});
mutationMock.mockResolvedValue({
createApplication: { id: 'application-1' },
});
respondWith();
});
it('refuses an unauthenticated caller without creating anything', async () => {
const result = await applyToBrief(event(validBody, null));
expect(result).toEqual({ ok: false, reason: 'UNAUTHENTICATED' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('refuses a caller with no partner without creating anything', async () => {
respondWith({ partners: { edges: [] } });
const result = await applyToBrief(event(validBody));
expect(result).toEqual({ ok: false, reason: 'NO_PARTNER' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('refuses a token minted in another workspace without creating anything', async () => {
metadataQueryMock.mockResolvedValue({
currentUser: { id: USER_ID, currentWorkspace: { id: 'workspace-other' } },
});
const result = await applyToBrief(event(validBody));
expect(result).toEqual({ ok: false, reason: 'UNAUTHENTICATED' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('refuses a brief that is not listed without creating anything', async () => {
respondWith({
opportunities: {
edges: [
{ node: { id: OPPORTUNITY_ID, name: 'Q3 Renewal', isListed: false } },
],
},
});
const result = await applyToBrief(event(validBody));
expect(result).toEqual({ ok: false, reason: 'BRIEF_NOT_OPEN' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('refuses a brief that does not exist without creating anything', async () => {
respondWith({ opportunities: { edges: [] } });
const result = await applyToBrief(event(validBody));
expect(result).toEqual({ ok: false, reason: 'BRIEF_NOT_OPEN' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('refuses a body without an opportunityId without creating anything', async () => {
const result = await applyToBrief(
event({ pitch: pitchOf(MIN_PITCH_LENGTH) }),
);
expect(result).toEqual({ ok: false, reason: 'BAD_REQUEST' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('refuses a pitch one character below the minimum after trim', async () => {
const pitch = ` ${pitchOf(MIN_PITCH_LENGTH - 1)} `;
const result = await applyToBrief(
event({ opportunityId: OPPORTUNITY_ID, pitch }),
);
expect(result).toEqual({ ok: false, reason: 'PITCH_TOO_SHORT' });
expect(mutationMock).not.toHaveBeenCalled();
});
it('refuses an existing application that already carries a pitch', async () => {
respondWith({
applications: {
edges: [
{
node: {
id: 'existing-application',
pitch: 'a real pitch',
state: 'APPLIED',
},
},
],
},
});
const result = await applyToBrief(event(validBody));
expect(result).toEqual({ ok: false, reason: 'ALREADY_APPLIED' });
expect(mutationMock).not.toHaveBeenCalled();
});
it.each([['INTRODUCED'], ['WON'], ['DECLINED']])(
'refuses to fill the pitch of a pitchless row already decided at %s',
async (state) => {
respondWith({
applications: {
edges: [{ node: { id: 'decided-application', pitch: null, state } }],
},
});
const result = await applyToBrief(event(validBody));
expect(result).toEqual({ ok: false, reason: 'BRIEF_NOT_OPEN' });
expect(mutationMock).not.toHaveBeenCalled();
},
);
it.each([['INVITED'], ['APPLIED'], ['BACKUP']])(
'fills the pitch of a pitchless row still in the running at %s',
async (state) => {
respondWith({
applications: {
edges: [{ node: { id: 'live-application', pitch: null, state } }],
},
});
const result = await applyToBrief(event(validBody));
expect(result).toEqual({ ok: true, applicationId: 'live-application' });
},
);
it.each([
['null', null],
['whitespace only', ' \n '],
])(
'fills the pitch of an existing row whose pitch is %s',
async (_label, pitch) => {
respondWith({
applications: {
edges: [
{ node: { id: 'invited-application', pitch, state: 'INVITED' } },
],
},
});
const submittedPitch = ` ${pitchOf(MIN_PITCH_LENGTH)} `;
const result = await applyToBrief(
event({ opportunityId: OPPORTUNITY_ID, pitch: submittedPitch }),
);
expect(result).toEqual({
ok: true,
applicationId: 'invited-application',
});
expect(mutationMock).toHaveBeenCalledTimes(1);
expect(mutationMock).toHaveBeenCalledWith({
updateApplication: {
__args: {
id: 'invited-application',
data: { pitch: submittedPitch.trim() },
},
id: true,
},
});
const data = mutationMock.mock.calls[0][0].updateApplication.__args.data;
expect(Object.keys(data)).toEqual(['pitch']);
expect(data).not.toHaveProperty('state');
},
);
it('creates the application once with the five derived fields', async () => {
const pitch = ` ${pitchOf(MIN_PITCH_LENGTH + 20)} `;
const result = await applyToBrief(
event({ opportunityId: OPPORTUNITY_ID, pitch }),
);
expect(result).toEqual({ ok: true, applicationId: 'application-1' });
expect(mutationMock).toHaveBeenCalledTimes(1);
expect(mutationMock).toHaveBeenCalledWith({
createApplication: {
__args: {
data: {
opportunityId: OPPORTUNITY_ID,
partnerId: PARTNER_ID,
partnerUserId: WORKSPACE_MEMBER_ID,
pitch: pitch.trim(),
name: 'Acme Partners · Q3 Renewal',
},
},
id: true,
},
});
});
it('accepts a pitch of exactly the minimum length', async () => {
const result = await applyToBrief(
event({
opportunityId: OPPORTUNITY_ID,
pitch: pitchOf(MIN_PITCH_LENGTH),
}),
);
expect(result).toEqual({ ok: true, applicationId: 'application-1' });
expect(mutationMock).toHaveBeenCalledTimes(1);
});
it('never reads partnerId or partnerUserId from the request body', async () => {
await applyToBrief(
event({
...validBody,
partnerId: 'spoofed-partner',
partnerUserId: 'spoofed-member',
}),
);
expect(mutationMock).toHaveBeenCalledWith(
expect.objectContaining({
createApplication: expect.objectContaining({
__args: {
data: expect.objectContaining({
partnerId: PARTNER_ID,
partnerUserId: WORKSPACE_MEMBER_ID,
}),
},
}),
}),
);
});
});

View File

@@ -0,0 +1,30 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { APPLY_TO_BRIEF_FUNCTION_ID } from 'src/modules/application/apply/constants/apply-to-brief.constants';
import { applyToBrief } from 'src/modules/application/apply/services/apply-to-brief.service';
import { type ApplyToBriefResult } from 'src/modules/application/apply/types/apply-to-brief.types';
export const handler = (
event: Pick<RoutePayload<unknown>, 'body' | 'headers'>,
): Promise<ApplyToBriefResult> => applyToBrief(event);
export default defineLogicFunction({
universalIdentifier: APPLY_TO_BRIEF_FUNCTION_ID,
name: 'apply-to-brief',
description:
'Creates an Application for the calling partner on a listed brief.',
// Six sequential round trips behind a cold isolate start: a first invocation measured
// 47s wall on a local dev server, so 20s killed it before the first query returned.
timeoutSeconds: 60,
handler,
httpRouteTriggerSettings: {
path: '/apply-to-brief',
httpMethod: 'POST',
// isAuthRequired would make the server mint a token carrying the caller's user claims,
// and the executor intersects the caller's role with the app role — the Partner role
// denies Application writes, so the create would be refused. The function verifies the
// forwarded header itself instead, and keeps an app-only token for the write.
isAuthRequired: false,
forwardedRequestHeaders: ['authorization'],
},
});

View File

@@ -0,0 +1,21 @@
import {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineCommandMenuItem,
} from 'twenty-sdk/define';
import {
APPLY_TO_BRIEF_COMMAND_MENU_ITEM_ID,
APPLY_TO_BRIEF_FRONT_COMPONENT_ID,
} from 'src/modules/application/apply/constants/apply-to-brief.constants';
export default defineCommandMenuItem({
universalIdentifier: APPLY_TO_BRIEF_COMMAND_MENU_ITEM_ID,
label: 'Apply to this brief',
shortLabel: 'Apply to this opportunity',
icon: 'IconSend',
isPinned: true,
availabilityType: 'RECORD_SELECTION',
availabilityObjectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
frontComponentUniversalIdentifier: APPLY_TO_BRIEF_FRONT_COMPONENT_ID,
});

View File

@@ -0,0 +1,12 @@
export const APPLY_TO_BRIEF_FRONT_COMPONENT_ID =
'54f96b09-3c98-4d3c-815a-3f95a2e42527';
export const APPLY_TO_BRIEF_FUNCTION_ID =
'38fb146a-abe0-495f-8e20-1ebe99a9926a';
export const APPLY_TO_BRIEF_COMMAND_MENU_ITEM_ID =
'e4668652-9212-4794-ab07-3514c1d0d829';
export const MIN_PITCH_LENGTH = 100;
// The states an existing row may still receive a pitch in. BACKUP belongs here: it is a
// standing shortlist set before the introduction, which is why the outcome cascade also
// leaves it alone. INTRODUCED, WON and DECLINED are decided.
export const PITCHABLE_STATES = ['APPLIED', 'INVITED', 'BACKUP'] as const;

View File

@@ -0,0 +1,179 @@
import { useCallback, useState, type CSSProperties } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
openSidePanelPage,
AppPath,
useFrontComponentExecutionContext,
type FrontComponentExecutionContext,
} from 'twenty-sdk/front-component';
import {
APPLY_TO_BRIEF_FRONT_COMPONENT_ID,
MIN_PITCH_LENGTH,
} from 'src/modules/application/apply/constants/apply-to-brief.constants';
import {
GENERIC_APPLY_FAILURE_MESSAGE,
getRefusalMessage,
} from 'src/modules/application/apply/front-components/apply-to-brief/refusal-message';
import { type ApplyToBriefResult } from 'src/modules/application/apply/types/apply-to-brief.types';
import { callAppRoute } from 'src/modules/shared/front-components/call-app-route';
import { COLORS, FONT } from 'src/modules/shared/front-components/palette';
const pageStyle: CSSProperties = {
fontFamily: FONT,
color: COLORS.fg,
maxWidth: '100%',
padding: '20px 24px',
display: 'flex',
flexDirection: 'column',
gap: 12,
};
const titleStyle: CSSProperties = { fontSize: 16, fontWeight: 700, margin: 0 };
const labelStyle: CSSProperties = {
fontSize: 12.5,
color: COLORS.muted,
lineHeight: 1.5,
};
const textareaStyle: CSSProperties = {
width: '100%',
boxSizing: 'border-box',
minHeight: 180,
padding: 12,
borderRadius: 8,
border: `1px solid ${COLORS.border}`,
background: COLORS.surface,
color: COLORS.fg,
fontSize: 14,
fontFamily: FONT,
lineHeight: 1.5,
outline: 'none',
resize: 'vertical',
};
const counterStyle: CSSProperties = { fontSize: 12, color: COLORS.muted };
const errorStyle: CSSProperties = { fontSize: 13, color: COLORS.danger };
const footerStyle: CSSProperties = {
display: 'flex',
justifyContent: 'flex-end',
};
const buttonStyle: CSSProperties = {
height: 36,
padding: '0 18px',
borderRadius: 8,
border: 'none',
background: COLORS.accent,
color: '#fff',
fontSize: 13.5,
fontWeight: 650,
fontFamily: FONT,
cursor: 'pointer',
};
const disabledButtonStyle: CSSProperties = {
...buttonStyle,
opacity: 0.5,
cursor: 'not-allowed',
};
// `recordId` is deprecated and only covers a renderer that sends no selection.
const selectBriefId = (
context: FrontComponentExecutionContext,
): string | null => context.selectedRecordIds[0] ?? context.recordId;
const ApplyToBrief = () => {
const briefId = useFrontComponentExecutionContext(selectBriefId);
const [pitch, setPitch] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const pitchLength = pitch.trim().length;
const isPitchLongEnough = pitchLength >= MIN_PITCH_LENGTH;
const apply = useCallback(async () => {
if (!briefId) return;
setIsSubmitting(true);
setErrorMessage(null);
try {
const result = (await callAppRoute('/apply-to-brief', {
opportunityId: briefId,
pitch,
})) as ApplyToBriefResult;
if (result.ok) {
await enqueueSnackbar({
message: 'Application sent',
variant: 'success',
});
await openSidePanelPage({
to: AppPath.RecordShowPage,
params: {
objectNameSingular: 'application',
objectRecordId: result.applicationId,
},
resetNavigationStack: true,
});
} else {
setErrorMessage(getRefusalMessage(result.reason));
}
} catch {
setErrorMessage(GENERIC_APPLY_FAILURE_MESSAGE);
} finally {
setIsSubmitting(false);
}
}, [briefId, pitch]);
if (!briefId) {
return <div style={pageStyle}>Open a brief to apply.</div>;
}
return (
<div style={pageStyle}>
<h1 style={titleStyle}>Apply to this brief</h1>
<label htmlFor="apply-pitch" style={labelStyle}>
Tell the client why your team fits this brief. Twenty introduces up to 2
partners per brief, so an application is not a guarantee of an
introduction.
</label>
<textarea
id="apply-pitch"
style={textareaStyle}
value={pitch}
onChange={(event) => setPitch(event.target.value)}
placeholder="Describe the relevant work you have done…"
disabled={isSubmitting}
/>
<div style={counterStyle}>
{pitchLength} / {MIN_PITCH_LENGTH} characters minimum
</div>
{errorMessage && (
<div role="alert" style={errorStyle}>
{errorMessage}
</div>
)}
<div style={footerStyle}>
<button
style={
isPitchLongEnough && !isSubmitting
? buttonStyle
: disabledButtonStyle
}
onClick={() => void apply()}
disabled={!isPitchLongEnough || isSubmitting}
>
{isSubmitting ? 'Sending…' : 'Send application'}
</button>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: APPLY_TO_BRIEF_FRONT_COMPONENT_ID,
name: 'Apply to Brief',
description: 'Side panel for a partner to pitch on an open brief.',
component: ApplyToBrief,
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import {
GENERIC_APPLY_FAILURE_MESSAGE,
getRefusalMessage,
} from './refusal-message';
describe('getRefusalMessage', () => {
it('maps every known reason to its own plain sentence', () => {
expect(getRefusalMessage('UNAUTHENTICATED')).toBe(
'Sign in again to apply.',
);
expect(getRefusalMessage('NO_PARTNER')).toBe(
'This account is not linked to a partner.',
);
expect(getRefusalMessage('BAD_REQUEST')).toBe(
'Reopen this brief and try again.',
);
expect(getRefusalMessage('BRIEF_NOT_OPEN')).toBe(
'This brief is no longer open for applications.',
);
expect(getRefusalMessage('PITCH_TOO_SHORT')).toBe(
'Add a little more detail before you apply.',
);
expect(getRefusalMessage('ALREADY_APPLIED')).toBe(
'You have already applied to this brief.',
);
});
it('falls back to the generic sentence for an unknown reason', () => {
expect(getRefusalMessage('SOMETHING_NEW')).toBe(
GENERIC_APPLY_FAILURE_MESSAGE,
);
expect(getRefusalMessage('')).toBe(GENERIC_APPLY_FAILURE_MESSAGE);
});
});

View File

@@ -0,0 +1,22 @@
import { type ApplyRefusalReason } from 'src/modules/application/apply/types/apply-to-brief.types';
export const GENERIC_APPLY_FAILURE_MESSAGE =
'Could not send your application. Try again.';
const REFUSAL_MESSAGES: Record<ApplyRefusalReason, string> = {
UNAUTHENTICATED: 'Sign in again to apply.',
NO_PARTNER: 'This account is not linked to a partner.',
BAD_REQUEST: 'Reopen this brief and try again.',
BRIEF_NOT_OPEN: 'This brief is no longer open for applications.',
PITCH_TOO_SHORT: 'Add a little more detail before you apply.',
ALREADY_APPLIED: 'You have already applied to this brief.',
};
const isApplyRefusalReason = (reason: string): reason is ApplyRefusalReason =>
reason in REFUSAL_MESSAGES;
// The route may add a reason the front component does not know yet, so never index blindly.
export const getRefusalMessage = (reason: string): string =>
isApplyRefusalReason(reason)
? REFUSAL_MESSAGES[reason]
: GENERIC_APPLY_FAILURE_MESSAGE;

View File

@@ -0,0 +1,10 @@
import { type CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
export function createApplication(
client: CoreApiClient,
data: CoreSchema.ApplicationCreateInput,
) {
return client.mutation({
createApplication: { __args: { data }, id: true },
});
}

View File

@@ -0,0 +1,15 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
// List form on purpose: the single-record read throws `Record not found` for an unknown id,
// which would hide a missing brief behind the generic failure message.
export function findOpportunityForApply(
client: CoreApiClient,
opportunityId: string,
) {
return client.query({
opportunities: {
__args: { filter: { id: { eq: opportunityId } }, first: 1 },
edges: { node: { id: true, name: true, isListed: true } },
},
});
}

View File

@@ -0,0 +1,94 @@
import { type RoutePayload } from 'twenty-sdk/define';
import { z } from 'zod';
import {
MIN_PITCH_LENGTH,
PITCHABLE_STATES,
} from 'src/modules/application/apply/constants/apply-to-brief.constants';
import { createApplication } from 'src/modules/application/apply/graphql/mutations/create-application';
import { findOpportunityForApply } from 'src/modules/application/apply/graphql/queries/find-opportunity-for-apply';
import { type ApplyToBriefResult } from 'src/modules/application/apply/types/apply-to-brief.types';
import { updateApplication } from 'src/modules/application/graphql/mutations/update-application';
import { findDuplicateApplication } from 'src/modules/application/graphql/queries/find-duplicate-application';
import { buildApplicationName } from 'src/modules/application/utils/build-application-name';
import {
buildAppClient,
errorResponse,
failureResponse,
resolvePartnerFromForwardedToken,
} from 'src/modules/shared/http/resolve-partner-from-request.service';
import { isNonEmptyString } from 'src/modules/shared/utils/is-non-empty-string.util';
const applyToBriefSchema = z.object({
opportunityId: z.string().min(1),
pitch: z.string(),
});
export const applyToBrief = async (
event: Pick<RoutePayload<unknown>, 'body' | 'headers'>,
): Promise<ApplyToBriefResult> => {
const resolved = await resolvePartnerFromForwardedToken(event);
if ('error' in resolved) return errorResponse(resolved.error);
const parsed = applyToBriefSchema.safeParse(event.body);
if (!parsed.success) return errorResponse('BAD_REQUEST');
const { opportunityId, pitch } = parsed.data;
try {
const client = buildAppClient();
const briefs = await findOpportunityForApply(client, opportunityId);
const brief = briefs.opportunities?.edges?.[0]?.node;
if (!brief || brief.isListed !== true)
return errorResponse('BRIEF_NOT_OPEN');
const trimmedPitch = pitch.trim();
if (trimmedPitch.length < MIN_PITCH_LENGTH)
return errorResponse('PITCH_TOO_SHORT');
const duplicates = await findDuplicateApplication(
client,
opportunityId,
resolved.partnerId,
);
const existing = duplicates.applications?.edges?.[0]?.node;
if (existing?.id) {
if (isNonEmptyString(existing.pitch))
return errorResponse('ALREADY_APPLIED');
// Fill the pitch in place and leave the state alone — an INVITED row records that
// Twenty pushed this brief at the partner. Guard on the state rather than on the
// brief being listed: the WON/DECLINED cascade fires on Opportunity.partner, which
// is not coupled to isListed, so a decided row can still sit under a listed brief.
if (!PITCHABLE_STATES.some((state) => state === existing.state)) {
return errorResponse('BRIEF_NOT_OPEN');
}
await updateApplication(client, existing.id, { pitch: trimmedPitch });
return { ok: true, applicationId: existing.id };
}
// on-application-set-name fires on application.updated only, so an insert that already
// carries partnerId never triggers it — the label must be written here.
const created = await createApplication(client, {
opportunityId,
partnerId: resolved.partnerId,
partnerUserId: resolved.workspaceMemberId,
pitch: trimmedPitch,
name: buildApplicationName(resolved.partnerName, brief.name),
});
const applicationId = created.createApplication?.id;
if (!isNonEmptyString(applicationId)) {
return failureResponse(
'apply-to-brief',
new Error('createApplication returned no id'),
);
}
return { ok: true, applicationId };
} catch (err) {
return failureResponse('apply-to-brief', err);
}
};

View File

@@ -0,0 +1,12 @@
export type ApplyRefusalReason =
| 'UNAUTHENTICATED'
| 'NO_PARTNER'
| 'BAD_REQUEST'
| 'BRIEF_NOT_OPEN'
| 'PITCH_TOO_SHORT'
| 'ALREADY_APPLIED';
// `reason` widens to string because failureResponse answers with a sentence, not a code.
export type ApplyToBriefResult =
| { ok: true; applicationId: string }
| { ok: false; reason: ApplyRefusalReason | string };

View File

@@ -0,0 +1,27 @@
import {
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineField,
} from 'twenty-sdk/define';
import {
APPLICATIONS_AS_PARTNER_USER_FIELD_ID,
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_PARTNER_USER_FIELD_ID,
} from 'src/modules/application/objects/application.object';
export default defineField({
universalIdentifier: APPLICATIONS_AS_PARTNER_USER_FIELD_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerApplications',
label: 'Partner Applications',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
APPLICATION_PARTNER_USER_FIELD_ID,
universalSettings: { relationType: RelationType.ONE_TO_MANY },
});

View File

@@ -0,0 +1,21 @@
import { FieldType, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import {
APPLICATIONS_ON_PARTNER_FIELD_ID,
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_PARTNER_FIELD_ID,
} from 'src/modules/application/objects/application.object';
export default defineField({
universalIdentifier: APPLICATIONS_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'applications',
label: 'Applications',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: APPLICATION_PARTNER_FIELD_ID,
universalSettings: { relationType: RelationType.ONE_TO_MANY },
});

View File

@@ -0,0 +1,10 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
export function deleteApplication(client: CoreApiClient, id: string) {
return client.mutation({
deleteApplication: {
__args: { id },
id: true,
},
});
}

View File

@@ -0,0 +1,11 @@
import { type CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
export function updateApplication(
client: CoreApiClient,
id: string,
data: CoreSchema.ApplicationUpdateInput,
) {
return client.mutation({
updateApplication: { __args: { id, data }, id: true },
});
}

View File

@@ -0,0 +1,17 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
// List form on purpose: the single-record read throws `Record not found` for an unknown id.
export function findApplicationWithRelations(client: CoreApiClient, id: string) {
return client.query({
applications: {
__args: { filter: { id: { eq: id } }, first: 1 },
edges: {
node: {
id: true,
partner: { name: true },
opportunity: { name: true },
},
},
},
});
}

View File

@@ -0,0 +1,20 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
export function findDuplicateApplication(
client: CoreApiClient,
opportunityId: string,
partnerId: string,
) {
return client.query({
applications: {
__args: {
filter: {
opportunityId: { eq: opportunityId },
partnerId: { eq: partnerId },
},
first: 1,
},
edges: { node: { id: true, pitch: true, state: true } },
},
});
}

View File

@@ -0,0 +1,12 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import { APPLICATIONS_BY_OPPORTUNITY_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/applications-by-opportunity.view';
export default defineNavigationMenuItem({
universalIdentifier: 'e80057bf-9b46-4502-8ccd-b25870ec293c',
type: NavigationMenuItemType.VIEW,
icon: 'IconTargetArrow',
position: 6,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: APPLICATIONS_BY_OPPORTUNITY_VIEW_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,12 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import { APPLICATIONS_REVIEW_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/applications-review.view';
export default defineNavigationMenuItem({
universalIdentifier: 'fcf7e5e8-9ec2-4f08-898a-39e51a2787d0',
type: NavigationMenuItemType.VIEW,
icon: 'IconUserPlus',
position: 4,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: APPLICATIONS_REVIEW_VIEW_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,17 @@
import {
NavigationMenuItemType,
defineNavigationMenuItem,
} from 'twenty-sdk/define';
import { MY_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/my-applications.view';
import { PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/shared/navigation-menu-items/partner-workspace-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: '71c418f0-5761-4797-830a-2cae6d4bdd49',
type: NavigationMenuItemType.VIEW,
icon: 'IconSend',
position: 2,
folderUniversalIdentifier: PARTNER_WORKSPACE_FOLDER_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier: MY_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,15 @@
import {
NavigationMenuItemType,
defineNavigationMenuItem,
} from 'twenty-sdk/define';
import { PIPELINE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/application/views/pipeline.view';
export default defineNavigationMenuItem({
universalIdentifier: 'ae1632fc-c53c-4a27-8e07-ad1f3a2d08b9',
type: NavigationMenuItemType.VIEW,
icon: 'IconLayoutKanban',
position: 3,
folderUniversalIdentifier: '0b2e499a-ae74-45e0-af08-243e19fc56aa',
viewUniversalIdentifier: PIPELINE_VIEW_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,169 @@
import {
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineObject,
} from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER =
'f17ec4d0-00c5-4137-9c8b-5507934d80cf';
export const APPLICATION_NAME_FIELD_ID = '51ff5f75-9df2-485f-8852-22484ae0cc3e';
export const APPLICATION_OPPORTUNITY_FIELD_ID =
'8aba48ca-1bfb-4141-b64d-b23d7f5fc41f';
export const APPLICATIONS_ON_OPPORTUNITY_FIELD_ID =
'4a6ce47b-a56b-4b21-81f4-83021fb0e09d';
export const APPLICATION_PARTNER_FIELD_ID =
'2ab5b079-fef3-452c-b4c0-a32ca1709157';
export const APPLICATIONS_ON_PARTNER_FIELD_ID =
'e36002c5-f71b-4048-953a-79f68baba4e2';
export const APPLICATION_PARTNER_USER_FIELD_ID =
'2895bcbb-df5a-4faa-af70-097729c1dd01';
export const APPLICATIONS_AS_PARTNER_USER_FIELD_ID =
'1010f214-ab3f-43aa-a19e-d351bddc2d61';
export const APPLICATION_STATE_FIELD_ID =
'c686db4f-f0e9-41a0-bdb3-3b4024ff59b6';
export const APPLICATION_PITCH_FIELD_ID =
'0a6cd9c9-e1e9-4315-8356-b72077443805';
export default defineObject({
universalIdentifier: APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'application',
namePlural: 'applications',
labelSingular: 'Application',
labelPlural: 'Applications',
description: 'A partner in play on an opportunity-brief — invited or applied',
icon: 'IconSend',
isSearchable: false,
labelIdentifierFieldMetadataUniversalIdentifier: APPLICATION_NAME_FIELD_ID,
fields: [
{
universalIdentifier: APPLICATION_NAME_FIELD_ID,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
icon: 'IconTag',
defaultValue: "''",
},
{
universalIdentifier: APPLICATION_OPPORTUNITY_FIELD_ID,
type: FieldType.RELATION,
name: 'opportunity',
label: 'Opportunity',
icon: 'IconTargetArrow',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
APPLICATIONS_ON_OPPORTUNITY_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'opportunityId',
},
},
{
universalIdentifier: APPLICATION_PARTNER_FIELD_ID,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
icon: 'IconBuildingStore',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
APPLICATIONS_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
},
{
universalIdentifier: APPLICATION_PARTNER_USER_FIELD_ID,
type: FieldType.RELATION,
name: 'partnerUser',
label: 'Partner User',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember
.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
APPLICATIONS_AS_PARTNER_USER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerUserId',
},
},
{
// Candidacy funnel. WON is set ONLY by on-opportunity-partner-won (Task 3) — a locked
// mirror of Opportunity.partner, never a user-set value.
universalIdentifier: APPLICATION_STATE_FIELD_ID,
type: FieldType.SELECT,
name: 'state',
label: 'State',
icon: 'IconProgressCheck',
isNullable: false,
// Self-apply is the primary path → default APPLIED so a partner who applies never sees a
// misleading "Invited" flicker while on-application-created (async) stamps the candidacy.
// Admin invites are a manual action that explicitly sets INVITED.
defaultValue: "'APPLIED'",
options: [
{
id: '7f9d5ae7-64be-4269-8fab-3cfeaf352bcb',
value: 'APPLIED',
label: 'Applied',
position: 0,
color: 'blue',
},
{
id: '2ce94524-4ac3-4a4a-a87c-5c9841eced7c',
value: 'INVITED',
label: 'Invited',
position: 1,
color: 'gray',
},
{
id: 'e4e3d579-17ef-4ef8-b9be-54f7d361ad51',
value: 'INTRODUCED',
label: 'Introduced',
position: 2,
color: 'purple',
},
{
id: '66ff342c-577b-4a84-bbdc-4ffbfa0bdd0e',
value: 'WON',
label: 'Won',
position: 3,
color: 'green',
},
{
id: '83846e19-5008-4f06-b62d-7a1f0dad23e2',
value: 'DECLINED',
label: 'Declined',
position: 4,
color: 'red',
},
{
id: '124f7e81-39b4-47cc-83f7-7dfb81ea751d',
value: 'BACKUP',
label: 'Backup',
position: 5,
color: 'orange',
},
],
},
{
universalIdentifier: APPLICATION_PITCH_FIELD_ID,
type: FieldType.TEXT,
name: 'pitch',
label: 'Pitch',
icon: 'IconMessage',
isNullable: true,
},
],
});

View File

@@ -0,0 +1,23 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { resolveCandidacy } from 'src/modules/application/services/resolve-candidacy.service';
const ON_APPLICATION_CREATED_FN_ID = '0e055a1c-b8b3-4572-89f3-e76e37bc3f9e';
export const handler = async (
payload: DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.Application>>,
): Promise<Record<string, unknown>> =>
resolveCandidacy(new CoreApiClient(), payload.properties.after);
export default defineLogicFunction({
universalIdentifier: ON_APPLICATION_CREATED_FN_ID,
name: 'on-application-created',
timeoutSeconds: 15,
handler,
databaseEventTriggerSettings: { eventName: 'application.created' },
});

View File

@@ -0,0 +1,39 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk/define';
import { findApplicationWithRelations } from 'src/modules/application/graphql/queries/find-application-with-relations';
import { updateApplication } from 'src/modules/application/graphql/mutations/update-application';
import { buildApplicationName } from 'src/modules/application/utils/build-application-name';
const ON_APPLICATION_SET_NAME_FN_ID = '8f123b54-a7c5-42d3-8049-a240930257a7';
// Re-label an Application "<partner> · <opportunity>" whenever either relation changes.
export const handler = async (
payload: DatabaseEventPayload<ObjectRecordUpdateEvent<CoreSchema.Application>>,
): Promise<Record<string, unknown>> => {
const { after, updatedFields } = payload.properties;
const touchedRelation = updatedFields?.includes('partnerId') || updatedFields?.includes('opportunityId');
if (!touchedRelation) return {};
const id = after?.id;
if (!id) return {};
const client = new CoreApiClient();
const result = await findApplicationWithRelations(client, id);
const application = result.applications?.edges?.[0]?.node;
const name = buildApplicationName(application?.partner?.name, application?.opportunity?.name);
await updateApplication(client, id, { name });
return { labelled: name };
};
export default defineLogicFunction({
universalIdentifier: ON_APPLICATION_SET_NAME_FN_ID,
name: 'on-application-set-name',
timeoutSeconds: 15,
handler,
databaseEventTriggerSettings: { eventName: 'application.updated' },
});

View File

@@ -0,0 +1,49 @@
import { PageLayoutTabLayoutMode, definePageLayout } from 'twenty-sdk/define';
import { APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/modules/application/objects/application.object';
import { APPLICATION_RECORD_PAGE_FIELDS_VIEW_ID } from 'src/modules/application/views/application-record-page-fields.view';
// Application is a custom (app-owned) object, so we fully control its record page (unlike the
// standard Opportunity). The Fields widget points at the FIELDS_WIDGET view so the opportunity +
// partner relations show in the side panel.
export default definePageLayout({
universalIdentifier: '7280910c-a409-4afc-9dac-ae9a8b3ca244',
name: 'Default Application Layout',
type: 'RECORD_PAGE',
objectUniversalIdentifier: APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '14270284-dbd2-4d03-b9cd-a8e78c325394',
title: 'Home',
position: 10,
icon: 'IconHome',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: '7f4a57f9-44f8-4fa4-bcdb-b25055c33db0',
title: 'Fields',
type: 'FIELDS',
configuration: {
configurationType: 'FIELDS',
viewUniversalIdentifier: APPLICATION_RECORD_PAGE_FIELDS_VIEW_ID,
},
},
],
},
{
universalIdentifier: '8f09a425-95ce-44a3-92af-7b37a1842851',
title: 'Timeline',
position: 20,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: '3e9acb85-9445-40d5-9c65-57461df8483c',
title: 'Timeline',
type: 'TIMELINE',
configuration: { configurationType: 'TIMELINE' },
},
],
},
],
});

View File

@@ -0,0 +1,80 @@
import type { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
} from 'twenty-sdk/define';
import { findDuplicateApplication } from 'src/modules/application/graphql/queries/find-duplicate-application';
import { deleteApplication } from 'src/modules/application/graphql/mutations/delete-application';
import { updateApplication } from 'src/modules/application/graphql/mutations/update-application';
import { findPartnerByMember } from 'src/modules/shared/graphql/queries/find-partner-by-member';
import { getPartnerOwner } from 'src/modules/shared/graphql/queries/get-partner-owner';
import { grantOpportunityVisibility } from 'src/modules/shared/services/grant-opportunity-visibility.service';
type ApplicationCreatedProperties = DatabaseEventPayload<
ObjectRecordCreateEvent<CoreSchema.Application>
>['properties'];
// The app route POST /apply-to-brief creates the Application with partnerId and
// partnerUserId already set, so this service skips the candidacy stamp and only grants
// opportunity read access. It still serves the admin path: an invite or an import sets
// partnerId but no partnerUserId, and this service stamps the partner's user so RLS
// doesn't hide the row from its own partner. The createdBy-based self-apply branch below
// is a fallback for rows created by a member without a partner set.
export async function resolveCandidacy(
client: CoreApiClient,
after: ApplicationCreatedProperties['after'],
): Promise<Record<string, unknown>> {
const applicationId = after?.id;
if (!applicationId) return {};
const opportunityId = after.opportunityId;
// Admin path (invite/import): without partnerUser, RLS hides the row from its own partner.
if (after.partnerId) {
if (after.partnerUserId) {
await grantOpportunityVisibility(client, opportunityId, [
after.partnerUserId,
]);
return {};
}
const ownerRes = await getPartnerOwner(client, after.partnerId);
const partnerUserId = ownerRes.partners?.edges?.[0]?.node?.partnerUserId;
if (!partnerUserId) return { skipped: true, reason: 'partner_has_no_user' };
await updateApplication(client, applicationId, { partnerUserId });
await grantOpportunityVisibility(client, opportunityId, [partnerUserId]);
return { stamped: partnerUserId };
}
const memberId = after.createdBy?.workspaceMemberId;
if (!memberId) return {}; // no member actor (system/import) — not a self-apply
const partnerRes = await findPartnerByMember(client, memberId);
const partnerId = partnerRes.partners?.edges?.[0]?.node?.id;
if (!partnerId) return {}; // creator isn't a partner (e.g. admin) — leave it
if (opportunityId) {
const existingRes = await findDuplicateApplication(client, opportunityId, partnerId);
const existingId = existingRes.applications?.edges?.find(
(edge) => edge.node?.id && edge.node.id !== applicationId,
)?.node?.id;
if (existingId) {
// No grant here on purpose: the kept row's own member holds it. A second member of
// the same partner never sees the first member's application either (partnerUser IS me).
await deleteApplication(client, applicationId);
return { duplicate: true, keptExisting: existingId };
}
}
// ponytail: dedupe by (opportunity, partner) above; two near-simultaneous creates could still both pass before either stamps — acceptable.
await updateApplication(client, applicationId, {
partnerId,
partnerUserId: memberId,
state: 'APPLIED',
});
await grantOpportunityVisibility(client, opportunityId, [memberId]);
return { applied: true, partnerId };
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { buildApplicationName } from 'src/modules/application/utils/build-application-name';
describe('buildApplicationName', () => {
it('joins both names with the separator', () => {
expect(buildApplicationName('Acme Partners', 'Q3 Renewal')).toBe('Acme Partners · Q3 Renewal');
});
it('falls back to Unassigned when the partner name is missing', () => {
expect(buildApplicationName(null, 'Q3 Renewal')).toBe('Unassigned · Q3 Renewal');
expect(buildApplicationName(undefined, 'Q3 Renewal')).toBe('Unassigned · Q3 Renewal');
});
it('falls back to No brief when the opportunity name is missing', () => {
expect(buildApplicationName('Acme Partners', null)).toBe('Acme Partners · No brief');
expect(buildApplicationName('Acme Partners', undefined)).toBe('Acme Partners · No brief');
});
it('falls back to both defaults when both names are missing', () => {
expect(buildApplicationName(null, undefined)).toBe('Unassigned · No brief');
});
});

View File

@@ -0,0 +1,8 @@
export const buildApplicationName = (
partnerName: string | null | undefined,
opportunityName: string | null | undefined,
): string => {
const resolvedPartnerName = partnerName ?? 'Unassigned';
const resolvedOpportunityName = opportunityName ?? 'No brief';
return `${resolvedPartnerName} · ${resolvedOpportunityName}`;
};

View File

@@ -0,0 +1,48 @@
import { ViewType, defineView } from 'twenty-sdk/define';
import {
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_OPPORTUNITY_FIELD_ID,
APPLICATION_PARTNER_FIELD_ID,
APPLICATION_PITCH_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/modules/application/objects/application.object';
export const APPLICATION_RECORD_PAGE_FIELDS_VIEW_ID =
'e004c2ff-462a-45d4-8a77-071dcf093879';
// FIELDS_WIDGET view backing the Application record page side panel. Relation fields
// (opportunity, partner) only render in the fields widget when an explicit view marks them
// visible — this is that view.
export default defineView({
universalIdentifier: APPLICATION_RECORD_PAGE_FIELDS_VIEW_ID,
name: 'Application Record Page Fields',
objectUniversalIdentifier: APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.FIELDS_WIDGET,
fields: [
{
universalIdentifier: '44793f47-5d06-43fa-9d33-23ec7602ecfc',
fieldMetadataUniversalIdentifier: APPLICATION_OPPORTUNITY_FIELD_ID,
position: 0,
isVisible: true,
},
{
universalIdentifier: '7d7ef49a-6d30-44de-b4eb-bb77d93b8982',
fieldMetadataUniversalIdentifier: APPLICATION_PARTNER_FIELD_ID,
position: 1,
isVisible: true,
},
{
universalIdentifier: 'd0278990-928c-4660-ac27-3abca0e52a5e',
fieldMetadataUniversalIdentifier: APPLICATION_STATE_FIELD_ID,
position: 2,
isVisible: true,
},
{
universalIdentifier: '28e0642b-76eb-4d41-83de-b84f07b3aad9',
fieldMetadataUniversalIdentifier: APPLICATION_PITCH_FIELD_ID,
position: 3,
isVisible: true,
},
],
});

View File

@@ -0,0 +1,60 @@
import { ViewSortDirection, ViewType, defineView } from 'twenty-sdk/define';
import {
APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
APPLICATION_OPPORTUNITY_FIELD_ID,
APPLICATION_PARTNER_FIELD_ID,
APPLICATION_PITCH_FIELD_ID,
APPLICATION_STATE_FIELD_ID,
} from 'src/modules/application/objects/application.object';
export const APPLICATIONS_BY_OPPORTUNITY_VIEW_UNIVERSAL_IDENTIFIER =
'db489249-284d-41b1-8537-71a655389cb7';
// Relation fields cannot drive TABLE group-by in Twenty (only SELECT + static
// groups[]). Sort by opportunity so applicants cluster per brief instead.
export default defineView({
universalIdentifier: APPLICATIONS_BY_OPPORTUNITY_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Applications by Opportunity',
icon: 'IconTargetArrow',
objectUniversalIdentifier: APPLICATION_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
position: 4,
fields: [
{
universalIdentifier: '1b6984aa-4011-4b77-8121-9f4d987a2e07',
fieldMetadataUniversalIdentifier: APPLICATION_OPPORTUNITY_FIELD_ID,
position: 0,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'f57b1dd2-f415-4c01-8f0a-d55b941b23c7',
fieldMetadataUniversalIdentifier: APPLICATION_PARTNER_FIELD_ID,
position: 1,
isVisible: true,
size: 200,
},
{
universalIdentifier: '612db8df-a809-4557-add4-1802a434efb2',
fieldMetadataUniversalIdentifier: APPLICATION_STATE_FIELD_ID,
position: 2,
isVisible: true,
size: 140,
},
{
universalIdentifier: 'ba53baa3-4db7-466e-a066-0d9156bc0a82',
fieldMetadataUniversalIdentifier: APPLICATION_PITCH_FIELD_ID,
position: 3,
isVisible: true,
size: 280,
},
],
sorts: [
{
universalIdentifier: 'ce31ed78-cd7a-410a-8d95-2e10c846d765',
fieldMetadataUniversalIdentifier: APPLICATION_OPPORTUNITY_FIELD_ID,
direction: ViewSortDirection.ASC,
},
],
});

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