diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json index 93c2f18d26..2b3936b4c5 100644 --- a/packages/twenty-apps/internal/twenty-partners/package.json +++ b/packages/twenty-apps/internal/twenty-partners/package.json @@ -1,6 +1,6 @@ { "name": "twenty-partners", - "version": "0.1.0", + "version": "0.3.1", "license": "MIT", "engines": { "node": "^24.5.0", @@ -16,8 +16,13 @@ "test": "vitest run", "test:watch": "vitest", "seed": "tsx src/scripts/seed.ts", + "seed:prod": "ENV_FILE=.env.prod tsx src/scripts/seed.ts", + "purge": "tsx src/scripts/purge-soft-deleted.ts", + "purge:prod": "ENV_FILE=.env.prod tsx src/scripts/purge-soft-deleted.ts", "import:dryrun": "tsx src/scripts/import-from-tft.ts", - "import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts" + "import:dryrun:prod": "ENV_FILE=.env.prod tsx src/scripts/import-from-tft.ts", + "import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts", + "import:apply:prod": "ENV_FILE=.env.prod IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts" }, "dependencies": { "twenty-client-sdk": "2.4.0", diff --git a/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts b/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts index a5c5d00384..83d4d700cd 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts @@ -3,7 +3,7 @@ export const APP_DESCRIPTION = ''; export const APPLICATION_UNIVERSAL_IDENTIFIER = 'e662fc1f-02c1-41ff-b8ba-c95a447b3965'; export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'ee18c3f3-ebe7-4c56-ad6d-aad555cc32db'; export const PARTNER_OBJECT_UNIVERSAL_IDENTIFIER = '39101b39-1c16-4148-9e82-45dc271bb90d'; -export const PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd'; +export const PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd'; export const ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '379b11d5-44d5-476b-ba7d-31d5f515c9b4'; export const ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER = '7a34da39-a8e1-44c7-88b4-91ceaa064920'; export const PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '3fe15ab5-e38b-4914-af17-2270b210aeb2'; @@ -35,7 +35,7 @@ export const ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER = '37944f52-cbe5-4814-a1 // Partner views + nav (harmonization) export const PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER = 'b57a84ed-d7c1-420d-b0eb-348db0dac612'; export const VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '13cca6a7-b9f1-4103-b011-ea2e39430899'; -export const PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b891-6201149510b3'; +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_QUOTES_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b'; +export const PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-content-customer-company.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-content-customer-company.field.ts new file mode 100644 index 0000000000..4ccc08f96e --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-content-customer-company.field.ts @@ -0,0 +1,22 @@ +import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +export const PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID = 'd448f6be-533f-4c43-a70f-55dc361dcdd7'; +export const PARTNER_CONTENTS_ON_COMPANY_FIELD_ID = '941e7707-1b3d-47d4-b13a-45c6628c1b3d'; + +export default defineField({ + universalIdentifier: PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID, + objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'customerCompany', + label: 'Customer Company', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_COMPANY_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'customerCompanyId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-content-customer-person.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-content-customer-person.field.ts new file mode 100644 index 0000000000..7570244bcb --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-content-customer-person.field.ts @@ -0,0 +1,22 @@ +import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +export const PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID = 'f17547be-76aa-4231-bd1a-3f57bb1ae323'; +export const PARTNER_CONTENTS_ON_PERSON_FIELD_ID = 'fae42f4e-b054-4267-a761-81a6792f7c12'; + +export default defineField({ + universalIdentifier: PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID, + objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'customerPerson', + label: 'Customer Person', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PERSON_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'customerPersonId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-content-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-content-partner.field.ts new file mode 100644 index 0000000000..dab923e131 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-content-partner.field.ts @@ -0,0 +1,22 @@ +import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define'; + +import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +export const PARTNER_CONTENT_PARTNER_FIELD_ID = '1774738e-96a1-43e2-aca4-d3c7cc794c50'; +export const PARTNER_CONTENTS_ON_PARTNER_FIELD_ID = 'ac7995f1-7ccf-4607-827b-0788eeacaee0'; + +export default defineField({ + universalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID, + objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'partner', + label: 'Partner', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'partnerId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-on-company.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-on-company.field.ts new file mode 100644 index 0000000000..4d8319e906 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-on-company.field.ts @@ -0,0 +1,18 @@ +import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { PARTNER_CONTENTS_ON_COMPANY_FIELD_ID, PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID } from './partner-content-customer-company.field'; + +export default defineField({ + universalIdentifier: PARTNER_CONTENTS_ON_COMPANY_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, + type: FieldType.RELATION, + name: 'partnerContents', + label: 'Partner Content', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-on-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-on-partner.field.ts new file mode 100644 index 0000000000..6f84ded039 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-on-partner.field.ts @@ -0,0 +1,18 @@ +import { FieldType, RelationType, defineField } from 'twenty-sdk/define'; + +import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { PARTNER_CONTENTS_ON_PARTNER_FIELD_ID, PARTNER_CONTENT_PARTNER_FIELD_ID } from './partner-content-partner.field'; + +export default defineField({ + universalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID, + objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'partnerContents', + label: 'Partner Content', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-on-person.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-on-person.field.ts new file mode 100644 index 0000000000..52c652cd9d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-on-person.field.ts @@ -0,0 +1,18 @@ +import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { PARTNER_CONTENTS_ON_PERSON_FIELD_ID, PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID } from './partner-content-customer-person.field'; + +export default defineField({ + universalIdentifier: PARTNER_CONTENTS_ON_PERSON_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + type: FieldType.RELATION, + name: 'partnerContents', + label: 'Partner Content', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-quote-opportunity.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-quote-opportunity.field.ts deleted file mode 100644 index ebb76ce893..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-quote-opportunity.field.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; - -import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; - -export const PARTNER_QUOTE_OPPORTUNITY_FIELD_ID = '36684f3e-f01a-4270-8f34-78966747dd64'; -export const PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID = '4857f339-a051-4cdb-bd8a-6219b933d1ce'; - -export default defineField({ - universalIdentifier: PARTNER_QUOTE_OPPORTUNITY_FIELD_ID, - objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER, - type: FieldType.RELATION, - name: 'opportunity', - label: 'Opportunity', - isNullable: true, - relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, - relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID, - universalSettings: { - relationType: RelationType.MANY_TO_ONE, - onDelete: OnDeleteAction.SET_NULL, - joinColumnName: 'opportunityId', - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-quote-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-quote-partner.field.ts deleted file mode 100644 index 5be6eaf4f8..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-quote-partner.field.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define'; - -import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; - -export const PARTNER_QUOTE_PARTNER_FIELD_ID = '1774738e-96a1-43e2-aca4-d3c7cc794c50'; -export const PARTNER_QUOTES_ON_PARTNER_FIELD_ID = 'ac7995f1-7ccf-4607-827b-0788eeacaee0'; - -export default defineField({ - universalIdentifier: PARTNER_QUOTE_PARTNER_FIELD_ID, - objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER, - type: FieldType.RELATION, - name: 'partner', - label: 'Partner', - isNullable: true, - relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, - relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTES_ON_PARTNER_FIELD_ID, - universalSettings: { - relationType: RelationType.MANY_TO_ONE, - onDelete: OnDeleteAction.SET_NULL, - joinColumnName: 'partnerId', - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-quotes-on-opportunity.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-quotes-on-opportunity.field.ts deleted file mode 100644 index 7fca6961cd..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-quotes-on-opportunity.field.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; - -import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID, PARTNER_QUOTE_OPPORTUNITY_FIELD_ID } from './partner-quote-opportunity.field'; - -export default defineField({ - universalIdentifier: PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID, - objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, - type: FieldType.RELATION, - name: 'partnerQuotes', - label: 'Partner Quotes', - isNullable: true, - relationTargetObjectMetadataUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER, - relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTE_OPPORTUNITY_FIELD_ID, - universalSettings: { - relationType: RelationType.ONE_TO_MANY, - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-quotes-on-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-quotes-on-partner.field.ts deleted file mode 100644 index afcf1f3386..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-quotes-on-partner.field.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { FieldType, RelationType, defineField } from 'twenty-sdk/define'; - -import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { PARTNER_QUOTES_ON_PARTNER_FIELD_ID, PARTNER_QUOTE_PARTNER_FIELD_ID } from './partner-quote-partner.field'; - -export default defineField({ - universalIdentifier: PARTNER_QUOTES_ON_PARTNER_FIELD_ID, - objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, - type: FieldType.RELATION, - name: 'partnerQuotes', - label: 'Partner Quotes', - isNullable: true, - relationTargetObjectMetadataUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER, - relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTE_PARTNER_FIELD_ID, - universalSettings: { - relationType: RelationType.ONE_TO_MANY, - }, -}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partner-quotes.navigation-menu-item.ts b/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partner-content.navigation-menu-item.ts similarity index 55% rename from packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partner-quotes.navigation-menu-item.ts rename to packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partner-content.navigation-menu-item.ts index 187ead55f6..f2f9705383 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partner-quotes.navigation-menu-item.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/navigation-menu-items/partner-content.navigation-menu-item.ts @@ -1,15 +1,15 @@ import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define'; import { - PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER, - PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER, + PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER, + PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER, } from 'src/constants/universal-identifiers'; export default defineNavigationMenuItem({ - universalIdentifier: PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER, + universalIdentifier: PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER, type: NavigationMenuItemType.VIEW, - icon: 'IconFileDollar', + icon: 'IconQuote', position: 3, folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d', - viewUniversalIdentifier: PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER, + viewUniversalIdentifier: PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER, }); diff --git a/packages/twenty-apps/internal/twenty-partners/src/objects/partner-quote.object.ts b/packages/twenty-apps/internal/twenty-partners/src/objects/partner-content.object.ts similarity index 54% rename from packages/twenty-apps/internal/twenty-partners/src/objects/partner-quote.object.ts rename to packages/twenty-apps/internal/twenty-partners/src/objects/partner-content.object.ts index 7fa789948f..d18f54051a 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/objects/partner-quote.object.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/objects/partner-content.object.ts @@ -1,15 +1,15 @@ import { FieldType, defineObject } from 'twenty-sdk/define'; -import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; export default defineObject({ - universalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER, - nameSingular: 'partnerQuote', - namePlural: 'partnerQuotes', - labelSingular: 'Partner Quote', - labelPlural: 'Partner Quotes', - description: 'A quote a partner submitted for a customer deal', - icon: 'IconFileDollar', + universalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER, + nameSingular: 'partnerContent', + namePlural: 'partnerContents', + labelSingular: 'Partner Content', + labelPlural: 'Partner Content', + description: 'Marketing content involving a partner: quotes, case studies, logos', + icon: 'IconQuote', isSearchable: true, labelIdentifierFieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6', fields: [ @@ -21,6 +21,20 @@ export default defineObject({ icon: 'IconTag', defaultValue: "''", }, + { + universalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692', + type: FieldType.MULTI_SELECT, + name: 'contentType', + label: 'Content Type', + icon: 'IconCategory', + isNullable: true, + options: [ + { id: '07f85e5b-0d70-416b-9884-256e469ed532', value: 'CUSTOMER_QUOTE', label: 'Customer quote', position: 0, color: 'blue' }, + { id: '108b2358-d04d-4fdc-83df-a5978d39f66f', value: 'CASE_STUDY', label: 'Case study', position: 1, color: 'green' }, + { id: 'eb45f371-f93c-4c45-9c8a-f29e1a58b7e4', value: 'PARTNER_QUOTE', label: 'Partner quote', position: 2, color: 'orange' }, + { id: '3356c8a0-41cd-47c3-a293-5862138abc1a', value: 'LOGO', label: 'Logo', position: 3, color: 'purple' }, + ], + }, { universalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', type: FieldType.SELECT, @@ -45,21 +59,21 @@ export default defineObject({ isNullable: true, }, { - universalIdentifier: '2cbe67e3-24ec-421b-bab5-50f3306c2391', - type: FieldType.CURRENCY, - name: 'amount', - label: 'Amount', - icon: 'IconCoin', + universalIdentifier: 'da7e9094-e2c3-47d3-924f-a1d4d3c717ed', + type: FieldType.LINKS, + name: 'interview', + label: 'Interview', + icon: 'IconMicrophone', isNullable: true, }, { universalIdentifier: 'f303369e-288c-4a48-9920-c1de0ad9a159', type: FieldType.FILES, - name: 'quoteFile', - label: 'Quote File', + name: 'documents', + label: 'Documents', icon: 'IconPaperclip', isNullable: true, - universalSettings: { maxNumberOfValues: 1 }, + universalSettings: { maxNumberOfValues: 10 }, }, ], }); diff --git a/packages/twenty-apps/internal/twenty-partners/src/scripts/import-from-tft.ts b/packages/twenty-apps/internal/twenty-partners/src/scripts/import-from-tft.ts index f60cc9c7ac..234e6baced 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/scripts/import-from-tft.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/scripts/import-from-tft.ts @@ -23,7 +23,7 @@ // tsx src/scripts/import-from-tft.ts import { config } from 'dotenv'; -config({ path: '.env.local' }); +config({ path: process.env.ENV_FILE ?? '.env.local' }); import { CoreApiClient } from 'twenty-client-sdk/core'; @@ -76,6 +76,17 @@ const PARTNER_STAGE_TO_VALIDATION: Record = { REJECTED: 'REJECTED', }; +// TFT person.partnerTimezone -> our Partner.region (MULTI_SELECT). TFT's value is a +// coarse timezone band, not a geography, so each maps to every region it plausibly +// covers. OTHER carries no signal -> no region. Region stays empty if unmapped. +const TIMEZONE_TO_REGION: Record = { + AMERICAS: ['US', 'LATAM'], + EMEA: ['EUROPE', 'MENA', 'AFRICA'], + WEST_ASIA: ['MENA', 'APAC'], + EAST_ASIA_OCEANIA: ['APAC'], + OTHER: [], +}; + // Local SELECT option sets, for preflight coverage checks (a TFT value not in // these would fail a real write). Keep in sync with src/objects + src/fields. const LOCAL_OPTIONS: Record> = { @@ -101,6 +112,30 @@ const edges = (result: any, key: string): any[] => const uniq = (values: (string | undefined | null)[]): string[] => [...new Set(values.filter((v): v is string => !!v))]; +// Normalize a domain for dedup. Twenty's company domain is a unique key but is +// stored with an https:// prefix, while TFT values vary, so compare on a canonical +// form (no protocol, no www, no trailing slash, lowercased). +const normDomain = (d?: string | null): string | undefined => + d + ? d.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '') || undefined + : undefined; + +// Distinct non-empty values across rows, for the preflight report. Flattens array +// fields (e.g. partnerScope, typeCustom) and stringifies, so scalar and +// multi-select fields share one path. Derived from the already-fetched source +// rows — no per-loop bookkeeping needed. +const distinct = (rows: TRow[], pick: (row: TRow) => unknown): string[] => + [ + ...new Set( + rows.flatMap((row) => { + const value = pick(row); + return Array.isArray(value) ? value : value != null ? [value] : []; + }), + ), + ] + .map(String) + .sort(); + async function main() { console.log(`[import] mode: ${APPLY ? 'APPLY (writing to local)' : 'DRY-RUN (no writes)'}`); const local = new CoreApiClient({ @@ -114,26 +149,10 @@ async function main() { if (APPLY && writes++ > 0) await new Promise((r) => setTimeout(r, 750)); }; - // Distinct TFT values seen, for the preflight coverage report. - const seen: Record> = { - partnerStage: new Set(), - partnerTier: new Set(), - partnerScope: new Set(), - typeOfTeam: new Set(), - oppStage: new Set(), - hostingType: new Set(), - subscriptionType: new Set(), - subscriptionFrequency: new Set(), - quoteStatus: new Set(), - typeCustom: new Set(), - }; - const note = (bucket: string, value?: string | null) => { - if (value) seen[bucket].add(value); - }; - // --------------------------------------------------------------------- // 1. Read all three TFT datasets (raw fetch; not rate-limited by local). // --------------------------------------------------------------------- + console.log('[import] fetching TFT people...'); const tftPeople = edges( await tftQuery(`query { people(first: 500, filter: { partnerStage: { in: ["APPLICATION","POTENTIAL_PARTNER","PARTNER","FORMER_PARTNER","REJECTED"] } }) { @@ -143,7 +162,7 @@ async function main() { emails { primaryEmail } city jobTitle linkedinLink { primaryLinkUrl } - partnerStage partnerTier partnerScope partnerTypeOfTeam partnerIsAvailable partnerSkills + partnerStage partnerTier partnerScope partnerTypeOfTeam partnerTimezone partnerIsAvailable partnerSkills partnerBudgetMinimum { amountMicros currencyCode } partnerBudgetAverage { amountMicros currencyCode } company { id name domainName { primaryLinkUrl } } @@ -152,7 +171,8 @@ async function main() { }`), 'people', ); - const tftOpps = edges( + console.log('[import] fetching TFT opportunities...'); + const tftOppsAll = edges( await tftQuery(`query { opportunities(first: 500) { edges { node { @@ -166,16 +186,32 @@ async function main() { }`), 'opportunities', ); + // Only import opportunities linked to a partner. The rest is TFT's general sales + // pipeline (mostly LOST/IDENTIFIED) — noise for a partners app. Every partner + // stage (INTRODUCED/WORKING) only ever appears on partner-linked opps anyway. + const tftOpps = tftOppsAll.filter((o: any) => o.partner?.id); + console.log(`[import] opportunities: ${tftOpps.length} partner-linked of ${tftOppsAll.length} total (skipping ${tftOppsAll.length - tftOpps.length} unlinked)`); + console.log('[import] fetching TFT partner content...'); const tftContent = edges( await tftQuery(`query { customerContents(first: 500) { - edges { node { id name status approvalDate typeCustom partnerPerson { id } } } + edges { node { + id name status approvalDate typeCustom + interview { primaryLinkUrl } + partnerPerson { id } + customerCompany { id name domainName { primaryLinkUrl } } + customerPerson { id } + } } } }`), 'customerContents', ); - const quotes = tftContent.filter((c: any) => Array.isArray(c.typeCustom) && c.typeCustom.includes('PARTNER_QUOTE')); - console.log(`[import] TFT: ${tftPeople.length} partner-people, ${tftOpps.length} opportunities, ${quotes.length} partner quotes`); + // Import all content TYPES (quotes, case studies, logos) but only records that + // involve a partner. Customer-only content (no partnerPerson) is noise for the + // partners app; a partner-linked case study/quote should show on the partner. + const contentRecords = tftContent.filter((c: any) => c.partnerPerson?.id); + console.log(`[import] TFT: ${tftPeople.length} partner-people, ${tftOpps.length} opportunities, ${tftContent.length} content records`); + console.log(`[import] partner content: ${contentRecords.length} partner-linked of ${tftContent.length} total (skipping ${tftContent.length - contentRecords.length} customer-only)`); const personSlug = (p: any): string => slugify([p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner') || p.id; @@ -183,6 +219,7 @@ async function main() { // --------------------------------------------------------------------- // 2. Batched existence lookups against local (one `in` query per object). // --------------------------------------------------------------------- + console.log('[import] checking existing records in target workspace...'); const partnerSlugs = uniq(tftPeople.map(personSlug)); const partnerIdBySlug = new Map( partnerSlugs.length @@ -195,17 +232,33 @@ async function main() { : [], ); - const companyNames = uniq([...tftPeople.map((p: any) => p.company?.name), ...tftOpps.map((o: any) => o.company?.name)]); - const companyIdByName = new Map( - companyNames.length - ? edges( - await local.query({ - companies: { __args: { filter: { name: { in: companyNames } }, first: 500 }, edges: { node: { id: true, name: true } } }, - } as any), - 'companies', - ).map((n: any) => [n.name, n.id]) - : [], - ); + // Fetch existing companies and index by BOTH name and normalized domain. Twenty + // enforces uniqueness on domain, so dedup must be domain-aware: the same company + // can arrive under different names (e.g. "Acme" vs "acme.com") across TFT people, + // opps and content, and creating a second one collides on the domain constraint. + // Page through ALL companies (not just the first 500): these dedupe maps must + // be complete, or upsertCompany would create domain-colliding duplicates for + // companies that live beyond the first page in a larger workspace. + const existingCompanies: any[] = []; + let companiesCursor: string | undefined; + for (;;) { + const page: any = await local.query({ + companies: { + __args: { filter: {}, first: 200, ...(companiesCursor ? { after: companiesCursor } : {}) }, + edges: { node: { id: true, name: true, domainName: { primaryLinkUrl: true } } }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + } as any); + existingCompanies.push(...edges(page, 'companies')); + if (!page?.companies?.pageInfo?.hasNextPage) break; + companiesCursor = page.companies.pageInfo.endCursor; + } + const companyIdByName = new Map(existingCompanies.map((n: any) => [n.name, n.id])); + const companyIdByDomain = new Map(); + for (const c of existingCompanies) { + const nd = normDomain(c.domainName?.primaryLinkUrl); + if (nd && !companyIdByDomain.has(nd)) companyIdByDomain.set(nd, c.id); + } const oppTftIds = uniq(tftOpps.filter((o: any) => o.name).map((o: any) => o.id)); const oppIdByTftId = new Map( @@ -219,14 +272,18 @@ async function main() { : [], ); - const quoteNames = uniq(quotes.map((q: any) => q.name || 'Partner quote')); - const quoteIdByName = new Map( - quoteNames.length + // Unnamed content upserts by name, so a constant fallback would make every + // unnamed record collide on one key (collapsing them on re-run). Key the + // fallback on the TFT id so each unnamed record stays distinct. + const contentName = (c: any): string => c.name || `Partner content ${c.id}`; + const contentNames = uniq(contentRecords.map(contentName)); + const contentIdByName = new Map( + contentNames.length ? edges( await local.query({ - partnerQuotes: { __args: { filter: { name: { in: quoteNames } }, first: 500 }, edges: { node: { id: true, name: true } } }, + partnerContents: { __args: { filter: { name: { in: contentNames } }, first: 500 }, edges: { node: { id: true, name: true } } }, } as any), - 'partnerQuotes', + 'partnerContents', ).map((n: any) => [n.name, n.id]) : [], ); @@ -238,43 +295,102 @@ async function main() { const upsertCompany = async (name?: string, domain?: string): Promise => { if (!name) return undefined; if (companyIdByName.has(name)) return companyIdByName.get(name); + const nd = normDomain(domain); + // Same company under a different name but same domain — reuse it. + if (nd && companyIdByDomain.has(nd)) { + const existingId = companyIdByDomain.get(nd) as string; + companyIdByName.set(name, existingId); + return existingId; + } let id = `dry:company:${name}`; if (APPLY) { await pace(); - const created: any = await local.mutation({ - createCompany: { - __args: { data: { name, ...(domain ? { domainName: { primaryLinkUrl: domain } } : {}) } }, - id: true, - }, - } as any); - id = created.createCompany.id; + try { + const created: any = await local.mutation({ + createCompany: { + __args: { data: { name, ...(domain ? { domainName: { primaryLinkUrl: domain } } : {}) } }, + id: true, + }, + } as any); + id = created.createCompany.id; + } catch (err) { + // Fallback: createCompany failed, almost certainly because the domain + // already exists (Twenty enforces a unique domain) on a company we + // didn't index. Re-find it and reuse instead of failing the import. + // `ilike` is a substring match — and the stored value carries an + // https:// prefix so we can't `eq` the normalized form — so it can + // return the wrong company ("acme.com" also matches "notacme.com" or + // "acme.com.br"). Confirm an exact normalized-domain match before reuse. + if (!nd) throw err; + await pace(); + const candidates = edges( + await local.query({ + companies: { __args: { filter: { domainName: { primaryLinkUrl: { ilike: `%${nd}%` } } }, first: 20 }, edges: { node: { id: true, domainName: { primaryLinkUrl: true } } } }, + } as any), + 'companies', + ); + const match = candidates.find((c: any) => normDomain(c.domainName?.primaryLinkUrl) === nd); + if (!match?.id) throw err; + id = match.id; + console.log(`[import] company "${name}" reused existing by domain ${nd}`); + } } companyIdByName.set(name, id); + if (nd) companyIdByDomain.set(nd, id); return id; }; const budgetCurrency = (amount: any) => amount?.amountMicros != null ? { amountMicros: amount.amountMicros, currencyCode: amount.currencyCode ?? 'USD' } : undefined; + // Generic create/update dispatch shared by partners, opportunities and content. + // Owns pacing + APPLY-gating + the create-vs-update branch, so each loop below + // only builds its `data`. genql keys a mutation by the object name, so the + // create/update names are derived from one argument. Companies + // keep their own upsert (above) because of the domain-collision fallback. + // Returns the row id: the real id on APPLY, a synthetic dry id otherwise so the + // relation mapping in dry-run still resolves to a stable placeholder. + const upsert = async ( + object: 'Partner' | 'Opportunity' | 'PartnerContent', + existingId: string | undefined, + data: Record, + dryKey: string, + ): Promise => { + if (!APPLY) return existingId ?? `dry:${object}:${dryKey}`; + await pace(); + if (existingId) { + await local.mutation({ [`update${object}`]: { __args: { id: existingId, data }, id: true } } as any); + return existingId; + } + const created: any = await local.mutation({ [`create${object}`]: { __args: { data }, id: true } } as any); + return created[`create${object}`].id; + }; + // -- Partners (upsert by slug) -- + console.log(`[import] upserting ${tftPeople.length} partners...`); const localPartnerIdByTftPersonId = new Map(); let partnersCreated = 0; let partnersUpdated = 0; + let partnersDone = 0; for (const p of tftPeople) { - note('partnerStage', p.partnerStage); - note('partnerTier', p.partnerTier); - (Array.isArray(p.partnerScope) ? p.partnerScope : []).forEach((s: string) => note('partnerScope', s)); - note('typeOfTeam', p.partnerTypeOfTeam); - const slug = personSlug(p); const companyId = await upsertCompany(p.company?.name, p.company?.domainName?.primaryLinkUrl); + // Timezone band -> geographic region(s). Unmapped/OTHER -> no region. + const region = TIMEZONE_TO_REGION[p.partnerTimezone] ?? []; + // A partner scoped for hosting is, by definition, a self-host expert. + const scope = Array.isArray(p.partnerScope) ? p.partnerScope : []; + const deploymentExpertise = scope.includes('HOSTING_ENVIRONMENT') ? ['SELF_HOST'] : []; const data: Record = { name: [p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner', slug, validationStage: PARTNER_STAGE_TO_VALIDATION[p.partnerStage] ?? 'APPLICATION', availability: p.partnerIsAvailable ? 'AVAILABLE' : 'UNAVAILABLE', + // TFT has no language data; default everyone to English. + languagesSpoken: ['ENGLISH'], ...(p.partnerTier ? { partnerTier: p.partnerTier } : {}), - ...(Array.isArray(p.partnerScope) && p.partnerScope.length ? { partnerScope: p.partnerScope } : {}), + ...(scope.length ? { partnerScope: scope } : {}), + ...(region.length ? { region } : {}), + ...(deploymentExpertise.length ? { deploymentExpertise } : {}), ...(p.partnerTypeOfTeam ? { typeOfTeam: p.partnerTypeOfTeam } : {}), ...(Array.isArray(p.partnerSkills) && p.partnerSkills.length ? { skills: p.partnerSkills } : {}), ...(p.city ? { city: p.city } : {}), @@ -284,39 +400,29 @@ async function main() { ...(companyId && APPLY ? { companyId } : {}), }; - let partnerId = partnerIdBySlug.get(slug); - if (partnerId) { - if (APPLY) { - await pace(); - await local.mutation({ updatePartner: { __args: { id: partnerId, data }, id: true } } as any); - } + const existingId = partnerIdBySlug.get(slug); + const partnerId = await upsert('Partner', existingId, data, slug); + if (existingId) { partnersUpdated++; } else { - if (APPLY) { - await pace(); - const created: any = await local.mutation({ createPartner: { __args: { data }, id: true } } as any); - partnerId = created.createPartner.id; - } else { - partnerId = `dry:partner:${slug}`; - } - partnerIdBySlug.set(slug, partnerId as string); + partnerIdBySlug.set(slug, partnerId); partnersCreated++; } - localPartnerIdByTftPersonId.set(p.id, partnerId as string); + localPartnerIdByTftPersonId.set(p.id, partnerId); + partnersDone++; + if (partnersDone % 10 === 0 || partnersDone === tftPeople.length) + console.log(`[import] partners ${partnersDone}/${tftPeople.length} (created=${partnersCreated} updated=${partnersUpdated})`); } - console.log(`[import] partners created=${partnersCreated} updated=${partnersUpdated}`); + console.log(`[import] partners done: created=${partnersCreated} updated=${partnersUpdated}`); // -- Opportunities (upsert by tftOpportunityId) -- + console.log(`[import] upserting ${tftOpps.length} opportunities...`); let oppsCreated = 0; let oppsUpdated = 0; let oppsPartnerLinked = 0; + let oppsDone = 0; for (const o of tftOpps) { if (!o.name) continue; - note('oppStage', o.stage); - note('hostingType', o.hostingType); - note('subscriptionType', o.subscriptionType); - note('subscriptionFrequency', o.subscriptionFrequence); - const companyId = await upsertCompany(o.company?.name, o.company?.domainName?.primaryLinkUrl); const partnerId = o.partner?.id ? localPartnerIdByTftPersonId.get(o.partner.id) : undefined; if (partnerId) oppsPartnerLinked++; @@ -338,58 +444,44 @@ async function main() { }; const existingId = oppIdByTftId.get(o.id); - if (existingId) { - if (APPLY) { - await pace(); - await local.mutation({ updateOpportunity: { __args: { id: existingId, data }, id: true } } as any); - } - oppsUpdated++; - } else { - if (APPLY) { - await pace(); - await local.mutation({ createOpportunity: { __args: { data }, id: true } } as any); - } - oppsCreated++; - } + await upsert('Opportunity', existingId, data, o.id); + if (existingId) oppsUpdated++; + else oppsCreated++; + oppsDone++; + if (oppsDone % 20 === 0 || oppsDone === tftOpps.length) + console.log(`[import] opportunities ${oppsDone}/${tftOpps.length} (created=${oppsCreated} updated=${oppsUpdated})`); } - console.log(`[import] opportunities created=${oppsCreated} updated=${oppsUpdated} (partner-linked=${oppsPartnerLinked})`); + console.log(`[import] opportunities done: created=${oppsCreated} updated=${oppsUpdated} (partner-linked=${oppsPartnerLinked})`); - // -- Partner quotes (upsert by name) -- - let quotesCreated = 0; - let quotesUpdated = 0; - tftContent.forEach((c: any) => (Array.isArray(c.typeCustom) ? c.typeCustom : []).forEach((t: string) => note('typeCustom', t))); - for (const q of quotes) { - note('quoteStatus', q.status); - const name = q.name || 'Partner quote'; - const partnerId = q.partnerPerson?.id ? localPartnerIdByTftPersonId.get(q.partnerPerson.id) : undefined; + // -- Partner content (upsert by name) -- + console.log(`[import] upserting ${contentRecords.length} content records...`); + let contentCreated = 0; + let contentUpdated = 0; + for (const c of contentRecords) { + const name = contentName(c); + const partnerId = c.partnerPerson?.id ? localPartnerIdByTftPersonId.get(c.partnerPerson.id) : undefined; + const customerCompanyId = await upsertCompany(c.customerCompany?.name, c.customerCompany?.domainName?.primaryLinkUrl); const data: Record = { name, - ...(q.status ? { status: q.status } : {}), - ...(q.approvalDate ? { approvalDate: q.approvalDate } : {}), + ...(Array.isArray(c.typeCustom) && c.typeCustom.length ? { contentType: c.typeCustom } : {}), + ...(c.status ? { status: c.status } : {}), + ...(c.approvalDate ? { approvalDate: c.approvalDate } : {}), + ...(c.interview?.primaryLinkUrl ? { interview: { primaryLinkUrl: c.interview.primaryLinkUrl } } : {}), ...(partnerId && APPLY ? { partnerId } : {}), + ...(customerCompanyId && APPLY ? { customerCompanyId } : {}), }; - const existingId = quoteIdByName.get(name); - if (existingId) { - if (APPLY) { - await pace(); - await local.mutation({ updatePartnerQuote: { __args: { id: existingId, data }, id: true } } as any); - } - quotesUpdated++; - } else { - if (APPLY) { - await pace(); - await local.mutation({ createPartnerQuote: { __args: { data }, id: true } } as any); - } - quotesCreated++; - } + const existingId = contentIdByName.get(name); + await upsert('PartnerContent', existingId, data, name); + if (existingId) contentUpdated++; + else contentCreated++; } - console.log(`[import] partner quotes created=${quotesCreated} updated=${quotesUpdated}`); + console.log(`[import] partner content created=${contentCreated} updated=${contentUpdated}`); // --------------------------------------------------------------------- - // 4. Preflight: distinct TFT values vs local option coverage. + // 4. Preflight: distinct TFT values vs local option coverage. Derived + // directly from the fetched source rows (no per-loop bookkeeping). // --------------------------------------------------------------------- - const report = (label: string, bucket: string, optionKey?: string) => { - const values = [...seen[bucket]].sort(); + const report = (label: string, values: string[], optionKey?: string) => { const allowed = optionKey ? LOCAL_OPTIONS[optionKey] : undefined; const uncovered = allowed ? values.filter((v) => !allowed.has(v)) : []; console.log( @@ -397,21 +489,27 @@ async function main() { (uncovered.length ? ` ⚠️ NOT IN LOCAL OPTIONS: ${uncovered.join(', ')}` : ''), ); }; + const partnerStages = distinct(tftPeople, (p: any) => p.partnerStage); + const oppStages = distinct(tftOpps, (o: any) => o.stage); + const timezones = distinct(tftPeople, (p: any) => p.partnerTimezone); console.log('--- preflight: distinct TFT values seen ---'); - report('partnerStage (-> validationStage map)', 'partnerStage'); - report('partnerTier', 'partnerTier', 'partnerTier'); - report('partnerScope', 'partnerScope', 'partnerScope'); - report('typeOfTeam', 'typeOfTeam', 'typeOfTeam'); - report('opp stage (-> matchStatus map)', 'oppStage'); - report('hostingType', 'hostingType', 'hostingType'); - report('subscriptionType', 'subscriptionType', 'subscriptionType'); - report('subscriptionFrequency', 'subscriptionFrequency', 'subscriptionFrequency'); - report('quote status', 'quoteStatus', 'quoteStatus'); - report('customerContent typeCustom', 'typeCustom'); - const unmappedStages = [...seen.partnerStage].filter((s) => !(s in PARTNER_STAGE_TO_VALIDATION)); - const unmappedOpps = [...seen.oppStage].filter((s) => !(s in STAGE_TO_MATCH_STATUS)); + report('partnerStage (-> validationStage map)', partnerStages); + report('partnerTier', distinct(tftPeople, (p: any) => p.partnerTier), 'partnerTier'); + report('partnerScope', distinct(tftPeople, (p: any) => p.partnerScope), 'partnerScope'); + report('typeOfTeam', distinct(tftPeople, (p: any) => p.partnerTypeOfTeam), 'typeOfTeam'); + report('partnerTimezone (-> region map)', timezones); + report('opp stage (-> matchStatus map)', oppStages); + report('hostingType', distinct(tftOpps, (o: any) => o.hostingType), 'hostingType'); + report('subscriptionType', distinct(tftOpps, (o: any) => o.subscriptionType), 'subscriptionType'); + report('subscriptionFrequency', distinct(tftOpps, (o: any) => o.subscriptionFrequence), 'subscriptionFrequency'); + report('quote status', distinct(contentRecords, (c: any) => c.status), 'quoteStatus'); + report('customerContent typeCustom', distinct(contentRecords, (c: any) => c.typeCustom)); + const unmappedStages = partnerStages.filter((s) => !(s in PARTNER_STAGE_TO_VALIDATION)); + const unmappedOpps = oppStages.filter((s) => !(s in STAGE_TO_MATCH_STATUS)); + const unmappedTz = timezones.filter((t) => !(t in TIMEZONE_TO_REGION)); if (unmappedStages.length) console.log(`[preflight] ⚠️ partnerStage not mapped: ${unmappedStages.join(', ')}`); if (unmappedOpps.length) console.log(`[preflight] ⚠️ opp stage not mapped: ${unmappedOpps.join(', ')}`); + if (unmappedTz.length) console.log(`[preflight] ⚠️ partnerTimezone not mapped: ${unmappedTz.join(', ')}`); } main().catch((err) => { diff --git a/packages/twenty-apps/internal/twenty-partners/src/scripts/purge-soft-deleted.ts b/packages/twenty-apps/internal/twenty-partners/src/scripts/purge-soft-deleted.ts new file mode 100644 index 0000000000..668e2a4389 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/scripts/purge-soft-deleted.ts @@ -0,0 +1,59 @@ +// Hard-destroy soft-deleted records that block re-imports. +// +// Twenty SOFT-deletes (sets deletedAt); the row stays in the DB and keeps holding +// unique constraints (e.g. company domain, partner slug). But normal queries — +// including the import's existence checks — exclude soft-deleted rows. So after a +// UI "delete" or a partial import that got rolled back, re-running the import hits +// "A duplicate entry was detected" on records it cannot see. This purges those +// ghosts permanently so idempotent upserts work again. +// +// Only touches soft-deleted rows (deletedAt IS NOT NULL); active/default data is +// left untouched. One bulk destroy per object, so it is not rate-limited. +// +// yarn purge # against .env.local +// yarn purge:prod # against .env.prod +// +import { config } from 'dotenv'; +config({ path: process.env.ENV_FILE ?? '.env.local' }); + +const requireEnv = (name: string): string => { + const value = process.env[name]; + if (!value) throw new Error(`Missing ${name} env var`); + return value; +}; + +// Objects the import writes to. partners + partnerContents are app custom objects; +// companies + opportunities are standard but populated by the import. +const OBJECTS = ['companies', 'partners', 'opportunities', 'partnerContents'] as const; + +const gql = async (url: string, key: string, query: string): Promise => { + const response = await fetch(`${url.replace(/\/$/, '')}/graphql`, { + method: 'POST', + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + }); + const json: any = await response.json(); + if (json.errors?.length) throw new Error(JSON.stringify(json.errors)); + return json.data; +}; + +async function main() { + const url = requireEnv('TWENTY_PARTNERS_API_URL'); + const key = requireEnv('TWENTY_PARTNERS_API_KEY'); + console.log(`[purge] target: ${url} — destroying soft-deleted rows only`); + + for (const obj of OBJECTS) { + // destroys is the bulk variant; capitalise + already-plural names. + const mutationName = `destroy${obj.charAt(0).toUpperCase()}${obj.slice(1)}`; + const data = await gql(url, key, `mutation { ${mutationName}(filter: { deletedAt: { is: NOT_NULL } }) { id } }`); + const destroyed = data[mutationName]?.length ?? 0; + console.log(`[purge] ${obj}: destroyed ${destroyed} soft-deleted`); + } + + console.log('[purge] done'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/scripts/seed.ts b/packages/twenty-apps/internal/twenty-partners/src/scripts/seed.ts index 524b984abb..5b4bd21ea4 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/scripts/seed.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/scripts/seed.ts @@ -11,7 +11,7 @@ // tsx src/scripts/seed.ts import { config } from 'dotenv'; -config({ path: '.env.local' }); +config({ path: process.env.ENV_FILE ?? '.env.local' }); import { CoreApiClient } from 'twenty-client-sdk/core'; @@ -89,13 +89,13 @@ const OPPORTUNITIES: Opp[] = [ { name: 'Sunrise — vendor onboarding', companyName: 'Sunrise Logistics', matchStatus: 'LOST', numberOfSeats: 10, hostingType: 'CLOUD', subscriptionType: 'PRO', subscriptionFrequency: 'MONTHLY' }, ]; -type Quote = { name: string; status: string; partnerSlug: string; oppName: string }; +type Quote = { name: string; status: string; partnerSlug: string; contentType: string[] }; const QUOTES: Quote[] = [ - { name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — APAC fleet CRM' }, - { name: 'Helix clinical — NetZero quote', status: 'INTERVIEW_SCHEDULED', partnerSlug: 'netzero-systems', oppName: 'Helix Bio — clinical trials CRM' }, - { name: 'Acme rollout — Elevate quote', status: 'UNDER_CUSTOMER_PARTNER_REVIEW', partnerSlug: 'elevate-consulting', oppName: 'Acme RE — CRM rollout' }, - { name: 'Sunrise LATAM — Nine Dots quote', status: 'APPROVED', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — LATAM expansion' }, - { name: 'Helix self-host — Meridian quote', status: 'REJECTED', partnerSlug: 'meridian-craft', oppName: 'Helix Bio — self-host evaluation' }, + { name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', contentType: ['PARTNER_QUOTE'] }, + { name: 'Helix clinical — NetZero quote', status: 'INTERVIEW_SCHEDULED', partnerSlug: 'netzero-systems', contentType: ['PARTNER_QUOTE'] }, + { name: 'Acme rollout — Elevate quote', status: 'UNDER_CUSTOMER_PARTNER_REVIEW', partnerSlug: 'elevate-consulting', contentType: ['PARTNER_QUOTE'] }, + { name: 'Sunrise LATAM — Nine Dots quote', status: 'APPROVED', partnerSlug: 'nine-dots-ventures', contentType: ['PARTNER_QUOTE'] }, + { name: 'Helix self-host — Meridian quote', status: 'REJECTED', partnerSlug: 'meridian-craft', contentType: ['CASE_STUDY'] }, ]; const nodes = (r: any, key: string): any[] => (r?.[key]?.edges ?? []).map((e: any) => e.node); @@ -177,12 +177,12 @@ async function main() { // -- Partner quotes (upsert by name) -- let quoteCount = 0; for (const q of QUOTES) { - const data = { name: q.name, status: q.status, partnerId: partnerIdBySlug.get(q.partnerSlug), opportunityId: oppIdByName.get(q.oppName) }; - const existing = nodes(await client.query({ partnerQuotes: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerQuotes'); + const data = { name: q.name, status: q.status, contentType: q.contentType, partnerId: partnerIdBySlug.get(q.partnerSlug) }; + const existing = nodes(await client.query({ partnerContents: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerContents'); if (existing[0]?.id) { - await client.mutation({ updatePartnerQuote: { __args: { id: existing[0].id, data }, id: true } } as any); + await client.mutation({ updatePartnerContent: { __args: { id: existing[0].id, data }, id: true } } as any); } else { - await client.mutation({ createPartnerQuote: { __args: { data }, id: true } } as any); + await client.mutation({ createPartnerContent: { __args: { data }, id: true } } as any); } quoteCount++; } diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/partner-quotes.view.ts b/packages/twenty-apps/internal/twenty-partners/src/views/partner-content.view.ts similarity index 55% rename from packages/twenty-apps/internal/twenty-partners/src/views/partner-quotes.view.ts rename to packages/twenty-apps/internal/twenty-partners/src/views/partner-content.view.ts index 268946b93b..05f787137f 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/partner-quotes.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/views/partner-content.view.ts @@ -1,21 +1,21 @@ import { ViewType, defineView } from 'twenty-sdk/define'; import { - PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER, - PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER, + PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER, + PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER, } from 'src/constants/universal-identifiers'; -// Index view for partner quotes. +// Index view for partner content. export default defineView({ - universalIdentifier: PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER, - name: 'Partner quotes', - icon: 'IconFileDollar', - objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER, + universalIdentifier: PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER, + name: 'Partner content', + icon: 'IconQuote', + objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER, type: ViewType.TABLE, fields: [ { universalIdentifier: 'ad7b3702-b552-4355-afec-1e1e96d9f3df', fieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6', position: 0, isVisible: true }, - { universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 1, isVisible: true }, - { universalIdentifier: '62bb1536-539c-4fb0-95bf-440c5c5da89f', fieldMetadataUniversalIdentifier: '2cbe67e3-24ec-421b-bab5-50f3306c2391', position: 2, isVisible: true }, + { universalIdentifier: 'a9bf3eaa-ec27-4a0a-8df2-e18c8f4239a7', fieldMetadataUniversalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692', position: 1, isVisible: true }, + { universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 2, isVisible: true }, { universalIdentifier: 'fbd1f953-1dd2-4d0f-a239-148a0688fbff', fieldMetadataUniversalIdentifier: 'b52d263e-423e-40b0-b82c-29214597c005', position: 3, isVisible: true }, ], });