Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6b5bf8bd6 | ||
|
|
34db7ac8b4 | ||
|
|
4a82cddad6 | ||
|
|
9d6c5b7d58 | ||
|
|
a2db0b6932 | ||
|
|
5eb79e7797 | ||
|
|
f19647617a | ||
|
|
424c6737a1 | ||
|
|
be8c25dfc2 | ||
|
|
b8de469f37 | ||
|
|
c74337978b | ||
|
|
059e75e532 | ||
|
|
bb5c2bd00c | ||
|
|
a2673da164 | ||
|
|
08682fb3f5 | ||
|
|
53d4e92dda | ||
|
|
53392f9a16 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.8.0",
|
||||
"version": "2.9.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+3
-3
@@ -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';
|
||||
|
||||
+22
@@ -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',
|
||||
},
|
||||
});
|
||||
+22
@@ -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',
|
||||
},
|
||||
});
|
||||
+22
@@ -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',
|
||||
},
|
||||
});
|
||||
+18
@@ -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,
|
||||
},
|
||||
});
|
||||
+18
@@ -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,
|
||||
},
|
||||
});
|
||||
+18
@@ -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,
|
||||
},
|
||||
});
|
||||
-22
@@ -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',
|
||||
},
|
||||
});
|
||||
-22
@@ -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',
|
||||
},
|
||||
});
|
||||
-18
@@ -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,
|
||||
},
|
||||
});
|
||||
-18
@@ -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,
|
||||
},
|
||||
});
|
||||
+5
-5
@@ -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,
|
||||
});
|
||||
+30
-16
@@ -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 },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
REJECTED: 'REJECTED',
|
||||
};
|
||||
|
||||
// TFT person.partnerTimezone -> our Partner.region (MULTI_SELECT). TFT's value is a
|
||||
// coarse timezone band, not a geography, so each maps to every region it plausibly
|
||||
// covers. OTHER carries no signal -> no region. Region stays empty if unmapped.
|
||||
const TIMEZONE_TO_REGION: Record<string, string[]> = {
|
||||
AMERICAS: ['US', 'LATAM'],
|
||||
EMEA: ['EUROPE', 'MENA', 'AFRICA'],
|
||||
WEST_ASIA: ['MENA', 'APAC'],
|
||||
EAST_ASIA_OCEANIA: ['APAC'],
|
||||
OTHER: [],
|
||||
};
|
||||
|
||||
// Local SELECT option sets, for preflight coverage checks (a TFT value not in
|
||||
// these would fail a real write). Keep in sync with src/objects + src/fields.
|
||||
const LOCAL_OPTIONS: Record<string, Set<string>> = {
|
||||
@@ -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 = <TRow>(rows: TRow[], pick: (row: TRow) => unknown): string[] =>
|
||||
[
|
||||
...new Set(
|
||||
rows.flatMap((row) => {
|
||||
const value = pick(row);
|
||||
return Array.isArray(value) ? value : value != null ? [value] : [];
|
||||
}),
|
||||
),
|
||||
]
|
||||
.map(String)
|
||||
.sort();
|
||||
|
||||
async function main() {
|
||||
console.log(`[import] mode: ${APPLY ? 'APPLY (writing to local)' : 'DRY-RUN (no writes)'}`);
|
||||
const local = new CoreApiClient({
|
||||
@@ -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<string, Set<string>> = {
|
||||
partnerStage: new Set(),
|
||||
partnerTier: new Set(),
|
||||
partnerScope: new Set(),
|
||||
typeOfTeam: new Set(),
|
||||
oppStage: new Set(),
|
||||
hostingType: new Set(),
|
||||
subscriptionType: new Set(),
|
||||
subscriptionFrequency: new Set(),
|
||||
quoteStatus: new Set(),
|
||||
typeCustom: new Set(),
|
||||
};
|
||||
const note = (bucket: string, value?: string | null) => {
|
||||
if (value) seen[bucket].add(value);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 1. Read all three TFT datasets (raw fetch; not rate-limited by local).
|
||||
// ---------------------------------------------------------------------
|
||||
console.log('[import] fetching TFT people...');
|
||||
const tftPeople = edges(
|
||||
await tftQuery(`query {
|
||||
people(first: 500, filter: { partnerStage: { in: ["APPLICATION","POTENTIAL_PARTNER","PARTNER","FORMER_PARTNER","REJECTED"] } }) {
|
||||
@@ -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<string, string>(
|
||||
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<string, string>(
|
||||
companyNames.length
|
||||
? edges(
|
||||
await local.query({
|
||||
companies: { __args: { filter: { name: { in: companyNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
|
||||
} as any),
|
||||
'companies',
|
||||
).map((n: any) => [n.name, n.id])
|
||||
: [],
|
||||
);
|
||||
// Fetch existing companies and index by BOTH name and normalized domain. Twenty
|
||||
// enforces uniqueness on domain, so dedup must be domain-aware: the same company
|
||||
// can arrive under different names (e.g. "Acme" vs "acme.com") across TFT people,
|
||||
// opps and content, and creating a second one collides on the domain constraint.
|
||||
// Page through ALL companies (not just the first 500): these dedupe maps must
|
||||
// be complete, or upsertCompany would create domain-colliding duplicates for
|
||||
// companies that live beyond the first page in a larger workspace.
|
||||
const existingCompanies: any[] = [];
|
||||
let companiesCursor: string | undefined;
|
||||
for (;;) {
|
||||
const page: any = await local.query({
|
||||
companies: {
|
||||
__args: { filter: {}, first: 200, ...(companiesCursor ? { after: companiesCursor } : {}) },
|
||||
edges: { node: { id: true, name: true, domainName: { primaryLinkUrl: true } } },
|
||||
pageInfo: { hasNextPage: true, endCursor: true },
|
||||
},
|
||||
} as any);
|
||||
existingCompanies.push(...edges(page, 'companies'));
|
||||
if (!page?.companies?.pageInfo?.hasNextPage) break;
|
||||
companiesCursor = page.companies.pageInfo.endCursor;
|
||||
}
|
||||
const companyIdByName = new Map<string, string>(existingCompanies.map((n: any) => [n.name, n.id]));
|
||||
const companyIdByDomain = new Map<string, string>();
|
||||
for (const c of existingCompanies) {
|
||||
const nd = normDomain(c.domainName?.primaryLinkUrl);
|
||||
if (nd && !companyIdByDomain.has(nd)) companyIdByDomain.set(nd, c.id);
|
||||
}
|
||||
|
||||
const oppTftIds = uniq(tftOpps.filter((o: any) => o.name).map((o: any) => o.id));
|
||||
const oppIdByTftId = new Map<string, string>(
|
||||
@@ -219,14 +272,18 @@ async function main() {
|
||||
: [],
|
||||
);
|
||||
|
||||
const quoteNames = uniq(quotes.map((q: any) => q.name || 'Partner quote'));
|
||||
const quoteIdByName = new Map<string, string>(
|
||||
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<string, string>(
|
||||
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<string | undefined> => {
|
||||
if (!name) return undefined;
|
||||
if (companyIdByName.has(name)) return companyIdByName.get(name);
|
||||
const nd = normDomain(domain);
|
||||
// Same company under a different name but same domain — reuse it.
|
||||
if (nd && companyIdByDomain.has(nd)) {
|
||||
const existingId = companyIdByDomain.get(nd) as string;
|
||||
companyIdByName.set(name, existingId);
|
||||
return existingId;
|
||||
}
|
||||
let id = `dry:company:${name}`;
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
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<Object>/update<Object> names are derived from one argument. Companies
|
||||
// keep their own upsert (above) because of the domain-collision fallback.
|
||||
// Returns the row id: the real id on APPLY, a synthetic dry id otherwise so the
|
||||
// relation mapping in dry-run still resolves to a stable placeholder.
|
||||
const upsert = async (
|
||||
object: 'Partner' | 'Opportunity' | 'PartnerContent',
|
||||
existingId: string | undefined,
|
||||
data: Record<string, unknown>,
|
||||
dryKey: string,
|
||||
): Promise<string> => {
|
||||
if (!APPLY) return existingId ?? `dry:${object}:${dryKey}`;
|
||||
await pace();
|
||||
if (existingId) {
|
||||
await local.mutation({ [`update${object}`]: { __args: { id: existingId, data }, id: true } } as any);
|
||||
return existingId;
|
||||
}
|
||||
const created: any = await local.mutation({ [`create${object}`]: { __args: { data }, id: true } } as any);
|
||||
return created[`create${object}`].id;
|
||||
};
|
||||
|
||||
// -- Partners (upsert by slug) --
|
||||
console.log(`[import] upserting ${tftPeople.length} partners...`);
|
||||
const localPartnerIdByTftPersonId = new Map<string, string>();
|
||||
let partnersCreated = 0;
|
||||
let partnersUpdated = 0;
|
||||
let partnersDone = 0;
|
||||
for (const p of tftPeople) {
|
||||
note('partnerStage', p.partnerStage);
|
||||
note('partnerTier', p.partnerTier);
|
||||
(Array.isArray(p.partnerScope) ? p.partnerScope : []).forEach((s: string) => note('partnerScope', s));
|
||||
note('typeOfTeam', p.partnerTypeOfTeam);
|
||||
|
||||
const slug = personSlug(p);
|
||||
const companyId = await upsertCompany(p.company?.name, p.company?.domainName?.primaryLinkUrl);
|
||||
// Timezone band -> geographic region(s). Unmapped/OTHER -> no region.
|
||||
const region = TIMEZONE_TO_REGION[p.partnerTimezone] ?? [];
|
||||
// A partner scoped for hosting is, by definition, a self-host expert.
|
||||
const scope = Array.isArray(p.partnerScope) ? p.partnerScope : [];
|
||||
const deploymentExpertise = scope.includes('HOSTING_ENVIRONMENT') ? ['SELF_HOST'] : [];
|
||||
const data: Record<string, unknown> = {
|
||||
name: [p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner',
|
||||
slug,
|
||||
validationStage: PARTNER_STAGE_TO_VALIDATION[p.partnerStage] ?? 'APPLICATION',
|
||||
availability: p.partnerIsAvailable ? 'AVAILABLE' : 'UNAVAILABLE',
|
||||
// TFT has no language data; default everyone to English.
|
||||
languagesSpoken: ['ENGLISH'],
|
||||
...(p.partnerTier ? { partnerTier: p.partnerTier } : {}),
|
||||
...(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<string, unknown> = {
|
||||
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) => {
|
||||
|
||||
@@ -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<any> => {
|
||||
const response = await fetch(`${url.replace(/\/$/, '')}/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
const json: any = await response.json();
|
||||
if (json.errors?.length) throw new Error(JSON.stringify(json.errors));
|
||||
return json.data;
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const url = requireEnv('TWENTY_PARTNERS_API_URL');
|
||||
const key = requireEnv('TWENTY_PARTNERS_API_KEY');
|
||||
console.log(`[purge] target: ${url} — destroying soft-deleted rows only`);
|
||||
|
||||
for (const obj of OBJECTS) {
|
||||
// destroy<Object>s is the bulk variant; capitalise + already-plural names.
|
||||
const mutationName = `destroy${obj.charAt(0).toUpperCase()}${obj.slice(1)}`;
|
||||
const data = await gql(url, key, `mutation { ${mutationName}(filter: { deletedAt: { is: NOT_NULL } }) { id } }`);
|
||||
const destroyed = data[mutationName]?.length ?? 0;
|
||||
console.log(`[purge] ${obj}: destroyed ${destroyed} soft-deleted`);
|
||||
}
|
||||
|
||||
console.log('[purge] done');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
// tsx src/scripts/seed.ts
|
||||
|
||||
import { config } from 'dotenv';
|
||||
config({ path: '.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++;
|
||||
}
|
||||
|
||||
+9
-9
@@ -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 },
|
||||
],
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-client-sdk",
|
||||
"version": "2.8.0",
|
||||
"version": "2.9.0",
|
||||
"sideEffects": false,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -48,7 +48,7 @@ type ApplicationRegistration {
|
||||
isListed: Boolean!
|
||||
isFeatured: Boolean!
|
||||
isPreInstalled: Boolean!
|
||||
logo: String
|
||||
logoUrl: String
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
isConfigured: Boolean!
|
||||
@@ -287,7 +287,7 @@ type ApplicationRegistrationSummary {
|
||||
id: UUID!
|
||||
latestAvailableVersion: String
|
||||
sourceType: ApplicationRegistrationSourceType!
|
||||
logo: String
|
||||
logoUrl: String
|
||||
}
|
||||
|
||||
type ApplicationVariable {
|
||||
@@ -1972,7 +1972,7 @@ type CreateApplicationRegistration {
|
||||
type PublicApplicationRegistration {
|
||||
id: UUID!
|
||||
name: String!
|
||||
logo: String
|
||||
logoUrl: String
|
||||
websiteUrl: String
|
||||
oAuthScopes: [String!]!
|
||||
}
|
||||
@@ -2358,7 +2358,6 @@ type MarketplaceAppDetail {
|
||||
sourceType: ApplicationRegistrationSourceType!
|
||||
sourcePackage: String
|
||||
latestAvailableVersion: String
|
||||
logo: String
|
||||
isListed: Boolean!
|
||||
isFeatured: Boolean!
|
||||
manifest: JSON
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface ApplicationRegistration {
|
||||
isListed: Scalars['Boolean']
|
||||
isFeatured: Scalars['Boolean']
|
||||
isPreInstalled: Scalars['Boolean']
|
||||
logo?: Scalars['String']
|
||||
logoUrl?: Scalars['String']
|
||||
createdAt: Scalars['DateTime']
|
||||
updatedAt: Scalars['DateTime']
|
||||
isConfigured: Scalars['Boolean']
|
||||
@@ -236,7 +236,7 @@ export interface ApplicationRegistrationSummary {
|
||||
id: Scalars['UUID']
|
||||
latestAvailableVersion?: Scalars['String']
|
||||
sourceType: ApplicationRegistrationSourceType
|
||||
logo?: Scalars['String']
|
||||
logoUrl?: Scalars['String']
|
||||
__typename: 'ApplicationRegistrationSummary'
|
||||
}
|
||||
|
||||
@@ -1612,7 +1612,7 @@ export interface CreateApplicationRegistration {
|
||||
export interface PublicApplicationRegistration {
|
||||
id: Scalars['UUID']
|
||||
name: Scalars['String']
|
||||
logo?: Scalars['String']
|
||||
logoUrl?: Scalars['String']
|
||||
websiteUrl?: Scalars['String']
|
||||
oAuthScopes: Scalars['String'][]
|
||||
__typename: 'PublicApplicationRegistration'
|
||||
@@ -2035,7 +2035,6 @@ export interface MarketplaceAppDetail {
|
||||
sourceType: ApplicationRegistrationSourceType
|
||||
sourcePackage?: Scalars['String']
|
||||
latestAvailableVersion?: Scalars['String']
|
||||
logo?: Scalars['String']
|
||||
isListed: Scalars['Boolean']
|
||||
isFeatured: Scalars['Boolean']
|
||||
manifest?: Scalars['JSON']
|
||||
@@ -2927,7 +2926,7 @@ export interface ApplicationRegistrationGenqlSelection{
|
||||
isListed?: boolean | number
|
||||
isFeatured?: boolean | number
|
||||
isPreInstalled?: boolean | number
|
||||
logo?: boolean | number
|
||||
logoUrl?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
updatedAt?: boolean | number
|
||||
isConfigured?: boolean | number
|
||||
@@ -3104,7 +3103,7 @@ export interface ApplicationRegistrationSummaryGenqlSelection{
|
||||
id?: boolean | number
|
||||
latestAvailableVersion?: boolean | number
|
||||
sourceType?: boolean | number
|
||||
logo?: boolean | number
|
||||
logoUrl?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
@@ -4560,7 +4559,7 @@ export interface CreateApplicationRegistrationGenqlSelection{
|
||||
export interface PublicApplicationRegistrationGenqlSelection{
|
||||
id?: boolean | number
|
||||
name?: boolean | number
|
||||
logo?: boolean | number
|
||||
logoUrl?: boolean | number
|
||||
websiteUrl?: boolean | number
|
||||
oAuthScopes?: boolean | number
|
||||
__typename?: boolean | number
|
||||
@@ -5035,7 +5034,6 @@ export interface MarketplaceAppDetailGenqlSelection{
|
||||
sourceType?: boolean | number
|
||||
sourcePackage?: boolean | number
|
||||
latestAvailableVersion?: boolean | number
|
||||
logo?: boolean | number
|
||||
isListed?: boolean | number
|
||||
isFeatured?: boolean | number
|
||||
manifest?: boolean | number
|
||||
|
||||
@@ -204,7 +204,7 @@ export default {
|
||||
"isPreInstalled": [
|
||||
6
|
||||
],
|
||||
"logo": [
|
||||
"logoUrl": [
|
||||
1
|
||||
],
|
||||
"createdAt": [
|
||||
@@ -615,7 +615,7 @@ export default {
|
||||
"sourceType": [
|
||||
8
|
||||
],
|
||||
"logo": [
|
||||
"logoUrl": [
|
||||
1
|
||||
],
|
||||
"__typename": [
|
||||
@@ -3886,7 +3886,7 @@ export default {
|
||||
"name": [
|
||||
1
|
||||
],
|
||||
"logo": [
|
||||
"logoUrl": [
|
||||
1
|
||||
],
|
||||
"websiteUrl": [
|
||||
@@ -4643,9 +4643,6 @@ export default {
|
||||
"latestAvailableVersion": [
|
||||
1
|
||||
],
|
||||
"logo": [
|
||||
1
|
||||
],
|
||||
"isListed": [
|
||||
6
|
||||
],
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Field types with similar names can use entirely different operands — `SELECT` and `MULTI_SELECT` being a common case.
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Feldtypen mit ähnlichen Namen können völlig unterschiedliche Operanden verwenden – `SELECT` und `MULTI_SELECT` sind ein häufiges Beispiel.
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Tipos de campos com nomes semelhantes podem usar operandos completamente diferentes — `SELECT` e `MULTI_SELECT` sendo um caso comum.
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Tipurile de câmp cu nume similare pot folosi operanzi complet diferiți — `SELECT` și `MULTI_SELECT` fiind un caz comun.
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Типы полей с похожими названиями могут использовать совершенно разные операнды — типичный пример: `SELECT` и `MULTI_SELECT`.
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Benzer adlara sahip alan türleri tamamen farklı işleçler kullanabilir — buna `SELECT` ve `MULTI_SELECT` yaygın bir örnektir.
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> 名称相似的字段类型可以使用完全不同的运算符 —— `SELECT` 和 `MULTI_SELECT` 就是常见情况。
|
||||
|
||||
@@ -176,7 +176,7 @@ export type ApplicationRegistration = {
|
||||
isListed: Scalars['Boolean'];
|
||||
isPreInstalled: Scalars['Boolean'];
|
||||
latestAvailableVersion?: Maybe<Scalars['String']>;
|
||||
logo?: Maybe<Scalars['String']>;
|
||||
logoUrl?: Maybe<Scalars['String']>;
|
||||
name: Scalars['String'];
|
||||
oAuthClientId: Scalars['String'];
|
||||
oAuthRedirectUris: Array<Scalars['String']>;
|
||||
@@ -933,7 +933,7 @@ export type FindAdminApplicationRegistrationVariablesQuery = { __typename?: 'Que
|
||||
export type FindAllApplicationRegistrationsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type FindAllApplicationRegistrationsQuery = { __typename?: 'Query', findAllApplicationRegistrations: Array<{ __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, logo?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, isPreInstalled: boolean, isConfigured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string }> };
|
||||
export type FindAllApplicationRegistrationsQuery = { __typename?: 'Query', findAllApplicationRegistrations: Array<{ __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, logoUrl?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, isPreInstalled: boolean, isConfigured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string }> };
|
||||
|
||||
export type CreateDatabaseConfigVariableMutationVariables = Exact<{
|
||||
key: Scalars['String'];
|
||||
@@ -1000,7 +1000,7 @@ export type FindOneAdminApplicationRegistrationQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type FindOneAdminApplicationRegistrationQuery = { __typename?: 'Query', findOneAdminApplicationRegistration: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, logo?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, isPreInstalled: boolean, isConfigured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string } };
|
||||
export type FindOneAdminApplicationRegistrationQuery = { __typename?: 'Query', findOneAdminApplicationRegistration: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, logoUrl?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, isPreInstalled: boolean, isConfigured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string } };
|
||||
|
||||
export type GetAdminChatThreadMessagesQueryVariables = Exact<{
|
||||
threadId: Scalars['UUID'];
|
||||
@@ -1136,10 +1136,10 @@ export type GetSigningKeysQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
export type GetSigningKeysQuery = { __typename?: 'Query', getSigningKeys: { __typename?: 'SigningKeysAdminPanelDTO', legacyVerifyCountInWindow: number, verifyWindowDays: number, signingKeys: Array<{ __typename?: 'SigningKeyDTO', id: string, publicKey: string, isCurrent: boolean, createdAt: string, revokedAt?: string | null, verifyCountInWindow: number }> } };
|
||||
|
||||
export type ApplicationRegistrationFragmentFragment = { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, logo?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, isPreInstalled: boolean, isConfigured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string };
|
||||
export type ApplicationRegistrationFragmentFragment = { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, logoUrl?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, isPreInstalled: boolean, isConfigured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string };
|
||||
|
||||
export const UserInfoFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserInfoFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UserInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode<UserInfoFragmentFragment, unknown>;
|
||||
export const ApplicationRegistrationFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<ApplicationRegistrationFragmentFragment, unknown>;
|
||||
export const ApplicationRegistrationFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<ApplicationRegistrationFragmentFragment, unknown>;
|
||||
export const AddAiProviderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddAiProvider"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerConfig"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addAiProvider"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerName"}}},{"kind":"Argument","name":{"kind":"Name","value":"providerConfig"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerConfig"}}}]}]}}]} as unknown as DocumentNode<AddAiProviderMutation, AddAiProviderMutationVariables>;
|
||||
export const AddModelToProviderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddModelToProvider"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelConfig"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addModelToProvider"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerName"}}},{"kind":"Argument","name":{"kind":"Name","value":"modelConfig"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelConfig"}}}]}]}}]} as unknown as DocumentNode<AddModelToProviderMutation, AddModelToProviderMutationVariables>;
|
||||
export const RemoveAiProviderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveAiProvider"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeAiProvider"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerName"}}}]}]}}]} as unknown as DocumentNode<RemoveAiProviderMutation, RemoveAiProviderMutationVariables>;
|
||||
@@ -1155,7 +1155,7 @@ export const GetAiProvidersDocument = {"kind":"Document","definitions":[{"kind":
|
||||
export const GetModelsDevProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModelsDevProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getModelsDevProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"modelCount"}},{"kind":"Field","name":{"kind":"Name","value":"npm"}}]}}]}}]} as unknown as DocumentNode<GetModelsDevProvidersQuery, GetModelsDevProvidersQueryVariables>;
|
||||
export const GetModelsDevSuggestionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModelsDevSuggestions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getModelsDevSuggestions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"modelId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"inputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"outputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"cachedInputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"cacheCreationCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"contextWindowTokens"}},{"kind":"Field","name":{"kind":"Name","value":"maxOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"modalities"}},{"kind":"Field","name":{"kind":"Name","value":"supportsReasoning"}}]}}]}}]} as unknown as DocumentNode<GetModelsDevSuggestionsQuery, GetModelsDevSuggestionsQueryVariables>;
|
||||
export const FindAdminApplicationRegistrationVariablesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAdminApplicationRegistrationVariables"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAdminApplicationRegistrationVariables"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRegistrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isSecret"}},{"kind":"Field","name":{"kind":"Name","value":"isRequired"}},{"kind":"Field","name":{"kind":"Name","value":"isFilled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<FindAdminApplicationRegistrationVariablesQuery, FindAdminApplicationRegistrationVariablesQueryVariables>;
|
||||
export const FindAllApplicationRegistrationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindAllApplicationRegistrationsQuery, FindAllApplicationRegistrationsQueryVariables>;
|
||||
export const FindAllApplicationRegistrationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindAllApplicationRegistrationsQuery, FindAllApplicationRegistrationsQueryVariables>;
|
||||
export const CreateDatabaseConfigVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateDatabaseConfigVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createDatabaseConfigVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}},{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}}]}]}}]} as unknown as DocumentNode<CreateDatabaseConfigVariableMutation, CreateDatabaseConfigVariableMutationVariables>;
|
||||
export const DeleteDatabaseConfigVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteDatabaseConfigVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteDatabaseConfigVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}]}]}}]} as unknown as DocumentNode<DeleteDatabaseConfigVariableMutation, DeleteDatabaseConfigVariableMutationVariables>;
|
||||
export const UpdateDatabaseConfigVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateDatabaseConfigVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateDatabaseConfigVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}},{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}}]}]}}]} as unknown as DocumentNode<UpdateDatabaseConfigVariableMutation, UpdateDatabaseConfigVariableMutationVariables>;
|
||||
@@ -1164,7 +1164,7 @@ export const GetDatabaseConfigVariableDocument = {"kind":"Document","definitions
|
||||
export const UpdateWorkspaceFeatureFlagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspaceFeatureFlag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"featureFlag"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkspaceFeatureFlag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"featureFlag"},"value":{"kind":"Variable","name":{"kind":"Name","value":"featureFlag"}}},{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}}]}]}}]} as unknown as DocumentNode<UpdateWorkspaceFeatureFlagMutation, UpdateWorkspaceFeatureFlagMutationVariables>;
|
||||
export const AdminPanelRecentUsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminPanelRecentUsers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminPanelRecentUsers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"searchTerm"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceLogo"}}]}}]}}]} as unknown as DocumentNode<AdminPanelRecentUsersQuery, AdminPanelRecentUsersQueryVariables>;
|
||||
export const AdminPanelTopWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminPanelTopWorkspaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminPanelTopWorkspaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"searchTerm"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"totalUsers"}},{"kind":"Field","name":{"kind":"Name","value":"subdomain"}}]}}]}}]} as unknown as DocumentNode<AdminPanelTopWorkspacesQuery, AdminPanelTopWorkspacesQueryVariables>;
|
||||
export const FindOneAdminApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneAdminApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findOneAdminApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindOneAdminApplicationRegistrationQuery, FindOneAdminApplicationRegistrationQueryVariables>;
|
||||
export const FindOneAdminApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneAdminApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findOneAdminApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindOneAdminApplicationRegistrationQuery, FindOneAdminApplicationRegistrationQueryVariables>;
|
||||
export const GetAdminChatThreadMessagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminChatThreadMessages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminChatThreadMessages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"thread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode<GetAdminChatThreadMessagesQuery, GetAdminChatThreadMessagesQueryVariables>;
|
||||
export const GetAdminWorkspaceChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminWorkspaceChatThreads"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminWorkspaceChatThreads"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<GetAdminWorkspaceChatThreadsQuery, GetAdminWorkspaceChatThreadsQueryVariables>;
|
||||
export const GetUpgradeStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUpgradeStatus"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getUpgradeStatus"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"inferredVersion"}},{"kind":"Field","name":{"kind":"Name","value":"health"}},{"kind":"Field","name":{"kind":"Name","value":"latestCommand"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"executedByVersion"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode<GetUpgradeStatusQuery, GetUpgradeStatusQueryVariables>;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -19,7 +19,7 @@ export const APPLICATION_FRAGMENT = gql`
|
||||
id
|
||||
latestAvailableVersion
|
||||
sourceType
|
||||
logo
|
||||
logoUrl
|
||||
}
|
||||
canBeUninstalled
|
||||
defaultRoleId
|
||||
|
||||
@@ -8,6 +8,7 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { buildApplicationLogoUrl } from '@/applications/utils/buildApplicationLogoUrl';
|
||||
import CustomLogo from '~/pages/settings/applications/assets/custom-illustrations/custom-logo.webp';
|
||||
import StandardLogo from '~/pages/settings/applications/assets/standard-illustrations/standard-logo.webp';
|
||||
|
||||
@@ -66,7 +67,11 @@ export const useApplicationChipData = ({
|
||||
? new URL(StandardLogo, window.location.href).toString()
|
||||
: isCustom
|
||||
? new URL(CustomLogo, window.location.href).toString()
|
||||
: (application.logo ?? undefined);
|
||||
: buildApplicationLogoUrl({
|
||||
applicationId: application.id,
|
||||
logo: application.logo,
|
||||
workspaceId: currentWorkspace?.id,
|
||||
});
|
||||
|
||||
return {
|
||||
applicationChipData: {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const buildApplicationLogoUrl = ({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
logo,
|
||||
}: {
|
||||
workspaceId?: string | null;
|
||||
applicationId?: string | null;
|
||||
logo?: string | null;
|
||||
}): string | undefined => {
|
||||
if (
|
||||
!isDefined(logo) ||
|
||||
logo.startsWith('http://') ||
|
||||
logo.startsWith('https://')
|
||||
) {
|
||||
return logo ?? undefined;
|
||||
}
|
||||
|
||||
if (!isDefined(workspaceId) || !isDefined(applicationId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${REACT_APP_SERVER_BASE_URL}/public-assets/${workspaceId}/${applicationId}/${logo}`;
|
||||
};
|
||||
+1
-1
@@ -4,7 +4,7 @@ export const FIND_APPLICATION_REGISTRATION_BY_CLIENT_ID = gql`
|
||||
query FindApplicationRegistrationByClientId($clientId: String!) {
|
||||
findApplicationRegistrationByClientId(clientId: $clientId) {
|
||||
id
|
||||
logo
|
||||
logoUrl
|
||||
name
|
||||
oAuthScopes
|
||||
websiteUrl
|
||||
|
||||
-1
@@ -8,7 +8,6 @@ export const MARKETPLACE_APP_DETAIL_FRAGMENT = gql`
|
||||
sourceType
|
||||
sourcePackage
|
||||
latestAvailableVersion
|
||||
logo
|
||||
isListed
|
||||
isFeatured
|
||||
manifest
|
||||
|
||||
+1
-1
@@ -179,7 +179,7 @@ const SettingsAdminAppsTableRow = ({
|
||||
<AppChip
|
||||
size="md"
|
||||
fallbackApplicationData={{
|
||||
logo: registration.logo,
|
||||
logo: registration.logoUrl,
|
||||
name: registration.name,
|
||||
}}
|
||||
/>
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const FIND_ADMIN_APPLICATION_REGISTRATION_VARIABLES = gql`
|
||||
query FindAdminApplicationRegistrationVariables(
|
||||
$applicationRegistrationId: String!
|
||||
) {
|
||||
findAdminApplicationRegistrationVariables(
|
||||
applicationRegistrationId: $applicationRegistrationId
|
||||
) {
|
||||
id
|
||||
key
|
||||
value
|
||||
description
|
||||
isSecret
|
||||
isRequired
|
||||
isFilled
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
+1
-1
@@ -5,7 +5,7 @@ export const APPLICATION_REGISTRATION_FRAGMENT = gql`
|
||||
id
|
||||
universalIdentifier
|
||||
name
|
||||
logo
|
||||
logoUrl
|
||||
oAuthClientId
|
||||
oAuthRedirectUris
|
||||
oAuthScopes
|
||||
|
||||
-73
@@ -1,10 +1,8 @@
|
||||
import { useUpdateStep } from '@/workflow/workflow-steps/hooks/useUpdateStep';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { type WorkflowAction } from '~/generated/graphql';
|
||||
|
||||
const mockUpdateWorkflowVersionStep = jest.fn();
|
||||
const mockGetUpdatableWorkflowVersion = jest.fn();
|
||||
const mockMarkStepForRecomputation = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
'@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep',
|
||||
@@ -21,12 +19,6 @@ jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/workflow/workflow-variables/hooks/useStepsOutputSchema', () => ({
|
||||
useStepsOutputSchema: jest.fn(() => ({
|
||||
markStepForRecomputation: mockMarkStepForRecomputation,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('useUpdateStep', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -69,69 +61,4 @@ describe('useUpdateStep', () => {
|
||||
step: mockStep,
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark step for recomputation after update', async () => {
|
||||
const mockWorkflowVersionId = 'version-123';
|
||||
const mockStep = {
|
||||
id: 'step-1',
|
||||
name: 'Create Record',
|
||||
valid: true,
|
||||
type: 'CREATE_RECORD' as const,
|
||||
settings: {
|
||||
input: {
|
||||
objectName: 'company',
|
||||
},
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: false },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockGetUpdatableWorkflowVersion.mockResolvedValue(mockWorkflowVersionId);
|
||||
|
||||
const { result } = renderHook(() => useUpdateStep());
|
||||
await act(async () => {
|
||||
await result.current.updateStep(mockStep as WorkflowAction);
|
||||
});
|
||||
|
||||
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
|
||||
stepId: 'step-1',
|
||||
workflowVersionId: mockWorkflowVersionId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark step for recomputation for all step types', async () => {
|
||||
const mockWorkflowVersionId = 'version-123';
|
||||
const stepTypes = ['CODE', 'HTTP_REQUEST', 'CREATE_RECORD', 'SEND_EMAIL'];
|
||||
|
||||
for (const stepType of stepTypes) {
|
||||
mockMarkStepForRecomputation.mockClear();
|
||||
mockGetUpdatableWorkflowVersion.mockResolvedValue(mockWorkflowVersionId);
|
||||
|
||||
const mockStep = {
|
||||
id: `step-${stepType}`,
|
||||
name: `${stepType} Step`,
|
||||
valid: true,
|
||||
type: stepType as any,
|
||||
settings: {
|
||||
input: {},
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: false },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useUpdateStep());
|
||||
await act(async () => {
|
||||
await result.current.updateStep(mockStep as WorkflowAction);
|
||||
});
|
||||
|
||||
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
|
||||
stepId: `step-${stepType}`,
|
||||
workflowVersionId: mockWorkflowVersionId,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { useUpdateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
const mockMutate = jest.fn();
|
||||
const mockGetRecordFromCache = jest.fn();
|
||||
const mockMarkStepForRecomputation = jest.fn();
|
||||
const mockEnqueueErrorSnackBar = jest.fn();
|
||||
|
||||
jest.mock('@/object-metadata/hooks/useApolloCoreClient', () => ({
|
||||
useApolloCoreClient: () => ({ cache: {} }),
|
||||
}));
|
||||
|
||||
jest.mock('@/object-metadata/hooks/useObjectMetadataItems', () => ({
|
||||
useObjectMetadataItems: () => ({ objectMetadataItems: [] }),
|
||||
}));
|
||||
|
||||
jest.mock('@/object-metadata/hooks/useObjectMetadataItem', () => ({
|
||||
useObjectMetadataItem: () => ({ objectMetadataItem: {} }),
|
||||
}));
|
||||
|
||||
jest.mock('@/object-record/hooks/useObjectPermissions', () => ({
|
||||
useObjectPermissions: () => ({ objectPermissionsByObjectMetadataId: {} }),
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
|
||||
useSnackBar: () => ({ enqueueErrorSnackBar: mockEnqueueErrorSnackBar }),
|
||||
}));
|
||||
|
||||
jest.mock('@/object-record/cache/hooks/useGetRecordFromCache', () => ({
|
||||
useGetRecordFromCache: () => mockGetRecordFromCache,
|
||||
}));
|
||||
|
||||
jest.mock('@/object-record/cache/utils/updateRecordFromCache', () => ({
|
||||
updateRecordFromCache: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/workflow/workflow-variables/hooks/useStepsOutputSchema', () => ({
|
||||
useStepsOutputSchema: () => ({
|
||||
markStepForRecomputation: mockMarkStepForRecomputation,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@apollo/client/react', () => ({
|
||||
useMutation: () => [mockMutate],
|
||||
}));
|
||||
|
||||
describe('useUpdateWorkflowVersionStep', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should mark step for recomputation after successful update', async () => {
|
||||
const updatedStep = {
|
||||
id: 'step-1',
|
||||
name: 'My Custom Name',
|
||||
type: 'CREATE_RECORD',
|
||||
};
|
||||
|
||||
mockMutate.mockResolvedValue({
|
||||
data: { updateWorkflowVersionStep: updatedStep },
|
||||
});
|
||||
|
||||
mockGetRecordFromCache.mockReturnValue({
|
||||
id: 'version-1',
|
||||
steps: [{ id: 'step-1', name: 'Create Record', type: 'CREATE_RECORD' }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUpdateWorkflowVersionStep());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateWorkflowVersionStep({
|
||||
workflowVersionId: 'version-1',
|
||||
step: updatedStep,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
|
||||
stepId: 'step-1',
|
||||
workflowVersionId: 'version-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not mark step for recomputation when mutation returns no data', async () => {
|
||||
mockMutate.mockResolvedValue({ data: null });
|
||||
|
||||
const { result } = renderHook(() => useUpdateWorkflowVersionStep());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateWorkflowVersionStep({
|
||||
workflowVersionId: 'version-1',
|
||||
step: { id: 'step-1', name: 'Step', type: 'CODE' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockMarkStepForRecomputation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still mark step for recomputation when cached record is missing', async () => {
|
||||
mockMutate.mockResolvedValue({
|
||||
data: {
|
||||
updateWorkflowVersionStep: {
|
||||
id: 'step-1',
|
||||
name: 'Step',
|
||||
type: 'CODE',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockGetRecordFromCache.mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useUpdateWorkflowVersionStep());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateWorkflowVersionStep({
|
||||
workflowVersionId: 'version-1',
|
||||
step: { id: 'step-1', name: 'Step', type: 'CODE' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
|
||||
stepId: 'step-1',
|
||||
workflowVersionId: 'version-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
|
||||
import { type WorkflowAction } from '@/workflow/types/Workflow';
|
||||
import { useUpdateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep';
|
||||
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
|
||||
|
||||
export const useUpdateStep = () => {
|
||||
const { getUpdatableWorkflowVersion } =
|
||||
useGetUpdatableWorkflowVersionOrThrow();
|
||||
const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep();
|
||||
const { markStepForRecomputation } = useStepsOutputSchema();
|
||||
|
||||
const updateStep = async (updatedStep: WorkflowAction) => {
|
||||
const workflowVersionId = await getUpdatableWorkflowVersion();
|
||||
@@ -17,11 +15,6 @@ export const useUpdateStep = () => {
|
||||
step: updatedStep,
|
||||
});
|
||||
|
||||
markStepForRecomputation({
|
||||
stepId: updatedStep.id,
|
||||
workflowVersionId,
|
||||
});
|
||||
|
||||
return {
|
||||
updatedStep: result?.data?.updateWorkflowVersionStep,
|
||||
};
|
||||
|
||||
+9
-1
@@ -10,6 +10,7 @@ import {
|
||||
type WorkflowVersion,
|
||||
type WorkflowStep,
|
||||
} from '@/workflow/types/Workflow';
|
||||
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -24,6 +25,7 @@ export const useUpdateWorkflowVersionStep = () => {
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const { markStepForRecomputation } = useStepsOutputSchema();
|
||||
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
|
||||
@@ -52,11 +54,16 @@ export const useUpdateWorkflowVersionStep = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
markStepForRecomputation({
|
||||
stepId: updatedStep.id,
|
||||
workflowVersionId: input.workflowVersionId,
|
||||
});
|
||||
|
||||
const cachedRecord = getRecordFromCache<WorkflowVersion>(
|
||||
input.workflowVersionId,
|
||||
);
|
||||
if (!isDefined(cachedRecord)) {
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
|
||||
const newCachedRecord = {
|
||||
@@ -80,6 +87,7 @@ export const useUpdateWorkflowVersionStep = () => {
|
||||
recordGqlFields,
|
||||
objectPermissionsByObjectMetadataId,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ export const Authorize = () => {
|
||||
}
|
||||
|
||||
const appName = applicationRegistration.name;
|
||||
const appLogoUrl = applicationRegistration.logo;
|
||||
const appLogoUrl = applicationRegistration.logoUrl;
|
||||
const requestedScopes: string[] = applicationRegistration.oAuthScopes ?? [];
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,7 +20,7 @@ const buildHandlers = (
|
||||
application: {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
logoUrl: string | null;
|
||||
oAuthScopes: string[];
|
||||
websiteUrl: string | null;
|
||||
} | null,
|
||||
@@ -90,7 +90,7 @@ export const Default: Story = {
|
||||
handlers: buildHandlers({
|
||||
id: 'application-id-default',
|
||||
name: 'ChatGPT',
|
||||
logo: null,
|
||||
logoUrl: null,
|
||||
oAuthScopes: ['api', 'profile'],
|
||||
websiteUrl: null,
|
||||
}),
|
||||
@@ -116,7 +116,7 @@ export const WithApiScopeOnly: Story = {
|
||||
handlers: buildHandlers({
|
||||
id: 'application-id-api-only',
|
||||
name: 'Internal Tool',
|
||||
logo: null,
|
||||
logoUrl: null,
|
||||
oAuthScopes: ['api'],
|
||||
websiteUrl: null,
|
||||
}),
|
||||
@@ -144,7 +144,7 @@ export const WithLongAppName: Story = {
|
||||
handlers: buildHandlers({
|
||||
id: 'application-id-long-name',
|
||||
name: 'Custom Workspace Automation Suite',
|
||||
logo: null,
|
||||
logoUrl: null,
|
||||
oAuthScopes: ['api', 'profile'],
|
||||
websiteUrl: null,
|
||||
}),
|
||||
|
||||
+1
-21
@@ -8,15 +8,12 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa
|
||||
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
|
||||
import { APPLICATION_REGISTRATION_ADMIN_PATH } from '@/settings/admin-panel/apps/constants/ApplicationRegistrationAdminPath';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { styled } from '@linaria/react';
|
||||
import {
|
||||
Avatar,
|
||||
IconInfoCircle,
|
||||
IconKey,
|
||||
IconSettings,
|
||||
IconWorld,
|
||||
} from 'twenty-ui/display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { SettingsApplicationRegistrationConfigTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationConfigTab';
|
||||
import { SettingsApplicationRegistrationOAuthTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationOAuthTab';
|
||||
import { SettingsApplicationRegistrationDistributionTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationDistributionTab';
|
||||
@@ -25,12 +22,6 @@ import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
|
||||
const StyledTitleContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const REGISTRATION_DETAIL_TAB_LIST_ID =
|
||||
'admin-application-registration-detail-tab-list';
|
||||
|
||||
@@ -103,18 +94,7 @@ export const SettingsAdminApplicationRegistrationDetail = () => {
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={
|
||||
<StyledTitleContainer>
|
||||
<Avatar
|
||||
type="app"
|
||||
size="md"
|
||||
avatarUrl={registration.logo ?? undefined}
|
||||
placeholder={registration.name}
|
||||
placeholderColorSeed={registration.name}
|
||||
/>
|
||||
{registration.name}
|
||||
</StyledTitleContainer>
|
||||
}
|
||||
title={registration.name}
|
||||
links={[
|
||||
{
|
||||
children: t`Other`,
|
||||
|
||||
+1
-21
@@ -8,15 +8,12 @@ import { useLingui } from '@lingui/react/macro';
|
||||
import { Tag } from 'twenty-ui/components';
|
||||
import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { styled } from '@linaria/react';
|
||||
import {
|
||||
Avatar,
|
||||
IconInfoCircle,
|
||||
IconKey,
|
||||
IconSettings,
|
||||
IconWorld,
|
||||
} from 'twenty-ui/display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { SettingsApplicationRegistrationConfigTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationConfigTab';
|
||||
import { SettingsApplicationRegistrationOAuthTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationOAuthTab';
|
||||
import { SettingsApplicationRegistrationDistributionTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationDistributionTab';
|
||||
@@ -24,12 +21,6 @@ import { SettingsApplicationRegistrationGeneralTab } from '~/pages/settings/appl
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
|
||||
const StyledTitleContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const REGISTRATION_DETAIL_TAB_LIST_ID =
|
||||
'application-registration-detail-tab-list';
|
||||
|
||||
@@ -95,18 +86,7 @@ export const SettingsApplicationRegistrationDetails = () => {
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={
|
||||
<StyledTitleContainer>
|
||||
<Avatar
|
||||
type="app"
|
||||
size="md"
|
||||
avatarUrl={registration.logo ?? undefined}
|
||||
placeholder={registration.name}
|
||||
placeholderColorSeed={registration.name}
|
||||
/>
|
||||
{registration.name}
|
||||
</StyledTitleContainer>
|
||||
}
|
||||
title={registration.name}
|
||||
tag={<Tag text={t`Owner`} color={'gray'} />}
|
||||
links={[
|
||||
{
|
||||
|
||||
+1
-5
@@ -247,7 +247,7 @@ export const SettingsAvailableApplicationDetails = () => {
|
||||
manifestContent={manifest}
|
||||
applicationInfo={{
|
||||
name: displayName,
|
||||
logo: detail?.logo,
|
||||
logo: app?.logoUrl,
|
||||
universalIdentifier: detail.universalIdentifier,
|
||||
}}
|
||||
/>
|
||||
@@ -288,10 +288,6 @@ export const SettingsAvailableApplicationDetails = () => {
|
||||
displayName={displayName}
|
||||
description={description}
|
||||
applicationId={application?.id}
|
||||
fallbackApplicationData={{
|
||||
logo: detail?.logo,
|
||||
name: displayName,
|
||||
}}
|
||||
isUnlisted={isUnlisted}
|
||||
/>
|
||||
}
|
||||
|
||||
+3
-6
@@ -9,11 +9,10 @@ import { useApplicationChipData } from '@/applications/hooks/useApplicationChipD
|
||||
type SettingsApplicationDetailTitleProps = {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
applicationId?: string;
|
||||
fallbackApplicationData?: {
|
||||
logo?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
applicationName?: string;
|
||||
universalIdentifier?: string;
|
||||
isUnlisted?: boolean;
|
||||
};
|
||||
|
||||
@@ -74,14 +73,12 @@ export const SettingsApplicationDetailTitle = ({
|
||||
displayName,
|
||||
description,
|
||||
applicationId,
|
||||
fallbackApplicationData,
|
||||
isUnlisted = false,
|
||||
}: SettingsApplicationDetailTitleProps) => {
|
||||
const descriptionSummary = getApplicationDescriptionSummary(description);
|
||||
|
||||
const { applicationChipData } = useApplicationChipData({
|
||||
applicationId,
|
||||
fallbackApplicationData,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
+5
-4
@@ -148,10 +148,11 @@ const buildFieldMetadataItemFromMarketplaceField = (
|
||||
field: ObjectFieldManifest,
|
||||
): FieldMetadataItem => {
|
||||
const now = new Date().toISOString();
|
||||
const universalIdentifier = field.universalIdentifier ?? uuidv4();
|
||||
|
||||
return {
|
||||
id: field.universalIdentifier ?? uuidv4(),
|
||||
universalIdentifier: field.universalIdentifier ?? uuidv4(),
|
||||
id: universalIdentifier,
|
||||
universalIdentifier,
|
||||
name: field.name,
|
||||
label: field.label,
|
||||
type: (field.type as FieldMetadataType) ?? FieldMetadataType.TEXT,
|
||||
@@ -172,7 +173,7 @@ const buildFieldMetadataItemFromMarketplaceField = (
|
||||
};
|
||||
};
|
||||
|
||||
const buildobjectMetadataItemsFromMarketplaceApp = (
|
||||
const buildObjectMetadataItemsFromMarketplaceApp = (
|
||||
defaultRole: RoleManifest,
|
||||
objectUniversalIdToIdMap: Record<string, string>,
|
||||
marketplaceAppObjects: ObjectManifest[],
|
||||
@@ -292,7 +293,7 @@ const MarketplaceRoleEffect = ({
|
||||
fieldUniversalIdToIdMap,
|
||||
),
|
||||
objectMetadataItemsFromMarketplaceApp:
|
||||
buildobjectMetadataItemsFromMarketplaceApp(
|
||||
buildObjectMetadataItemsFromMarketplaceApp(
|
||||
defaultRole,
|
||||
objectUniversalIdToIdMap,
|
||||
marketplaceAppObjects,
|
||||
|
||||
+2
-3
@@ -177,8 +177,8 @@ export const SettingsApplicationsDeveloperTab = () => {
|
||||
<TableRow
|
||||
gridTemplateColumns={APPLICATION_TABLE_ROW_GRID_TEMPLATE_COLUMNS}
|
||||
>
|
||||
<TableHeader>{t`Name`}</TableHeader>
|
||||
<TableHeader>{t`Type`}</TableHeader>
|
||||
<TableHeader> {t`Name`}</TableHeader>
|
||||
<TableHeader>{''}</TableHeader>
|
||||
<TableHeader>{''}</TableHeader>
|
||||
<TableHeader />
|
||||
</TableRow>
|
||||
@@ -188,7 +188,6 @@ export const SettingsApplicationsDeveloperTab = () => {
|
||||
<SettingsApplicationTableRow
|
||||
key={registration.id}
|
||||
application={registration}
|
||||
sourceType={registration.sourceType}
|
||||
action={
|
||||
<IconChevronRight
|
||||
size={theme.icon.size.md}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-sdk",
|
||||
"version": "2.8.0",
|
||||
"version": "2.9.0",
|
||||
"sideEffects": false,
|
||||
"bin": {
|
||||
"twenty": "dist/cli.cjs"
|
||||
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
getInputSchemaFromSourceCode,
|
||||
jsonSchemaToInputSchema,
|
||||
} from 'twenty-shared/logic-function';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const loadSources = async (appPath: string): Promise<string[]> => {
|
||||
return await glob(['**/*.ts', '**/*.tsx'], {
|
||||
@@ -358,7 +358,7 @@ export const buildManifest = async (
|
||||
errors.push(...extract.errors);
|
||||
warnings.push(...(extract.warnings ?? []));
|
||||
|
||||
const { component, ...rest } = extract.config;
|
||||
const { component, command, ...rest } = extract.config;
|
||||
|
||||
const relativeFilePath = relative(appPath, filePath);
|
||||
|
||||
@@ -374,6 +374,14 @@ export const buildManifest = async (
|
||||
frontComponents.push(config);
|
||||
frontComponentsFilePaths.push(relativePath);
|
||||
|
||||
if (isDefined(command)) {
|
||||
commandMenuItems.push({
|
||||
...command,
|
||||
frontComponentUniversalIdentifier: config.universalIdentifier,
|
||||
} as unknown as CommandMenuItemManifest);
|
||||
commandMenuItemsFilePaths.push(relativePath);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case ManifestEntityKey.Views: {
|
||||
|
||||
@@ -19,6 +19,16 @@ export const defineFrontComponent: DefineEntity<FrontComponentConfig> = (
|
||||
errors.push('Front component component must be a React component');
|
||||
}
|
||||
|
||||
if (config.command) {
|
||||
if (!config.command.universalIdentifier) {
|
||||
errors.push('Command must have a universalIdentifier');
|
||||
}
|
||||
|
||||
if (!config.command.label) {
|
||||
errors.push('Command must have a label');
|
||||
}
|
||||
}
|
||||
|
||||
return createValidationResult({
|
||||
config,
|
||||
errors,
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { type FrontComponentManifest } from 'twenty-shared/application';
|
||||
import {
|
||||
type FrontComponentCommandManifest,
|
||||
type FrontComponentManifest,
|
||||
} from 'twenty-shared/application';
|
||||
|
||||
export type FrontComponentType = React.ComponentType<any>;
|
||||
|
||||
export type FrontComponentCommandConfig = Omit<
|
||||
FrontComponentCommandManifest,
|
||||
'conditionalAvailabilityExpression'
|
||||
> & {
|
||||
conditionalAvailabilityExpression?: boolean | string;
|
||||
};
|
||||
|
||||
export type FrontComponentConfig = Omit<
|
||||
FrontComponentManifest,
|
||||
| 'sourceComponentPath'
|
||||
@@ -11,4 +21,5 @@ export type FrontComponentConfig = Omit<
|
||||
| 'usesSdkClient'
|
||||
> & {
|
||||
component: FrontComponentType;
|
||||
command?: FrontComponentCommandConfig;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
DEFAULT_API_URL_NAME,
|
||||
DEFAULT_APP_ACCESS_TOKEN_NAME,
|
||||
} from 'twenty-shared/application';
|
||||
import { buildPublicAssetUrl } from 'twenty-shared/utils';
|
||||
|
||||
const decodeTokenPayload = (
|
||||
token: string,
|
||||
@@ -34,24 +33,10 @@ export const getPublicAssetUrl = (path: string): string => {
|
||||
? withoutLeadingSlash
|
||||
: `public/${withoutLeadingSlash}`;
|
||||
|
||||
const isAbsolute =
|
||||
withPublicPrefix.startsWith('http://') ||
|
||||
withPublicPrefix.startsWith('https://');
|
||||
const encodedPath = withPublicPrefix
|
||||
.split('/')
|
||||
.map(encodeURIComponent)
|
||||
.join('/');
|
||||
|
||||
const encodedPath = isAbsolute
|
||||
? withPublicPrefix
|
||||
: withPublicPrefix.split('/').map(encodeURIComponent).join('/');
|
||||
|
||||
const url = buildPublicAssetUrl({
|
||||
path: encodedPath,
|
||||
serverUrl: apiUrl,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
});
|
||||
|
||||
if (!url) {
|
||||
throw new Error('Failed to build public asset URL');
|
||||
}
|
||||
|
||||
return url;
|
||||
return `${apiUrl}/public-assets/${workspaceId}/${applicationId}/${encodedPath}`;
|
||||
};
|
||||
|
||||
@@ -96,5 +96,8 @@ FRONTEND_URL=http://localhost:3001
|
||||
# Add custom providers (private gateway, extra regions, etc.):
|
||||
# AI_PROVIDERS='{"my-gateway":{"type":"openai-compatible","baseUrl":"...","apiKey":"..."}}'
|
||||
#
|
||||
# Override model preferences (disabled, recommended, defaults):
|
||||
# AI_MODEL_PREFERENCES='{"recommendedModels":["openai/gpt-5.2"],"defaultFastModels":["openai/gpt-5-mini"]}'
|
||||
# Comma-separated model IDs (JSON array syntax also accepted, e.g. '["a","b"]'):
|
||||
# AI_MODELS_DEFAULT_FAST=openai/gpt-5-mini,anthropic/claude-haiku-4-5-20251001
|
||||
# AI_MODELS_DEFAULT_SMART=openai/gpt-5.2,anthropic/claude-sonnet-4-6
|
||||
# AI_MODELS_DEFAULT_RECOMMENDED=openai/gpt-5.2,openai/gpt-4.1,anthropic/claude-sonnet-4-6
|
||||
# AI_MODELS_DEFAULT_DISABLED=
|
||||
|
||||
@@ -29,7 +29,9 @@ export class AddWorkspaceIdToTotoFastInstanceCommand
|
||||
implements FastInstanceCommand
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "core"."toto" ADD "workspaceId" uuid`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."toto" ADD "workspaceId" uuid`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
@@ -79,9 +81,12 @@ Workspace commands run per-workspace logic across all active or suspended worksp
|
||||
@RegisteredWorkspaceCommand('1.22.0', 1780000002000)
|
||||
@Command({
|
||||
name: 'upgrade:1-22:backfill-standard-skills',
|
||||
description: 'Backfill standard skills for existing workspaces',
|
||||
description:
|
||||
'Backfill standard skills for existing workspaces',
|
||||
})
|
||||
export class BackfillStandardSkillsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
export class BackfillStandardSkillsCommand
|
||||
extends ActiveOrSuspendedWorkspaceCommandRunner
|
||||
{
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
// inject any services you need
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
This Bash script helps generate self-signed SSL certificates for local development. It uses OpenSSL to create a root certificate authority, a domain certificate, and configures them for local usage.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates a private key and root certificate.
|
||||
- Creates a signed certificate for a specified domain.
|
||||
- Adds the root certificate to the macOS keychain for trusted usage (macOS only).
|
||||
- Customizable with default values for easier use.
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenSSL
|
||||
|
||||
## Usage
|
||||
@@ -32,27 +30,24 @@ To generate certificates using the default values:
|
||||
#### Examples:
|
||||
|
||||
1. **Using Default Values**:
|
||||
|
||||
```sh
|
||||
./script.sh
|
||||
```
|
||||
```sh
|
||||
./script.sh
|
||||
```
|
||||
|
||||
2. **Custom Domain Name**:
|
||||
|
||||
```sh
|
||||
./script.sh example.com
|
||||
```
|
||||
```sh
|
||||
./script.sh example.com
|
||||
```
|
||||
|
||||
3. **Custom Domain Name and Root Certificate Name**:
|
||||
|
||||
```sh
|
||||
./script.sh example.com customRootCertificate
|
||||
```
|
||||
```sh
|
||||
./script.sh example.com customRootCertificate
|
||||
```
|
||||
|
||||
4. **Custom Domain Name, Root Certificate Name, and Validity Days**:
|
||||
```sh
|
||||
./script.sh example.com customRootCertificate 398
|
||||
```
|
||||
```sh
|
||||
./script.sh example.com customRootCertificate 398
|
||||
```
|
||||
|
||||
## Script Details
|
||||
|
||||
@@ -76,4 +71,4 @@ The generated files are stored in `~/certs/{domain}`:
|
||||
## Notes
|
||||
|
||||
- If running on non-macOS systems, you'll need to manually add the root certificate to your trusted certificate store.
|
||||
- Ensure that OpenSSL is installed and available in your PATH.
|
||||
- Ensure that OpenSSL is installed and available in your PATH.
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
import { QueryRunner } from 'typeorm';
|
||||
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
|
||||
|
||||
@RegisteredInstanceCommand('2.8.0', 1779387505162)
|
||||
export class AddLogoToApplicationRegistrationFastInstanceCommand
|
||||
implements FastInstanceCommand
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."applicationRegistration" ADD "logo" text',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."applicationRegistration" ADD "logoFileId" uuid',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "UQ_796819fb23559c233e6ebd49f34" UNIQUE ("logoFileId")',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "FK_796819fb23559c233e6ebd49f34" FOREIGN KEY ("logoFileId") REFERENCES "core"."file"("id") ON DELETE SET NULL ON UPDATE NO ACTION',
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT "FK_796819fb23559c233e6ebd49f34"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT "UQ_796819fb23559c233e6ebd49f34"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."applicationRegistration" DROP COLUMN "logoFileId"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."applicationRegistration" DROP COLUMN "logo"',
|
||||
);
|
||||
}
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
import { DataSource, QueryRunner } from 'typeorm';
|
||||
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
|
||||
|
||||
@RegisteredInstanceCommand('2.8.0', 1779387601428, { type: 'slow' })
|
||||
export class BackfillApplicationRegistrationLogoSlowInstanceCommand
|
||||
implements SlowInstanceCommand
|
||||
{
|
||||
async runDataMigration(dataSource: DataSource): Promise<void> {
|
||||
await dataSource.query(`
|
||||
UPDATE "core"."applicationRegistration"
|
||||
SET "logo" = manifest->'application'->>'logoUrl'
|
||||
WHERE manifest IS NOT NULL
|
||||
AND manifest->'application'->>'logoUrl' IS NOT NULL
|
||||
AND "logo" IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async up(_queryRunner: QueryRunner): Promise<void> {}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {}
|
||||
}
|
||||
+7
@@ -1,10 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { DropChannelStandardObjectsCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1798000050000-drop-channel-standard-objects.command';
|
||||
import { BackfillRelationJoinColumnIndexesCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1798100000000-backfill-relation-join-column-indexes.command';
|
||||
import { GateDefaultCommandMenuItemsByPermissionFlagCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1798100010000-gate-default-command-menu-items-by-permission-flag.command';
|
||||
import { RestoreChannelAssociationScalarFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1798100020000-restore-channel-association-scalar-field-metadata.command';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
|
||||
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
|
||||
@@ -12,8 +16,10 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
@Module({
|
||||
imports: [
|
||||
ApplicationModule,
|
||||
TypeOrmModule.forFeature([FieldMetadataEntity]),
|
||||
WorkspaceCacheModule,
|
||||
WorkspaceIteratorModule,
|
||||
WorkspaceMetadataVersionModule,
|
||||
WorkspaceMigrationModule,
|
||||
WorkspaceSchemaManagerModule,
|
||||
],
|
||||
@@ -21,6 +27,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
DropChannelStandardObjectsCommand,
|
||||
BackfillRelationJoinColumnIndexesCommand,
|
||||
GateDefaultCommandMenuItemsByPermissionFlagCommand,
|
||||
RestoreChannelAssociationScalarFieldMetadataCommand,
|
||||
],
|
||||
})
|
||||
export class V2_8_UpgradeVersionCommandModule {}
|
||||
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import { Command } from 'nest-commander';
|
||||
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
|
||||
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
|
||||
import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util';
|
||||
import { getMetadataSerializedRelationNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-serialized-relation-names.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { getWorkspaceSchemaContextForMigration } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/get-workspace-schema-context-for-migration.util';
|
||||
|
||||
// 2-8 dropped calendarChannel/messageChannel/messageFolder standard objects, which
|
||||
// cascade-deleted the relation field metadata that owned the join columns on the
|
||||
// surviving association objects. The physical columns + their data survived, but the
|
||||
// replacement standard scalar field metadata was never created for existing workspaces,
|
||||
// so TwentyORM builds the association entity without the join field and every calendar /
|
||||
// message import crashes with `Property "<channel>Id" was not found`.
|
||||
// This re-registers the scalar field metadata over the existing physical columns.
|
||||
type ScalarFieldToRestore = {
|
||||
objectUniversalIdentifier: string;
|
||||
fieldUniversalIdentifier: string;
|
||||
fieldName: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
isNullable: boolean;
|
||||
};
|
||||
|
||||
const SCALAR_FIELDS_TO_RESTORE: ScalarFieldToRestore[] = [
|
||||
{
|
||||
objectUniversalIdentifier: '20202020-491b-4aaa-9825-afd1bae6ae00',
|
||||
fieldUniversalIdentifier: '20202020-93ee-4da4-8d58-0282c4a9cb7d',
|
||||
fieldName: 'calendarChannelId',
|
||||
label: 'Channel ID',
|
||||
description: 'Channel ID',
|
||||
icon: 'IconCalendar',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier: '20202020-ad1e-4127-bccb-d83ae04d2ccb',
|
||||
fieldUniversalIdentifier: '20202020-b658-408f-bd46-3bd2d15d7e52',
|
||||
fieldName: 'messageChannelId',
|
||||
label: 'Message Channel Id',
|
||||
description: 'Message Channel Id',
|
||||
icon: 'IconHash',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier: '20202020-a1b0-40b0-8ab0-5b6c7d8e9f0a',
|
||||
fieldUniversalIdentifier: 'b3369d31-3856-4a7a-b007-ee353918127c',
|
||||
fieldName: 'messageFolderId',
|
||||
label: 'Message Folder',
|
||||
description: 'Message Folder',
|
||||
icon: 'IconFolder',
|
||||
isNullable: false,
|
||||
},
|
||||
];
|
||||
|
||||
@RegisteredWorkspaceCommand('2.8.0', 1798100020000)
|
||||
@Command({
|
||||
name: 'upgrade:2-8:restore-channel-association-scalar-field-metadata',
|
||||
description:
|
||||
'Re-register the calendarChannelId/messageChannelId/messageFolderId scalar field metadata on the surviving association objects, over their already-existing physical columns, for workspaces where 2-8 drop-channel-standard-objects cascade-removed them',
|
||||
})
|
||||
export class RestoreChannelAssociationScalarFieldMetadataCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
|
||||
@InjectRepository(FieldMetadataEntity)
|
||||
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
dataSource,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
if (!isDefined(dataSource)) {
|
||||
this.logger.log(`No data source for workspace ${workspaceId}, skipping`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatObjectMetadataMaps',
|
||||
'flatFieldMetadataMaps',
|
||||
]);
|
||||
|
||||
// Only fields whose object still exists and whose scalar metadata is absent.
|
||||
// For already-healthy workspaces this is empty, so no schema query runs below.
|
||||
const candidates = SCALAR_FIELDS_TO_RESTORE.flatMap((fieldToRestore) => {
|
||||
const flatObjectMetadata =
|
||||
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
|
||||
flatEntityMaps: flatObjectMetadataMaps,
|
||||
universalIdentifier: fieldToRestore.objectUniversalIdentifier,
|
||||
});
|
||||
|
||||
if (!isDefined(flatObjectMetadata)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existingFlatFieldMetadata =
|
||||
findFlatEntityByUniversalIdentifier<FlatFieldMetadata>({
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
universalIdentifier: fieldToRestore.fieldUniversalIdentifier,
|
||||
});
|
||||
|
||||
if (isDefined(existingFlatFieldMetadata)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { schemaName, tableName } = getWorkspaceSchemaContextForMigration({
|
||||
workspaceId,
|
||||
objectMetadata: flatObjectMetadata,
|
||||
});
|
||||
|
||||
return [{ fieldToRestore, flatObjectMetadata, schemaName, tableName }];
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
this.logger.log(
|
||||
`No channel association scalar field metadata to restore for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const existingColumnKeys = await this.getExistingColumnKeys({
|
||||
dataSource,
|
||||
tableColumnPairs: candidates.map(({ tableName, fieldToRestore }) => ({
|
||||
tableName,
|
||||
columnName: fieldToRestore.fieldName,
|
||||
})),
|
||||
schemaName: candidates[0].schemaName,
|
||||
});
|
||||
|
||||
const fieldMetadataRowsToInsert: QueryDeepPartialEntity<FieldMetadataEntity>[] =
|
||||
[];
|
||||
|
||||
for (const {
|
||||
fieldToRestore,
|
||||
flatObjectMetadata,
|
||||
tableName,
|
||||
} of candidates) {
|
||||
if (!existingColumnKeys.has(`${tableName}.${fieldToRestore.fieldName}`)) {
|
||||
this.logger.warn(
|
||||
`Physical column ${tableName}.${fieldToRestore.fieldName} missing for workspace ${workspaceId} - skipping metadata-only restore (needs schema repair)`,
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
fieldMetadataRowsToInsert.push({
|
||||
id: uuidv4(),
|
||||
universalIdentifier: fieldToRestore.fieldUniversalIdentifier,
|
||||
objectMetadataId: flatObjectMetadata.id,
|
||||
workspaceId,
|
||||
type: FieldMetadataType.UUID,
|
||||
name: fieldToRestore.fieldName,
|
||||
label: fieldToRestore.label,
|
||||
description: fieldToRestore.description,
|
||||
icon: fieldToRestore.icon,
|
||||
isCustom: false,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
isNullable: fieldToRestore.isNullable,
|
||||
isUIReadOnly: true,
|
||||
isLabelSyncedWithName: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (fieldMetadataRowsToInsert.length === 0) {
|
||||
this.logger.log(
|
||||
`No channel association scalar field metadata to restore for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`${isDryRun ? '[DRY RUN] ' : ''}Restoring ${fieldMetadataRowsToInsert.length} scalar field metadata row(s) for workspace ${workspaceId}: ${fieldMetadataRowsToInsert.map((row) => row.name).join(', ')}`,
|
||||
);
|
||||
|
||||
if (isDryRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { twentyStandardFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
const fieldMetadataRowsWithApplication = fieldMetadataRowsToInsert.map(
|
||||
(row) => ({
|
||||
...row,
|
||||
applicationId: twentyStandardFlatApplication.id,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.fieldMetadataRepository.insert(fieldMetadataRowsWithApplication);
|
||||
|
||||
// Derive the affected cache keys from the fieldMetadata metadata name using
|
||||
// the same utility set the migration runner uses, so the flush scope stays
|
||||
// in sync with the canonical dependency graph automatically.
|
||||
const fieldMetadataRelatedNames = [
|
||||
'fieldMetadata',
|
||||
...getMetadataRelatedMetadataNames('fieldMetadata'),
|
||||
...getMetadataSerializedRelationNames('fieldMetadata'),
|
||||
] as const;
|
||||
const cacheKeysToFlush = [
|
||||
...new Set(fieldMetadataRelatedNames.map(getMetadataFlatEntityMapsKey)),
|
||||
];
|
||||
|
||||
await this.workspaceCacheService.flush(workspaceId, [
|
||||
...cacheKeysToFlush,
|
||||
'ORMEntityMetadatas',
|
||||
'graphQLResolverNameMap',
|
||||
]);
|
||||
|
||||
await this.workspaceMetadataVersionService.incrementMetadataVersion(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Restored ${fieldMetadataRowsWithApplication.length} scalar field metadata row(s) for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Single information_schema lookup for all candidate columns in the workspace
|
||||
// schema. Returns a set of `tableName.columnName` keys that physically exist.
|
||||
private async getExistingColumnKeys({
|
||||
dataSource,
|
||||
schemaName,
|
||||
tableColumnPairs,
|
||||
}: {
|
||||
dataSource: NonNullable<RunOnWorkspaceArgs['dataSource']>;
|
||||
schemaName: string;
|
||||
tableColumnPairs: { tableName: string; columnName: string }[];
|
||||
}): Promise<Set<string>> {
|
||||
const tableNames = [
|
||||
...new Set(tableColumnPairs.map(({ tableName }) => tableName)),
|
||||
];
|
||||
const columnNames = [
|
||||
...new Set(tableColumnPairs.map(({ columnName }) => columnName)),
|
||||
];
|
||||
|
||||
// The workspace data source blocks raw query(); use a query runner like sibling
|
||||
// upgrade commands do for direct schema access.
|
||||
const queryRunner = dataSource.createQueryRunner();
|
||||
|
||||
try {
|
||||
await queryRunner.connect();
|
||||
|
||||
const rows: { table_name: string; column_name: string }[] =
|
||||
await queryRunner.query(
|
||||
`SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1
|
||||
AND table_name = ANY($2)
|
||||
AND column_name = ANY($3)`,
|
||||
[schemaName, tableNames, columnNames],
|
||||
);
|
||||
|
||||
return new Set(rows.map((row) => `${row.table_name}.${row.column_name}`));
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { DataSource, IsNull, QueryRunner } from 'typeorm';
|
||||
|
||||
import {
|
||||
KeyValuePairEntity,
|
||||
KeyValuePairType,
|
||||
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
|
||||
import { aiModelPreferencesSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.schema';
|
||||
|
||||
const NEW_KEYS = [
|
||||
'AI_MODELS_DEFAULT_FAST',
|
||||
'AI_MODELS_DEFAULT_SMART',
|
||||
'AI_MODELS_DEFAULT_RECOMMENDED',
|
||||
'AI_MODELS_DEFAULT_DISABLED',
|
||||
] as const;
|
||||
|
||||
const PREFERENCE_KEY_MAP = {
|
||||
AI_MODELS_DEFAULT_FAST: 'defaultFastModels',
|
||||
AI_MODELS_DEFAULT_SMART: 'defaultSmartModels',
|
||||
AI_MODELS_DEFAULT_RECOMMENDED: 'recommendedModels',
|
||||
AI_MODELS_DEFAULT_DISABLED: 'disabledModels',
|
||||
} as const;
|
||||
|
||||
@RegisteredInstanceCommand('2.9.0', 1799000010000, { type: 'slow' })
|
||||
export class MigrateAiModelPreferencesSlowInstanceCommand
|
||||
implements SlowInstanceCommand
|
||||
{
|
||||
private readonly logger = new Logger(
|
||||
MigrateAiModelPreferencesSlowInstanceCommand.name,
|
||||
);
|
||||
|
||||
async runDataMigration(dataSource: DataSource): Promise<void> {
|
||||
const keyValuePairRepository = dataSource.getRepository(KeyValuePairEntity);
|
||||
|
||||
const existingRow = await keyValuePairRepository.findOne({
|
||||
where: {
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
key: 'AI_MODEL_PREFERENCES',
|
||||
userId: IsNull(),
|
||||
workspaceId: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(existingRow)) {
|
||||
this.logger.log(
|
||||
'No server-level AI_MODEL_PREFERENCES row found, skipping',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const parseResult = aiModelPreferencesSchema.safeParse(existingRow.value);
|
||||
|
||||
if (!parseResult.success) {
|
||||
this.logger.error(
|
||||
`Failed to parse server-level AI_MODEL_PREFERENCES: ${parseResult.error.message}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const prefs = parseResult.data;
|
||||
|
||||
this.logger.log('Migrating server-level AI_MODEL_PREFERENCES');
|
||||
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const transactionalRepository = manager.getRepository(KeyValuePairEntity);
|
||||
|
||||
for (const newKey of NEW_KEYS) {
|
||||
const prefField = PREFERENCE_KEY_MAP[newKey];
|
||||
const value = prefs[prefField];
|
||||
|
||||
if (!isDefined(value) || value.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingNewKeyCount = await transactionalRepository.count({
|
||||
where: {
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
key: newKey,
|
||||
userId: IsNull(),
|
||||
workspaceId: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
if (existingNewKeyCount > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await transactionalRepository.insert({
|
||||
key: newKey,
|
||||
value: value as unknown as JSON,
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
});
|
||||
}
|
||||
|
||||
await transactionalRepository.delete({ id: existingRow.id });
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
'Migrated server-level AI_MODEL_PREFERENCES to 4 individual vars',
|
||||
);
|
||||
}
|
||||
|
||||
public async up(_queryRunner: QueryRunner): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { MigrateAiModelPreferencesCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000000000-migrate-ai-model-preferences.command';
|
||||
import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([KeyValuePairEntity]),
|
||||
WorkspaceIteratorModule,
|
||||
],
|
||||
providers: [MigrateAiModelPreferencesCommand],
|
||||
})
|
||||
export class V2_9_UpgradeVersionCommandModule {}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Command } from 'nest-commander';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { isArray, isDefined } from 'class-validator';
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import {
|
||||
KeyValuePairEntity,
|
||||
KeyValuePairType,
|
||||
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { aiModelPreferencesSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.schema';
|
||||
|
||||
const NEW_KEYS = [
|
||||
'AI_MODELS_DEFAULT_FAST',
|
||||
'AI_MODELS_DEFAULT_SMART',
|
||||
'AI_MODELS_DEFAULT_RECOMMENDED',
|
||||
'AI_MODELS_DEFAULT_DISABLED',
|
||||
] as const;
|
||||
|
||||
const PREFERENCE_KEY_MAP = {
|
||||
AI_MODELS_DEFAULT_FAST: 'defaultFastModels',
|
||||
AI_MODELS_DEFAULT_SMART: 'defaultSmartModels',
|
||||
AI_MODELS_DEFAULT_RECOMMENDED: 'recommendedModels',
|
||||
AI_MODELS_DEFAULT_DISABLED: 'disabledModels',
|
||||
} as const;
|
||||
|
||||
@RegisteredWorkspaceCommand('2.9.0', 1799000000000)
|
||||
@Command({
|
||||
name: 'upgrade:2-9:migrate-ai-model-preferences',
|
||||
description:
|
||||
'Migrate AI_MODEL_PREFERENCES config var to the four individual AI_MODELS_DEFAULT_* vars, per workspace',
|
||||
})
|
||||
export class MigrateAiModelPreferencesCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
@InjectRepository(KeyValuePairEntity)
|
||||
private readonly keyValuePairRepository: Repository<KeyValuePairEntity>,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
const existingPreferencesRow = await this.keyValuePairRepository.findOne({
|
||||
where: {
|
||||
key: 'AI_MODEL_PREFERENCES',
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
workspaceId,
|
||||
userId: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
if (existingPreferencesRow === null) {
|
||||
this.logger.log(
|
||||
`No AI_MODEL_PREFERENCES row found for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const parseResult = aiModelPreferencesSchema.safeParse(
|
||||
existingPreferencesRow.value,
|
||||
);
|
||||
|
||||
if (!parseResult.success) {
|
||||
this.logger.error(
|
||||
`Failed to parse AI_MODEL_PREFERENCES for workspace ${workspaceId}: ${parseResult.error.message}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const prefs = parseResult.data;
|
||||
|
||||
this.logger.log(
|
||||
`${isDryRun ? '[DRY RUN] ' : ''}Migrating AI_MODEL_PREFERENCES for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
for (const newKey of NEW_KEYS) {
|
||||
const prefField = PREFERENCE_KEY_MAP[newKey];
|
||||
if (
|
||||
!isDefined(prefField) ||
|
||||
(isArray(prefs[prefField]) && prefs[prefField].length === 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const value = prefs[prefField];
|
||||
|
||||
if (isDryRun) {
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would insert ${newKey} = ${JSON.stringify(value)} for workspace ${workspaceId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingNewKeyRow = await this.keyValuePairRepository.findOne({
|
||||
where: {
|
||||
key: newKey,
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
workspaceId,
|
||||
userId: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
if (existingNewKeyRow !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.keyValuePairRepository.insert({
|
||||
key: newKey,
|
||||
value: value as unknown as JSON,
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
workspaceId,
|
||||
userId: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isDryRun) {
|
||||
await this.keyValuePairRepository.delete({
|
||||
id: existingPreferencesRow.id,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Migrated AI_MODEL_PREFERENCES to 4 individual vars for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -53,10 +53,9 @@ import { DropPostgresCredentialsTableFastInstanceCommand } from 'src/database/co
|
||||
import { AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000005000-add-relation-target-field-metadata-id-to-view-filter';
|
||||
import { AddChannelSyncStageIndexesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000010000-add-channel-sync-stage-indexes';
|
||||
import { FinalizeRolePermissionFlagCutoverFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-7/2-7-instance-command-fast-1779600000000-finalize-role-permission-flag-cutover';
|
||||
import { AddLogoToApplicationRegistrationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1779387505162-add-logo-to-application-registration';
|
||||
import { BackfillApplicationRegistrationLogoSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-slow-1779387601428-backfill-application-registration-logo';
|
||||
import { AddSubFieldNameToIndexFieldMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1798200000000-add-sub-field-name-to-index-field-metadata';
|
||||
import { DropFieldMetadataIsUniqueColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1798300000000-drop-field-metadata-is-unique-column';
|
||||
import { MigrateAiModelPreferencesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences';
|
||||
|
||||
export const INSTANCE_COMMANDS = [
|
||||
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
|
||||
@@ -112,8 +111,7 @@ export const INSTANCE_COMMANDS = [
|
||||
AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand,
|
||||
AddChannelSyncStageIndexesFastInstanceCommand,
|
||||
FinalizeRolePermissionFlagCutoverFastInstanceCommand,
|
||||
AddLogoToApplicationRegistrationFastInstanceCommand,
|
||||
BackfillApplicationRegistrationLogoSlowInstanceCommand,
|
||||
AddSubFieldNameToIndexFieldMetadataFastInstanceCommand,
|
||||
DropFieldMetadataIsUniqueColumnFastInstanceCommand,
|
||||
MigrateAiModelPreferencesSlowInstanceCommand,
|
||||
];
|
||||
|
||||
+2
@@ -11,6 +11,7 @@ import { V2_4_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
|
||||
import { V2_5_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-5/2-5-upgrade-version-command.module';
|
||||
import { V2_7_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-7/2-7-upgrade-version-command.module';
|
||||
import { V2_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-8/2-8-upgrade-version-command.module';
|
||||
import { V2_9_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -25,6 +26,7 @@ import { V2_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
|
||||
V2_5_UpgradeVersionCommandModule,
|
||||
V2_7_UpgradeVersionCommandModule,
|
||||
V2_8_UpgradeVersionCommandModule,
|
||||
V2_9_UpgradeVersionCommandModule,
|
||||
],
|
||||
})
|
||||
export class WorkspaceCommandProviderModule {}
|
||||
|
||||
-12
@@ -2,22 +2,10 @@ import { Context, Parent, ResolveField } from '@nestjs/graphql';
|
||||
|
||||
import { AdminResolver } from 'src/engine/api/graphql/graphql-config/decorators/admin-resolver.decorator';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationLogoService } from 'src/engine/core-modules/application/application-registration/application-registration-logo.service';
|
||||
import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
|
||||
|
||||
@AdminResolver(() => ApplicationRegistrationEntity)
|
||||
export class AdminPanelApplicationRegistrationResolver {
|
||||
constructor(
|
||||
private readonly applicationRegistrationLogoService: ApplicationRegistrationLogoService,
|
||||
) {}
|
||||
|
||||
@ResolveField(() => String, { nullable: true })
|
||||
async logo(
|
||||
@Parent() registration: ApplicationRegistrationEntity,
|
||||
): Promise<string | null> {
|
||||
return this.applicationRegistrationLogoService.resolveLogoUrl(registration);
|
||||
}
|
||||
|
||||
@ResolveField(() => Boolean)
|
||||
async isConfigured(
|
||||
@Parent() registration: ApplicationRegistrationEntity,
|
||||
|
||||
+22
-6
@@ -20,9 +20,9 @@ import { DevelopmentApplicationDTO } from 'src/engine/core-modules/application/a
|
||||
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/application-development/dtos/generate-application-token.input';
|
||||
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/application-development/dtos/upload-application-file.input';
|
||||
import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/application-development/dtos/workspace-migration.dto';
|
||||
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
|
||||
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
|
||||
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
|
||||
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/application-oauth/dtos/application-token-pair.dto';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
@@ -34,10 +34,12 @@ import {
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
|
||||
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
@@ -67,6 +69,7 @@ export class ApplicationDevelopmentResolver {
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly sdkClientGenerationService: SdkClientGenerationService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly throttlerService: ThrottlerService,
|
||||
) {}
|
||||
|
||||
@@ -170,7 +173,12 @@ export class ApplicationDevelopmentResolver {
|
||||
});
|
||||
}
|
||||
|
||||
await this.syncRegistrationMetadata(applicationRegistrationId, manifest);
|
||||
await this.syncRegistrationMetadata(
|
||||
applicationRegistrationId,
|
||||
manifest,
|
||||
workspaceId,
|
||||
application.id,
|
||||
);
|
||||
|
||||
return {
|
||||
applicationUniversalIdentifier:
|
||||
@@ -184,7 +192,7 @@ export class ApplicationDevelopmentResolver {
|
||||
async uploadApplicationFile(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, mimetype }: FileUpload,
|
||||
{ createReadStream }: FileUpload,
|
||||
@Args()
|
||||
{
|
||||
applicationUniversalIdentifier,
|
||||
@@ -242,7 +250,6 @@ export class ApplicationDevelopmentResolver {
|
||||
|
||||
return await this.fileStorageService.writeFile({
|
||||
sourceFile: buffer,
|
||||
mimeType: mimetype,
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
@@ -284,11 +291,20 @@ export class ApplicationDevelopmentResolver {
|
||||
private async syncRegistrationMetadata(
|
||||
applicationRegistrationId: string,
|
||||
manifest: ApplicationInput['manifest'],
|
||||
workspaceId: string,
|
||||
applicationId: string,
|
||||
): Promise<void> {
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
|
||||
const manifestWithResolvedUrls = resolveManifestAssetUrls(
|
||||
manifest,
|
||||
(filePath) =>
|
||||
`${serverUrl}/public-assets/${workspaceId}/${applicationId}/${filePath}`,
|
||||
);
|
||||
|
||||
await this.applicationRegistrationService.updateFromManifest(
|
||||
applicationRegistrationId,
|
||||
manifest,
|
||||
ApplicationRegistrationSourceType.LOCAL,
|
||||
manifestWithResolvedUrls,
|
||||
);
|
||||
|
||||
if (manifest.application.serverVariables) {
|
||||
|
||||
+1
-6
@@ -9,7 +9,6 @@ import { ApplicationManifestModule } from 'src/engine/core-modules/application/a
|
||||
import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.module';
|
||||
import { ApplicationInstallResolver } from 'src/engine/core-modules/application/application-install/application-install.resolver';
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
import { ApplicationLogoResolver } from 'src/engine/core-modules/application/application-install/application-logo.resolver';
|
||||
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
||||
import { LogicFunctionModule } from 'src/engine/core-modules/logic-function/logic-function.module';
|
||||
import { SdkClientModule } from 'src/engine/core-modules/sdk-client/sdk-client.module';
|
||||
@@ -30,11 +29,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
FileStorageModule,
|
||||
WorkspaceCacheModule,
|
||||
],
|
||||
providers: [
|
||||
ApplicationInstallResolver,
|
||||
ApplicationInstallService,
|
||||
ApplicationLogoResolver,
|
||||
],
|
||||
providers: [ApplicationInstallResolver, ApplicationInstallService],
|
||||
exports: [ApplicationInstallService],
|
||||
})
|
||||
export class ApplicationInstallModule {}
|
||||
|
||||
+1
-10
@@ -5,8 +5,6 @@ import { promises as fs } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
import semver from 'semver';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { Manifest } from 'twenty-shared/application';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -493,15 +491,8 @@ export class ApplicationInstallService {
|
||||
);
|
||||
}
|
||||
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
file: content,
|
||||
filename: relativePath,
|
||||
});
|
||||
const sanitizedContent = sanitizeFile({ file: content, ext, mimeType });
|
||||
|
||||
await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedContent,
|
||||
mimeType,
|
||||
sourceFile: content,
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import { Parent, ResolveField } from '@nestjs/graphql';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
|
||||
import { resolveApplicationLogoUrl } from 'src/engine/core-modules/application/utils/resolve-application-logo-url.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@MetadataResolver(() => ApplicationDTO)
|
||||
export class ApplicationLogoResolver {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
@ResolveField(() => String, { nullable: true })
|
||||
logo(
|
||||
@Parent() application: ApplicationDTO & { workspaceId?: string },
|
||||
): string | null {
|
||||
if (!application.workspaceId) {
|
||||
return application.logo ?? null;
|
||||
}
|
||||
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
|
||||
return resolveApplicationLogoUrl({
|
||||
logo: application.logo,
|
||||
serverUrl,
|
||||
workspaceId: application.workspaceId,
|
||||
applicationId: application.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
-5
@@ -36,11 +36,6 @@ export class MarketplaceAppDetailDTO {
|
||||
@Field({ nullable: true })
|
||||
latestAvailableVersion?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
logo?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@Field(() => Boolean)
|
||||
isListed: boolean;
|
||||
|
||||
+18
-1
@@ -3,6 +3,9 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service';
|
||||
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
|
||||
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceCatalogSyncService {
|
||||
@@ -11,6 +14,7 @@ export class MarketplaceCatalogSyncService {
|
||||
constructor(
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
async syncCatalog(): Promise<void> {
|
||||
@@ -55,13 +59,26 @@ export class MarketplaceCatalogSyncService {
|
||||
}
|
||||
: fetchedManifest;
|
||||
|
||||
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
|
||||
|
||||
const manifestWithResolvedUrls = resolveManifestAssetUrls(
|
||||
manifest,
|
||||
(filePath) =>
|
||||
buildRegistryCdnUrl({
|
||||
cdnBaseUrl,
|
||||
packageName: pkg.name,
|
||||
version: pkg.version,
|
||||
filePath,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.applicationRegistrationService.upsertFromCatalog({
|
||||
universalIdentifier,
|
||||
name: manifest.application.displayName ?? pkg.name,
|
||||
sourceType: ApplicationRegistrationSourceType.NPM,
|
||||
sourcePackage: pkg.name,
|
||||
latestAvailableVersion: pkg.version ?? null,
|
||||
manifest,
|
||||
manifest: manifestWithResolvedUrls,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
|
||||
+1
-25
@@ -12,11 +12,9 @@ import { ApplicationRegistrationService } from 'src/engine/core-modules/applicat
|
||||
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
|
||||
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
|
||||
import { MarketplaceAppDetailDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app-detail.dto';
|
||||
import { resolveApplicationRegistrationLogoUrl } from 'src/engine/core-modules/application/utils/resolve-application-registration-logo-url.util';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceQueryService {
|
||||
@@ -26,7 +24,6 @@ export class MarketplaceQueryService {
|
||||
constructor(
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {}
|
||||
@@ -99,17 +96,7 @@ export class MarketplaceQueryService {
|
||||
description: app?.description ?? '',
|
||||
author: `${app?.author ?? 'Unknown'}`,
|
||||
category: app?.category ?? '',
|
||||
logo:
|
||||
resolveApplicationRegistrationLogoUrl({
|
||||
logo:
|
||||
registration.logo ??
|
||||
registration.manifest?.application?.logoUrl ??
|
||||
null,
|
||||
sourceType: registration.sourceType,
|
||||
sourcePackage: registration.sourcePackage,
|
||||
latestAvailableVersion: registration.latestAvailableVersion,
|
||||
cdnBaseUrl: this.twentyConfigService.get('APP_REGISTRY_CDN_URL'),
|
||||
}) ?? undefined,
|
||||
logo: app?.logoUrl ?? undefined,
|
||||
sourcePackage: registration.sourcePackage ?? undefined,
|
||||
isFeatured: registration.isFeatured,
|
||||
};
|
||||
@@ -125,17 +112,6 @@ export class MarketplaceQueryService {
|
||||
sourceType: registration.sourceType,
|
||||
sourcePackage: registration.sourcePackage ?? undefined,
|
||||
latestAvailableVersion: registration.latestAvailableVersion ?? undefined,
|
||||
logo:
|
||||
resolveApplicationRegistrationLogoUrl({
|
||||
logo:
|
||||
registration.logo ??
|
||||
registration.manifest?.application?.logoUrl ??
|
||||
null,
|
||||
sourceType: registration.sourceType,
|
||||
sourcePackage: registration.sourcePackage,
|
||||
latestAvailableVersion: registration.latestAvailableVersion,
|
||||
cdnBaseUrl: this.twentyConfigService.get('APP_REGISTRY_CDN_URL'),
|
||||
}) ?? undefined,
|
||||
isListed: registration.isListed,
|
||||
isFeatured: registration.isFeatured,
|
||||
manifest: registration.manifest ?? undefined,
|
||||
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { resolveApplicationLogoUrl } from 'src/engine/core-modules/application/utils/resolve-application-logo-url.util';
|
||||
import { resolveApplicationRegistrationLogoUrl } from 'src/engine/core-modules/application/utils/resolve-application-registration-logo-url.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationRegistrationLogoService {
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
) {}
|
||||
|
||||
async resolveLogoUrl(
|
||||
registration: ApplicationRegistrationEntity,
|
||||
): Promise<string | null> {
|
||||
if (
|
||||
registration.sourceType === ApplicationRegistrationSourceType.TARBALL &&
|
||||
registration.logoFileId
|
||||
) {
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
|
||||
return `${serverUrl}/file/${FileFolder.AppTarball}/${registration.logoFileId}`;
|
||||
}
|
||||
|
||||
const logo =
|
||||
registration.logo ?? registration.manifest?.application?.logoUrl ?? null;
|
||||
|
||||
if (
|
||||
registration.sourceType === ApplicationRegistrationSourceType.LOCAL &&
|
||||
logo &&
|
||||
registration.ownerWorkspaceId
|
||||
) {
|
||||
const application =
|
||||
await this.applicationService.findByUniversalIdentifier({
|
||||
universalIdentifier: registration.universalIdentifier,
|
||||
workspaceId: registration.ownerWorkspaceId,
|
||||
});
|
||||
|
||||
if (application) {
|
||||
return resolveApplicationLogoUrl({
|
||||
logo,
|
||||
serverUrl: this.twentyConfigService.get('SERVER_URL'),
|
||||
workspaceId: registration.ownerWorkspaceId,
|
||||
applicationId: application.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return resolveApplicationRegistrationLogoUrl({
|
||||
logo,
|
||||
sourceType: registration.sourceType,
|
||||
sourcePackage: registration.sourcePackage,
|
||||
latestAvailableVersion: registration.latestAvailableVersion,
|
||||
cdnBaseUrl: this.twentyConfigService.get('APP_REGISTRY_CDN_URL'),
|
||||
});
|
||||
}
|
||||
}
|
||||
+3
-18
@@ -21,7 +21,6 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@@ -135,23 +134,9 @@ export class ApplicationRegistrationEntity {
|
||||
manifest: Manifest | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName:
|
||||
'2.8.0_AddLogoToApplicationRegistrationFastInstanceCommand_1779387505162',
|
||||
})
|
||||
logo: string | null;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName:
|
||||
'2.8.0_AddLogoToApplicationRegistrationFastInstanceCommand_1779387505162',
|
||||
})
|
||||
logoFileId: string | null;
|
||||
|
||||
@OneToOne(() => FileEntity, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'logoFileId' })
|
||||
logoFile: Relation<FileEntity> | null;
|
||||
get logoUrl(): string | null {
|
||||
return this.manifest?.application?.logoUrl ?? null;
|
||||
}
|
||||
|
||||
@OneToMany(
|
||||
() => ApplicationRegistrationVariableEntity,
|
||||
|
||||
-3
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationLogoService } from 'src/engine/core-modules/application/application-registration/application-registration-logo.service';
|
||||
import { ApplicationRegistrationResolver } from 'src/engine/core-modules/application/application-registration/application-registration.resolver';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { ApplicationRegistrationVariableModule } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.module';
|
||||
@@ -37,13 +36,11 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
|
||||
],
|
||||
providers: [
|
||||
ApplicationRegistrationService,
|
||||
ApplicationRegistrationLogoService,
|
||||
ApplicationRegistrationResolver,
|
||||
ApplicationTarballService,
|
||||
],
|
||||
exports: [
|
||||
ApplicationRegistrationService,
|
||||
ApplicationRegistrationLogoService,
|
||||
ApplicationRegistrationVariableModule,
|
||||
],
|
||||
})
|
||||
|
||||
-9
@@ -24,7 +24,6 @@ import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modu
|
||||
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input';
|
||||
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationLogoService } from 'src/engine/core-modules/application/application-registration/application-registration-logo.service';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { ApplicationTarballService } from 'src/engine/core-modules/application/application-registration/application-tarball.service';
|
||||
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
|
||||
@@ -65,7 +64,6 @@ import {
|
||||
export class ApplicationRegistrationResolver {
|
||||
constructor(
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationRegistrationLogoService: ApplicationRegistrationLogoService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly applicationTarballService: ApplicationTarballService,
|
||||
private readonly fileUrlService: FileUrlService,
|
||||
@@ -332,13 +330,6 @@ export class ApplicationRegistrationResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => String, { nullable: true })
|
||||
async logo(
|
||||
@Parent() registration: ApplicationRegistrationEntity,
|
||||
): Promise<string | null> {
|
||||
return this.applicationRegistrationLogoService.resolveLogoUrl(registration);
|
||||
}
|
||||
|
||||
@ResolveField(() => Boolean)
|
||||
async isConfigured(
|
||||
@Parent() registration: ApplicationRegistrationEntity,
|
||||
|
||||
+2
-15
@@ -16,8 +16,6 @@ import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { resolveApplicationRegistrationLogoUrl } from 'src/engine/core-modules/application/utils/resolve-application-registration-logo-url.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
|
||||
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
|
||||
import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
|
||||
@@ -41,7 +39,6 @@ export class ApplicationRegistrationService {
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
async findMany(
|
||||
@@ -107,6 +104,7 @@ export class ApplicationRegistrationService {
|
||||
): Promise<PublicApplicationRegistrationDTO | null> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { oAuthClientId: clientId },
|
||||
select: ['id', 'name', 'manifest', 'oAuthScopes'],
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
@@ -116,13 +114,7 @@ export class ApplicationRegistrationService {
|
||||
return {
|
||||
id: registration.id,
|
||||
name: registration.name,
|
||||
logo: resolveApplicationRegistrationLogoUrl({
|
||||
logo: registration.logo,
|
||||
sourceType: registration.sourceType,
|
||||
sourcePackage: registration.sourcePackage,
|
||||
latestAvailableVersion: registration.latestAvailableVersion,
|
||||
cdnBaseUrl: this.twentyConfigService.get('APP_REGISTRY_CDN_URL'),
|
||||
}),
|
||||
logoUrl: registration.manifest?.application?.logoUrl ?? null,
|
||||
websiteUrl: registration.manifest?.application?.websiteUrl ?? null,
|
||||
oAuthScopes: registration.oAuthScopes,
|
||||
};
|
||||
@@ -222,7 +214,6 @@ export class ApplicationRegistrationService {
|
||||
async updateFromManifest(
|
||||
applicationRegistrationId: string,
|
||||
manifest: Manifest,
|
||||
sourceType?: ApplicationRegistrationSourceType,
|
||||
): Promise<void> {
|
||||
const existing = await this.applicationRegistrationRepository.findOneOrFail(
|
||||
{ where: { id: applicationRegistrationId } },
|
||||
@@ -231,9 +222,7 @@ export class ApplicationRegistrationService {
|
||||
await this.applicationRegistrationRepository.save({
|
||||
...existing,
|
||||
name: manifest.application.displayName,
|
||||
logo: manifest.application.logoUrl ?? null,
|
||||
manifest,
|
||||
...(sourceType !== undefined && { sourceType }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -301,7 +290,6 @@ export class ApplicationRegistrationService {
|
||||
sourceType: params.sourceType,
|
||||
sourcePackage: params.sourcePackage,
|
||||
latestAvailableVersion: params.latestAvailableVersion,
|
||||
logo: params.manifest?.application?.logoUrl ?? null,
|
||||
manifest: params.manifest,
|
||||
isFeatured,
|
||||
});
|
||||
@@ -314,7 +302,6 @@ export class ApplicationRegistrationService {
|
||||
latestAvailableVersion: params.latestAvailableVersion,
|
||||
isListed: true,
|
||||
isFeatured,
|
||||
logo: params.manifest?.application?.logoUrl ?? null,
|
||||
manifest: params.manifest,
|
||||
oAuthClientId: v4(),
|
||||
oAuthRedirectUris: [],
|
||||
|
||||
+1
-71
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join, resolve, sep } from 'path';
|
||||
import { join } from 'path';
|
||||
|
||||
import semver from 'semver';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
@@ -28,8 +28,6 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import type { ApplicationManifest } from 'twenty-shared/application';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationTarballService {
|
||||
@@ -192,7 +190,6 @@ export class ApplicationTarballService {
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: params.tarballBuffer,
|
||||
resourcePath: `${appRegistration.id}/app.tar.gz`,
|
||||
mimeType: 'application/gzip',
|
||||
fileFolder: FileFolder.AppTarball,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
@@ -204,22 +201,10 @@ export class ApplicationTarballService {
|
||||
},
|
||||
});
|
||||
|
||||
const logoFileId = await this.uploadLogoFromTarball({
|
||||
contentDir,
|
||||
logoPath: manifest.application?.logoUrl ?? null,
|
||||
registrationId: appRegistration.id,
|
||||
existingLogoFileId: appRegistration.logoFileId ?? undefined,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
workspaceId: params.ownerWorkspaceId,
|
||||
});
|
||||
|
||||
await this.appRegistrationRepository.update(appRegistration.id, {
|
||||
sourceType: ApplicationRegistrationSourceType.TARBALL,
|
||||
tarballFileId: savedFile.id,
|
||||
name: manifest.application?.displayName ?? 'Unknown App',
|
||||
logo: manifest.application?.logoUrl ?? null,
|
||||
logoFileId,
|
||||
manifest,
|
||||
latestAvailableVersion: packageJson?.version ?? null,
|
||||
isListed: false,
|
||||
@@ -245,59 +230,4 @@ export class ApplicationTarballService {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadLogoFromTarball(params: {
|
||||
contentDir: string;
|
||||
logoPath: string | null;
|
||||
registrationId: string;
|
||||
existingLogoFileId?: string;
|
||||
applicationUniversalIdentifier: string;
|
||||
workspaceId: string;
|
||||
}): Promise<string | null> {
|
||||
if (
|
||||
!params.logoPath ||
|
||||
params.logoPath.startsWith('http://') ||
|
||||
params.logoPath.startsWith('https://')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const absolutePath = resolve(params.contentDir, params.logoPath);
|
||||
const safePrefx = params.contentDir.endsWith(sep)
|
||||
? params.contentDir
|
||||
: params.contentDir + sep;
|
||||
|
||||
if (!absolutePath.startsWith(safePrefx)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(absolutePath);
|
||||
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
file: content,
|
||||
filename: params.logoPath,
|
||||
});
|
||||
const sanitizedContent = sanitizeFile({ file: content, ext, mimeType });
|
||||
|
||||
const savedLogoFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedContent,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.AppTarball,
|
||||
applicationUniversalIdentifier: params.applicationUniversalIdentifier,
|
||||
workspaceId: params.workspaceId,
|
||||
resourcePath: `${params.registrationId}/logo${ext}`,
|
||||
fileId: params.existingLogoFileId ?? v4(),
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
return savedLogoFile.id;
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
`Failed to process logo from tarball: ${params.logoPath}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -21,5 +21,5 @@ export class ApplicationRegistrationSummaryDTO {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field(() => String, { nullable: true })
|
||||
logo?: string | null;
|
||||
logoUrl?: string | null;
|
||||
}
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export class PublicApplicationRegistrationDTO {
|
||||
name: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
logo: string | null;
|
||||
logoUrl: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
websiteUrl: string | null;
|
||||
|
||||
@@ -411,7 +411,6 @@ export class ApplicationService {
|
||||
|
||||
const packageJsonFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: defaultPackageFields.packageJsonContent,
|
||||
mimeType: undefined,
|
||||
fileFolder: FileFolder.Dependencies,
|
||||
applicationUniversalIdentifier: application.universalIdentifier,
|
||||
workspaceId: application.workspaceId,
|
||||
@@ -422,7 +421,6 @@ export class ApplicationService {
|
||||
|
||||
const yarnLockFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: defaultPackageFields.yarnLockContent,
|
||||
mimeType: undefined,
|
||||
fileFolder: FileFolder.Dependencies,
|
||||
applicationUniversalIdentifier: application.universalIdentifier,
|
||||
workspaceId: application.workspaceId,
|
||||
|
||||
+1
-3
@@ -14,9 +14,8 @@ export const fromFlatApplicationToApplicationDto = ({
|
||||
availablePackages,
|
||||
universalIdentifier,
|
||||
version,
|
||||
workspaceId,
|
||||
settingsCustomTabFrontComponentId,
|
||||
}: FlatApplication): ApplicationDTO & { workspaceId: string } => {
|
||||
}: FlatApplication): ApplicationDTO => {
|
||||
return {
|
||||
canBeUninstalled,
|
||||
description: description ?? undefined,
|
||||
@@ -31,7 +30,6 @@ export const fromFlatApplicationToApplicationDto = ({
|
||||
availablePackages: availablePackages ?? {},
|
||||
universalIdentifier,
|
||||
version: version ?? undefined,
|
||||
workspaceId,
|
||||
settingsCustomTabFrontComponentId:
|
||||
settingsCustomTabFrontComponentId ?? undefined,
|
||||
};
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
import { buildPublicAssetUrl } from 'twenty-shared/utils';
|
||||
|
||||
export const resolveApplicationLogoUrl = ({
|
||||
logo,
|
||||
serverUrl,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
}: {
|
||||
logo: string | null | undefined;
|
||||
serverUrl: string;
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
}): string | null => {
|
||||
return buildPublicAssetUrl({
|
||||
path: logo,
|
||||
serverUrl,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
});
|
||||
};
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
|
||||
|
||||
const isAbsoluteUrl = (url: string): boolean =>
|
||||
url.startsWith('http://') || url.startsWith('https://');
|
||||
|
||||
export const resolveApplicationRegistrationLogoUrl = ({
|
||||
logo,
|
||||
sourceType,
|
||||
sourcePackage,
|
||||
latestAvailableVersion,
|
||||
cdnBaseUrl,
|
||||
}: {
|
||||
logo: string | null | undefined;
|
||||
sourceType: ApplicationRegistrationSourceType;
|
||||
sourcePackage: string | null;
|
||||
latestAvailableVersion: string | null;
|
||||
cdnBaseUrl: string;
|
||||
}): string | null => {
|
||||
if (!logo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAbsoluteUrl(logo)) {
|
||||
return logo;
|
||||
}
|
||||
|
||||
if (
|
||||
sourceType === ApplicationRegistrationSourceType.NPM &&
|
||||
sourcePackage &&
|
||||
latestAvailableVersion
|
||||
) {
|
||||
return buildRegistryCdnUrl({
|
||||
cdnBaseUrl,
|
||||
packageName: sourcePackage,
|
||||
version: latestAvailableVersion,
|
||||
filePath: logo,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { FileStorageExceptionFilter } from 'src/engine/core-modules/file-storage/file-storage-exception-filter';
|
||||
import {
|
||||
FileStorageException,
|
||||
FileStorageExceptionCode,
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
import {
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
describe('FileStorageExceptionFilter', () => {
|
||||
const filter = new FileStorageExceptionFilter();
|
||||
|
||||
it.each([
|
||||
{
|
||||
code: FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
expectedError: UserInputError,
|
||||
},
|
||||
{
|
||||
code: FileStorageExceptionCode.ACCESS_DENIED,
|
||||
expectedError: ForbiddenError,
|
||||
},
|
||||
{
|
||||
code: FileStorageExceptionCode.FILE_NOT_FOUND,
|
||||
expectedError: NotFoundError,
|
||||
},
|
||||
])(
|
||||
'should map $code to the expected GraphQL error',
|
||||
({ code, expectedError }) => {
|
||||
const exception = new FileStorageException('test message', code);
|
||||
|
||||
expect(() => filter.catch(exception)).toThrow(expectedError);
|
||||
},
|
||||
);
|
||||
});
|
||||
+135
-2
@@ -381,7 +381,6 @@ describe('FileStorageService', () => {
|
||||
...validResourceIdentifier,
|
||||
resourcePath: context.resourcePath,
|
||||
sourceFile: Buffer.from('malicious'),
|
||||
mimeType: 'application/javascript',
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
@@ -396,7 +395,6 @@ describe('FileStorageService', () => {
|
||||
await service.writeFile({
|
||||
...validResourceIdentifier,
|
||||
sourceFile: Buffer.from('valid content'),
|
||||
mimeType: 'application/javascript',
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
@@ -406,6 +404,141 @@ describe('FileStorageService', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('magic-byte backstop', () => {
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
]);
|
||||
const textBuffer = Buffer.from('Hello, world!', 'utf-8');
|
||||
|
||||
it('should reject buffer whose magic bytes do not match the path extension', async () => {
|
||||
await expect(
|
||||
service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
resourcePath: 'assets/fake-image.png',
|
||||
sourceFile: textBuffer,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
});
|
||||
|
||||
expect(mockDriver.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept buffer whose magic bytes match the path extension and persist the bytes-derived mime', async () => {
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
resourcePath: 'assets/photo.png',
|
||||
sourceFile: pngBuffer,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
expect(mockDriver.writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mimeType: 'image/png' }),
|
||||
);
|
||||
expect(mockFileRepository.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mimeType: 'image/png' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist application/typescript for a TypeScript source string (TWENTY_MIME_POLICY)', async () => {
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.Source,
|
||||
resourcePath: 'src/index.tsx',
|
||||
sourceFile: 'export const App = () => null;',
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
expect(mockDriver.writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mimeType: 'application/typescript' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist application/typescript for a TypeScript source buffer (policy beats mrmime collision)', async () => {
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.Source,
|
||||
resourcePath: 'src/handler.ts',
|
||||
sourceFile: Buffer.from(
|
||||
'export const handler = () => null;',
|
||||
'utf-8',
|
||||
),
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
expect(mockDriver.writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mimeType: 'application/typescript' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SVG sanitization (centralized invariant)', () => {
|
||||
const maliciousSvg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(2)</script><circle r="10" /></svg>';
|
||||
|
||||
it('should strip scripts and event handlers from an SVG string upload', async () => {
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
resourcePath: 'assets/icon.svg',
|
||||
sourceFile: maliciousSvg,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
const driverCall = mockDriver.writeFile.mock.calls[0][0];
|
||||
|
||||
expect(driverCall.mimeType).toBe('image/svg+xml');
|
||||
expect(typeof driverCall.sourceFile).toBe('string');
|
||||
expect(driverCall.sourceFile).not.toContain('<script>');
|
||||
expect(driverCall.sourceFile).not.toContain('onload');
|
||||
expect(driverCall.sourceFile).toContain('<circle');
|
||||
});
|
||||
|
||||
it('should strip scripts and event handlers from an SVG buffer upload', async () => {
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
resourcePath: 'assets/icon.svg',
|
||||
sourceFile: Buffer.from(maliciousSvg, 'utf-8'),
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
const driverCall = mockDriver.writeFile.mock.calls[0][0];
|
||||
|
||||
expect(driverCall.mimeType).toBe('image/svg+xml');
|
||||
expect(driverCall.sourceFile).not.toContain('<script>');
|
||||
expect(driverCall.sourceFile).not.toContain('onload');
|
||||
});
|
||||
|
||||
it('should leave non-SVG content untouched (sanitize is no-op)', async () => {
|
||||
const original = '{"foo": "bar"}';
|
||||
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.Source,
|
||||
resourcePath: 'package.json',
|
||||
sourceFile: original,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
const driverCall = mockDriver.writeFile.mock.calls[0][0];
|
||||
|
||||
expect(driverCall.sourceFile).toBe(original);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { Catch, ExceptionFilter } from '@nestjs/common';
|
||||
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
FileStorageException,
|
||||
FileStorageExceptionCode,
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
import {
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
@Catch(FileStorageException)
|
||||
export class FileStorageExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: FileStorageException) {
|
||||
switch (exception.code) {
|
||||
case FileStorageExceptionCode.INVALID_EXTENSION:
|
||||
throw new UserInputError(exception);
|
||||
case FileStorageExceptionCode.ACCESS_DENIED:
|
||||
throw new ForbiddenError(exception);
|
||||
case FileStorageExceptionCode.FILE_NOT_FOUND:
|
||||
throw new NotFoundError(exception);
|
||||
default:
|
||||
assertUnreachable(exception.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { type DynamicModule, Global } from '@nestjs/common';
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { FileStorageExceptionFilter } from 'src/engine/core-modules/file-storage/file-storage-exception-filter';
|
||||
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
@@ -16,7 +18,14 @@ export class FileStorageModule {
|
||||
TwentyConfigModule,
|
||||
TypeOrmModule.forFeature([FileEntity, ApplicationEntity]),
|
||||
],
|
||||
providers: [FileStorageDriverFactory, FileStorageService],
|
||||
providers: [
|
||||
FileStorageDriverFactory,
|
||||
FileStorageService,
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: FileStorageExceptionFilter,
|
||||
},
|
||||
],
|
||||
exports: [FileStorageDriverFactory, FileStorageService],
|
||||
};
|
||||
}
|
||||
|
||||
+26
-17
@@ -13,6 +13,7 @@ import {
|
||||
FileStorageException,
|
||||
FileStorageExceptionCode,
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
import { prepareFileForStorageOrThrow } from 'src/engine/core-modules/file-storage/utils/prepare-file-for-storage-or-throw.util';
|
||||
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
|
||||
import { validateFolderPath } from 'src/engine/core-modules/file-storage/utils/validate-folder-path.util';
|
||||
import { validateStoragePathIsWithinWorkspaceOrThrow } from 'src/engine/core-modules/file-storage/utils/validate-storage-path-is-within-workspace-or-throw.util';
|
||||
@@ -66,7 +67,7 @@ export class FileStorageService {
|
||||
return { onStoragePath, resourcePath };
|
||||
}
|
||||
|
||||
private validateAndBuildFileStoragePath(params: ResourceIdentifier): {
|
||||
private validateAndBuildFileStoragePathOrThrow(params: ResourceIdentifier): {
|
||||
onStorageFilePath: string;
|
||||
filePath: string;
|
||||
} {
|
||||
@@ -91,7 +92,7 @@ export class FileStorageService {
|
||||
return { onStorageFilePath: onStoragePath, filePath: resourcePath };
|
||||
}
|
||||
|
||||
private validateAndBuildFolderStoragePath(
|
||||
private validateAndBuildFolderStoragePathOrThrow(
|
||||
params: Omit<ResourceIdentifier, 'resourcePath'> & { folderPath: string },
|
||||
): { onStorageFolderPath: string; folderPath: string } {
|
||||
const validationResult = validateFolderPath({
|
||||
@@ -119,7 +120,6 @@ export class FileStorageService {
|
||||
|
||||
async writeFile({
|
||||
sourceFile,
|
||||
mimeType,
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
@@ -129,7 +129,6 @@ export class FileStorageService {
|
||||
queryRunner,
|
||||
}: ResourceIdentifier & {
|
||||
sourceFile: string | Buffer | Uint8Array;
|
||||
mimeType: string | undefined;
|
||||
fileId?: string;
|
||||
settings: FileSettings;
|
||||
queryRunner?: QueryRunner;
|
||||
@@ -151,17 +150,23 @@ export class FileStorageService {
|
||||
});
|
||||
|
||||
const { onStorageFilePath, filePath } =
|
||||
this.validateAndBuildFileStoragePath({
|
||||
this.validateAndBuildFileStoragePathOrThrow({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
fileFolder,
|
||||
resourcePath,
|
||||
});
|
||||
|
||||
const { sourceFile: persistedSourceFile, mimeType } =
|
||||
await prepareFileForStorageOrThrow({
|
||||
sourceFile,
|
||||
resourcePath,
|
||||
});
|
||||
|
||||
await driver.writeFile({
|
||||
filePath: onStorageFilePath,
|
||||
mimeType,
|
||||
sourceFile,
|
||||
sourceFile: persistedSourceFile,
|
||||
});
|
||||
|
||||
await fileRepository.upsert(
|
||||
@@ -172,9 +177,9 @@ export class FileStorageService {
|
||||
id: fileId,
|
||||
mimeType,
|
||||
size:
|
||||
typeof sourceFile === 'string'
|
||||
? Buffer.byteLength(sourceFile)
|
||||
: sourceFile.length,
|
||||
typeof persistedSourceFile === 'string'
|
||||
? Buffer.byteLength(persistedSourceFile)
|
||||
: persistedSourceFile.length,
|
||||
settings,
|
||||
},
|
||||
['path', 'workspaceId', 'applicationId'],
|
||||
@@ -197,7 +202,8 @@ export class FileStorageService {
|
||||
},
|
||||
): Promise<string | null> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
|
||||
const { onStorageFilePath } =
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
|
||||
return driver.getPresignedUrl({
|
||||
filePath: onStorageFilePath,
|
||||
@@ -210,7 +216,8 @@ export class FileStorageService {
|
||||
readFile(params: ResourceIdentifier): Promise<Readable> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
|
||||
const { onStorageFilePath } =
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
|
||||
return driver.readFile({ filePath: onStorageFilePath });
|
||||
}
|
||||
@@ -219,7 +226,8 @@ export class FileStorageService {
|
||||
params: ResourceIdentifier & { localPath: string },
|
||||
): Promise<void> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
|
||||
const { onStorageFilePath } =
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
|
||||
return driver.downloadFile({
|
||||
onStoragePath: onStorageFilePath,
|
||||
@@ -256,7 +264,7 @@ export class FileStorageService {
|
||||
async deleteFile(params: ResourceIdentifier): Promise<void> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const { onStorageFilePath, filePath } =
|
||||
this.validateAndBuildFileStoragePath(params);
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
|
||||
await driver.delete({
|
||||
folderPath: dirname(onStorageFilePath),
|
||||
@@ -288,7 +296,7 @@ export class FileStorageService {
|
||||
} = params;
|
||||
|
||||
const { onStorageFolderPath, folderPath: validatedFolderPath } =
|
||||
this.validateAndBuildFolderStoragePath({
|
||||
this.validateAndBuildFolderStoragePathOrThrow({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
fileFolder,
|
||||
@@ -373,9 +381,9 @@ export class FileStorageService {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
const { onStorageFilePath: fromPath } =
|
||||
this.validateAndBuildFileStoragePath(from);
|
||||
this.validateAndBuildFileStoragePathOrThrow(from);
|
||||
const { onStorageFilePath: toPath } =
|
||||
this.validateAndBuildFileStoragePath(to);
|
||||
this.validateAndBuildFileStoragePathOrThrow(to);
|
||||
|
||||
const isFile = await driver.checkFileExists({ filePath: fromPath });
|
||||
|
||||
@@ -394,7 +402,8 @@ export class FileStorageService {
|
||||
|
||||
checkFileExists(params: ResourceIdentifier): Promise<boolean> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
|
||||
const { onStorageFilePath } =
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
|
||||
return driver.checkFileExists({ filePath: onStorageFilePath });
|
||||
}
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
import { prepareFileForStorageOrThrow } from 'src/engine/core-modules/file-storage/utils/prepare-file-for-storage-or-throw.util';
|
||||
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
|
||||
0x48, 0x44, 0x52,
|
||||
]);
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4\n', 'utf-8');
|
||||
const zipBuffer = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||
const textBuffer = Buffer.from('Hello, world!', 'utf-8');
|
||||
|
||||
const benignSvg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><circle r="10" /></svg>';
|
||||
const maliciousSvg = `<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(2)</script><circle r="10" /></svg>`;
|
||||
|
||||
describe('prepareFileForStorageOrThrow', () => {
|
||||
describe('mime resolution', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'TypeScript .ts string (policy)',
|
||||
sourceFile: 'export const x = 1;',
|
||||
resourcePath: 'src/index.ts',
|
||||
expectedMime: 'application/typescript',
|
||||
},
|
||||
{
|
||||
name: 'TypeScript .tsx string (policy)',
|
||||
sourceFile: 'export const App = () => null;',
|
||||
resourcePath: 'src/App.tsx',
|
||||
expectedMime: 'application/typescript',
|
||||
},
|
||||
{
|
||||
name: '.mjs string (mrmime)',
|
||||
sourceFile: 'export default 1;',
|
||||
resourcePath: 'src/handler.mjs',
|
||||
expectedMime: 'text/javascript',
|
||||
},
|
||||
{
|
||||
name: 'JSON string (mrmime)',
|
||||
sourceFile: '{"foo": "bar"}',
|
||||
resourcePath: 'package.json',
|
||||
expectedMime: 'application/json',
|
||||
},
|
||||
{
|
||||
name: 'unknown-extension string (octet-stream)',
|
||||
sourceFile: 'lock content',
|
||||
resourcePath: 'yarn.lock',
|
||||
expectedMime: 'application/octet-stream',
|
||||
},
|
||||
{
|
||||
name: 'no-extension string (octet-stream)',
|
||||
sourceFile: 'content',
|
||||
resourcePath: 'somefile',
|
||||
expectedMime: 'application/octet-stream',
|
||||
},
|
||||
{
|
||||
name: 'dot-file string (octet-stream — extname semantics)',
|
||||
sourceFile: 'content',
|
||||
resourcePath: '.gitignore',
|
||||
expectedMime: 'application/octet-stream',
|
||||
},
|
||||
])(
|
||||
'returns $expectedMime for $name',
|
||||
async ({ sourceFile, resourcePath, expectedMime }) => {
|
||||
const { mimeType } = await prepareFileForStorageOrThrow({
|
||||
sourceFile,
|
||||
resourcePath,
|
||||
});
|
||||
|
||||
expect(mimeType).toBe(expectedMime);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'PNG buffer',
|
||||
sourceFile: pngBuffer,
|
||||
resourcePath: 'images/photo.png',
|
||||
expectedMime: 'image/png',
|
||||
},
|
||||
{
|
||||
name: 'PDF buffer',
|
||||
sourceFile: pdfBuffer,
|
||||
resourcePath: 'docs/contract.pdf',
|
||||
expectedMime: 'application/pdf',
|
||||
},
|
||||
{
|
||||
name: 'ZIP buffer',
|
||||
sourceFile: zipBuffer,
|
||||
resourcePath: 'archives/data.zip',
|
||||
expectedMime: 'application/zip',
|
||||
},
|
||||
])(
|
||||
'returns the bytes-derived mime for $name',
|
||||
async ({ sourceFile, resourcePath, expectedMime }) => {
|
||||
const { mimeType } = await prepareFileForStorageOrThrow({
|
||||
sourceFile,
|
||||
resourcePath,
|
||||
});
|
||||
|
||||
expect(mimeType).toBe(expectedMime);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns the bytes-derived mime for a Uint8Array source', async () => {
|
||||
const { mimeType } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: new Uint8Array(pngBuffer),
|
||||
resourcePath: 'images/photo.png',
|
||||
});
|
||||
|
||||
expect(mimeType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('binary-extension backstop', () => {
|
||||
it('throws when a buffer with a binary extension does not match the magic bytes', async () => {
|
||||
await expect(
|
||||
prepareFileForStorageOrThrow({
|
||||
sourceFile: textBuffer,
|
||||
resourcePath: 'fake-image.png',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when a string with a binary extension does not match the magic bytes', async () => {
|
||||
await expect(
|
||||
prepareFileForStorageOrThrow({
|
||||
sourceFile: 'this is not a png',
|
||||
resourcePath: 'fake-image.png',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on Uint8Array byte/extension mismatch', async () => {
|
||||
await expect(
|
||||
prepareFileForStorageOrThrow({
|
||||
sourceFile: new Uint8Array(textBuffer),
|
||||
resourcePath: 'fake-image.pdf',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SVG sanitization', () => {
|
||||
it('sanitizes an SVG string by removing scripts and event handlers', async () => {
|
||||
const { sourceFile, mimeType } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: maliciousSvg,
|
||||
resourcePath: 'assets/icon.svg',
|
||||
});
|
||||
|
||||
expect(mimeType).toBe('image/svg+xml');
|
||||
expect(typeof sourceFile).toBe('string');
|
||||
expect(sourceFile).not.toContain('<script>');
|
||||
expect(sourceFile).not.toContain('onload');
|
||||
expect(sourceFile).toContain('<circle');
|
||||
});
|
||||
|
||||
it('sanitizes an SVG buffer (DOMPurify returns a string)', async () => {
|
||||
const { sourceFile, mimeType } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: Buffer.from(maliciousSvg, 'utf-8'),
|
||||
resourcePath: 'assets/icon.svg',
|
||||
});
|
||||
|
||||
expect(mimeType).toBe('image/svg+xml');
|
||||
expect(typeof sourceFile).toBe('string');
|
||||
expect(sourceFile).not.toContain('<script>');
|
||||
expect(sourceFile).not.toContain('onload');
|
||||
});
|
||||
|
||||
it('preserves a benign SVG (no destructive sanitization)', async () => {
|
||||
const { sourceFile } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: benignSvg,
|
||||
resourcePath: 'assets/icon.svg',
|
||||
});
|
||||
|
||||
expect(sourceFile).toContain('<circle');
|
||||
expect(sourceFile).toContain('xmlns');
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-SVG content is returned unchanged', () => {
|
||||
it('returns the original buffer reference for a non-SVG buffer', async () => {
|
||||
const { sourceFile } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: pngBuffer,
|
||||
resourcePath: 'images/photo.png',
|
||||
});
|
||||
|
||||
expect(sourceFile).toBe(pngBuffer);
|
||||
});
|
||||
|
||||
it('returns the original string for a non-SVG string', async () => {
|
||||
const original = '{"foo": "bar"}';
|
||||
|
||||
const { sourceFile } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: original,
|
||||
resourcePath: 'package.json',
|
||||
});
|
||||
|
||||
expect(sourceFile).toBe(original);
|
||||
});
|
||||
});
|
||||
});
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
|
||||
export const prepareFileForStorageOrThrow = async ({
|
||||
sourceFile,
|
||||
resourcePath,
|
||||
}: {
|
||||
sourceFile: Buffer | Uint8Array | string;
|
||||
resourcePath: string;
|
||||
}): Promise<{
|
||||
sourceFile: Buffer | Uint8Array | string;
|
||||
mimeType: string;
|
||||
}> => {
|
||||
const bufferForExtract =
|
||||
typeof sourceFile === 'string'
|
||||
? Buffer.from(sourceFile, 'utf8')
|
||||
: Buffer.isBuffer(sourceFile)
|
||||
? sourceFile
|
||||
: Buffer.from(sourceFile);
|
||||
|
||||
const { mimeType, ext } = await extractFileInfoOrThrow({
|
||||
file: bufferForExtract,
|
||||
filename: resourcePath,
|
||||
});
|
||||
|
||||
const sanitizedSourceFile = sanitizeFile({
|
||||
file: sourceFile,
|
||||
ext,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
return { sourceFile: sanitizedSourceFile, mimeType };
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
// Extensions where Twenty intentionally deviates from the IANA-standard mime
|
||||
// mapping returned by mrmime. Twenty's storage layer persists these values
|
||||
// instead of mrmime's output (or instead of throwing in the case of `.ts`).
|
||||
//
|
||||
// Why each entry exists:
|
||||
// - ts/tsx: IANA registers `.ts` as `video/mp2t` (MPEG-2 Transport Stream),
|
||||
// which predates TypeScript. `.tsx` is unregistered. Twenty uses
|
||||
// the developer-tooling convention `application/typescript`.
|
||||
//
|
||||
// Only add an entry when (1) mrmime returns the wrong mime (collision) or no
|
||||
// mime AND (2) Twenty actually writes that extension via FileStorageService AND
|
||||
// (3) there is a clear developer-tooling convention for the right mime. For
|
||||
// everything else — media, archives, documents, `.js`, `.mjs`, `.cjs`, etc. —
|
||||
// trust mrmime. This policy is reactive: extend it the day we observe a real
|
||||
// wrong-mime persisted by an exercised code path, not in anticipation.
|
||||
export const TWENTY_MIME_POLICY: Record<string, string> = {
|
||||
ts: 'application/typescript',
|
||||
tsx: 'application/typescript',
|
||||
};
|
||||
+4
-7
@@ -8,8 +8,8 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
|
||||
@Injectable()
|
||||
export class FileAiChatService {
|
||||
constructor(
|
||||
@@ -27,13 +27,11 @@ export class FileAiChatService {
|
||||
filename: string;
|
||||
workspaceId: string;
|
||||
}): Promise<FileWithSignedUrlDTO> {
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
const { ext } = await extractFileInfoOrThrow({
|
||||
file,
|
||||
filename,
|
||||
});
|
||||
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
|
||||
const fileId = v4();
|
||||
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
|
||||
@@ -45,9 +43,8 @@ export class FileAiChatService {
|
||||
);
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedFile,
|
||||
sourceFile: file,
|
||||
resourcePath: name,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.AgentChat,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
|
||||
+3
-6
@@ -19,9 +19,8 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { getImageBufferFromUrl } from 'src/utils/image';
|
||||
@@ -72,8 +71,7 @@ export class FileCorePictureService {
|
||||
applicationUniversalIdentifier?: string;
|
||||
queryRunner?: QueryRunner;
|
||||
}): Promise<FileEntity> {
|
||||
const { mimeType, ext } = await extractFileInfo({ file, filename });
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
const { ext } = await extractFileInfoOrThrow({ file, filename });
|
||||
|
||||
const fileId = v4();
|
||||
const finalName = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
@@ -83,9 +81,8 @@ export class FileCorePictureService {
|
||||
(await this.findCustomApplicationUniversalIdentifier(workspaceId));
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedFile,
|
||||
sourceFile: file,
|
||||
resourcePath: finalName,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
applicationUniversalIdentifier: universalIdentifier,
|
||||
workspaceId,
|
||||
|
||||
+3
-7
@@ -8,8 +8,7 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
|
||||
@Injectable()
|
||||
export class FileEmailAttachmentService {
|
||||
@@ -30,13 +29,11 @@ export class FileEmailAttachmentService {
|
||||
filename: string;
|
||||
workspaceId: string;
|
||||
}): Promise<FileWithSignedUrlDTO> {
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
const { ext } = await extractFileInfoOrThrow({
|
||||
file,
|
||||
filename,
|
||||
});
|
||||
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
|
||||
const fileId = v4();
|
||||
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
|
||||
@@ -48,9 +45,8 @@ export class FileEmailAttachmentService {
|
||||
);
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedFile,
|
||||
sourceFile: file,
|
||||
resourcePath: name,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.EmailAttachment,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user