fix IndexFieldMetadata availability in IndexMetadata/ObjectMetadata in front (#12886)

Context : 
- IndexFieldMetadata was no longer available on 'objects' gql query
([since this PR](https://github.com/twentyhq/twenty/pull/12785)). Then,
unicity checks on import do not work anymore.

Fix : 
- Add a dataloader logic in indexFieldMetadata
- Add extra check in unicity hook on import
This commit is contained in:
Etienne
2025-06-27 13:12:14 +02:00
committed by GitHub
parent c3f8a25d25
commit b80762b3e1
20 changed files with 225 additions and 71 deletions

View File

@ -30,7 +30,7 @@ const documents = {
"\n mutation UpdateOneObjectMetadataItem(\n $idToUpdate: UUID!\n $updatePayload: UpdateObjectPayload!\n ) {\n updateOneObject(input: { id: $idToUpdate, update: $updatePayload }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n isSearchable\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n isLabelSyncedWithName\n }\n }\n": types.UpdateOneObjectMetadataItemDocument, "\n mutation UpdateOneObjectMetadataItem(\n $idToUpdate: UUID!\n $updatePayload: UpdateObjectPayload!\n ) {\n updateOneObject(input: { id: $idToUpdate, update: $updatePayload }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n isSearchable\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n isLabelSyncedWithName\n }\n }\n": types.UpdateOneObjectMetadataItemDocument,
"\n mutation DeleteOneObjectMetadataItem($idToDelete: UUID!) {\n deleteOneObject(input: { id: $idToDelete }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n isSearchable\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n isLabelSyncedWithName\n }\n }\n": types.DeleteOneObjectMetadataItemDocument, "\n mutation DeleteOneObjectMetadataItem($idToDelete: UUID!) {\n deleteOneObject(input: { id: $idToDelete }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n isSearchable\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n isLabelSyncedWithName\n }\n }\n": types.DeleteOneObjectMetadataItemDocument,
"\n mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {\n deleteOneField(input: { id: $idToDelete }) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n }\n }\n": types.DeleteOneFieldMetadataItemDocument, "\n mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {\n deleteOneField(input: { id: $idToDelete }) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n }\n }\n": types.DeleteOneFieldMetadataItemDocument,
"\n query ObjectMetadataItems {\n objects(paging: { first: 1000 }) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n isSearchable\n duplicateCriteria\n indexMetadataList {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n }\n fieldsList {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n isLabelSyncedWithName\n relation {\n type\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n": types.ObjectMetadataItemsDocument, "\n query ObjectMetadataItems {\n objects(paging: { first: 1000 }) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n isSearchable\n duplicateCriteria\n indexMetadataList {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadataList {\n id\n fieldMetadataId\n createdAt\n updatedAt\n order\n }\n }\n fieldsList {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n isLabelSyncedWithName\n relation {\n type\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n": types.ObjectMetadataItemsDocument,
"\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n timeoutSeconds\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc, "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n timeoutSeconds\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc,
"\n \n mutation CreateOneServerlessFunctionItem(\n $input: CreateServerlessFunctionInput!\n ) {\n createOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.CreateOneServerlessFunctionItemDocument, "\n \n mutation CreateOneServerlessFunctionItem(\n $input: CreateServerlessFunctionInput!\n ) {\n createOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.CreateOneServerlessFunctionItemDocument,
"\n \n mutation DeleteOneServerlessFunction($input: ServerlessFunctionIdInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.DeleteOneServerlessFunctionDocument, "\n \n mutation DeleteOneServerlessFunction($input: ServerlessFunctionIdInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.DeleteOneServerlessFunctionDocument,
@ -128,7 +128,7 @@ export function graphql(source: "\n mutation DeleteOneFieldMetadataItem($idToDe
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
export function graphql(source: "\n query ObjectMetadataItems {\n objects(paging: { first: 1000 }) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n isSearchable\n duplicateCriteria\n indexMetadataList {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n }\n fieldsList {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n isLabelSyncedWithName\n relation {\n type\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n"): (typeof documents)["\n query ObjectMetadataItems {\n objects(paging: { first: 1000 }) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n isSearchable\n duplicateCriteria\n indexMetadataList {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n }\n fieldsList {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n isLabelSyncedWithName\n relation {\n type\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n"]; export function graphql(source: "\n query ObjectMetadataItems {\n objects(paging: { first: 1000 }) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n isSearchable\n duplicateCriteria\n indexMetadataList {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadataList {\n id\n fieldMetadataId\n createdAt\n updatedAt\n order\n }\n }\n fieldsList {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n isLabelSyncedWithName\n relation {\n type\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n"): (typeof documents)["\n query ObjectMetadataItems {\n objects(paging: { first: 1000 }) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n isSearchable\n duplicateCriteria\n indexMetadataList {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadataList {\n id\n fieldMetadataId\n createdAt\n updatedAt\n order\n }\n }\n fieldsList {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n isLabelSyncedWithName\n relation {\n type\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */

File diff suppressed because one or more lines are too long

View File

@ -778,6 +778,7 @@ export type Index = {
__typename?: 'Index'; __typename?: 'Index';
createdAt: Scalars['DateTime']; createdAt: Scalars['DateTime'];
id: Scalars['UUID']; id: Scalars['UUID'];
indexFieldMetadataList: Array<IndexField>;
indexFieldMetadatas: IndexIndexFieldMetadatasConnection; indexFieldMetadatas: IndexIndexFieldMetadatasConnection;
indexType: IndexType; indexType: IndexType;
indexWhereClause?: Maybe<Scalars['String']>; indexWhereClause?: Maybe<Scalars['String']>;

View File

@ -33,6 +33,13 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql`
indexWhereClause indexWhereClause
indexType indexType
isUnique isUnique
indexFieldMetadataList {
id
fieldMetadataId
createdAt
updatedAt
order
}
} }
fieldsList { fieldsList {
id id

View File

@ -3,7 +3,10 @@ import { Index as GeneratedIndex } from '~/generated-metadata/graphql';
export type IndexMetadataItem = Omit< export type IndexMetadataItem = Omit<
GeneratedIndex, GeneratedIndex,
'__typename' | 'indexFieldMetadatas' | 'objectMetadata' | '__typename'
| 'indexFieldMetadatas'
| 'objectMetadata'
| 'indexFieldMetadataList'
> & { > & {
__typename?: string; __typename?: string;
indexFieldMetadatas: IndexFieldMetadataItem[]; indexFieldMetadatas: IndexFieldMetadataItem[];

View File

@ -1,3 +1,4 @@
import { IndexFieldMetadataItem } from '@/object-metadata/types/IndexFieldMetadataItem';
import { IndexMetadataItem } from '@/object-metadata/types/IndexMetadataItem'; import { IndexMetadataItem } from '@/object-metadata/types/IndexMetadataItem';
import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema'; import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema';
import { ObjectMetadataItemsQuery } from '~/generated-metadata/graphql'; import { ObjectMetadataItemsQuery } from '~/generated-metadata/graphql';
@ -26,7 +27,12 @@ export const mapPaginatedObjectMetadataItemsToObjectMetadataItems = ({
(index) => (index) =>
({ ({
...index, ...index,
indexFieldMetadatas: [], indexFieldMetadatas: index.indexFieldMetadataList.map(
(indexFieldMetadata) =>
({
...indexFieldMetadata,
}) satisfies IndexFieldMetadataItem,
),
}) satisfies IndexMetadataItem, }) satisfies IndexMetadataItem,
), ),
} satisfies ObjectMetadataItem; } satisfies ObjectMetadataItem;

View File

@ -81,9 +81,9 @@ describe('spreadsheetImportGetUnicityRowHook', () => {
const hook = spreadsheetImportGetUnicityRowHook(mockObjectMetadataItem); const hook = spreadsheetImportGetUnicityRowHook(mockObjectMetadataItem);
const testData: ImportedStructuredRow<string>[] = [ const testData: ImportedStructuredRow<string>[] = [
{ 'Link URL (domainName)': 'duplicaTe.com', id: '1' }, { 'Link URL (domainName)': 'duplicaTe.com' },
{ 'Link URL (domainName)': 'duplicate.com ', id: '2' }, { 'Link URL (domainName)': 'duplicate.com ' },
{ 'Link URL (domainName)': 'other.com', id: '3' }, { 'Link URL (domainName)': 'other.com' },
]; ];
const addErrorMock = jest.fn(); const addErrorMock = jest.fn();

View File

@ -1,12 +1,24 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey'; import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey';
import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs'; import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { import {
ImportedStructuredRow, ImportedStructuredRow,
SpreadsheetImportRowHook, SpreadsheetImportRowHook,
} from '@/spreadsheet-import/types'; } from '@/spreadsheet-import/types';
import { isDefined } from 'twenty-shared/utils'; import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import {
isDefined,
lowercaseUrlAndRemoveTrailingSlash,
} from 'twenty-shared/utils';
type Column = {
columnName: string;
fieldType: FieldMetadataType;
};
export const spreadsheetImportGetUnicityRowHook = ( export const spreadsheetImportGetUnicityRowHook = (
objectMetadataItem: ObjectMetadataItem, objectMetadataItem: ObjectMetadataItem,
@ -15,8 +27,8 @@ export const spreadsheetImportGetUnicityRowHook = (
(indexMetadata) => indexMetadata.isUnique, (indexMetadata) => indexMetadata.isUnique,
); );
const uniqueConstraintFields = [ const uniqueConstraintsWithColumnNames: Column[][] = [
['id'], [{ columnName: 'id', fieldType: FieldMetadataType.UUID }],
...uniqueConstraints.map((indexMetadata) => ...uniqueConstraints.map((indexMetadata) =>
indexMetadata.indexFieldMetadatas.flatMap((indexField) => { indexMetadata.indexFieldMetadatas.flatMap((indexField) => {
const field = objectMetadataItem.fields.find( const field = objectMetadataItem.fields.find(
@ -35,12 +47,13 @@ export const spreadsheetImportGetUnicityRowHook = (
(subField) => subField.isIncludedInUniqueConstraint, (subField) => subField.isIncludedInUniqueConstraint,
); );
return uniqueSubFields.map((subField) => return uniqueSubFields.map((subField) => ({
getSubFieldOptionKey(field, subField.subFieldName), columnName: getSubFieldOptionKey(field, subField.subFieldName),
); fieldType: field.type,
}));
} }
return [field.name]; return [{ columnName: field.name, fieldType: field.type }];
}), }),
), ),
]; ];
@ -50,9 +63,13 @@ export const spreadsheetImportGetUnicityRowHook = (
return row; return row;
} }
uniqueConstraintFields.forEach((uniqueConstraint) => { uniqueConstraintsWithColumnNames.forEach((uniqueConstraint) => {
const rowUniqueValues = getUniqueValues(row, uniqueConstraint); const rowUniqueValues = getUniqueValues(row, uniqueConstraint);
if (!isNonEmptyString(rowUniqueValues)) {
return row;
}
const duplicateRows = table.filter( const duplicateRows = table.filter(
(r) => getUniqueValues(r, uniqueConstraint) === rowUniqueValues, (r) => getUniqueValues(r, uniqueConstraint) === rowUniqueValues,
); );
@ -61,10 +78,10 @@ export const spreadsheetImportGetUnicityRowHook = (
return row; return row;
} }
uniqueConstraint.forEach((field) => { uniqueConstraint.forEach(({ columnName }) => {
if (isDefined(row[field])) { if (isDefined(row[columnName])) {
addError(field, { addError(columnName, {
message: `This ${field} value already exists in your import data`, message: t`This ${columnName} value already exists in your import data`,
level: 'error', level: 'error',
}); });
} }
@ -79,9 +96,24 @@ export const spreadsheetImportGetUnicityRowHook = (
const getUniqueValues = ( const getUniqueValues = (
row: ImportedStructuredRow<string>, row: ImportedStructuredRow<string>,
uniqueConstraint: string[], uniqueConstraint: Column[],
) => { ) => {
return uniqueConstraint return uniqueConstraint
.map((field) => row?.[field]?.toString().trim().toLowerCase()) .map(({ columnName, fieldType }) => {
// need to ensure the primary link url is processed before import as on server side
if (
fieldType === FieldMetadataType.LINKS &&
columnName.includes(
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS]
.primaryLinkUrl,
)
) {
return lowercaseUrlAndRemoveTrailingSlash(
row?.[columnName]?.toString().trim() || '',
);
}
return row?.[columnName]?.toString().trim().toLowerCase();
})
.join(''); .join('');
}; };

View File

@ -1,31 +0,0 @@
import { lowercaseDomainAndRemoveTrailingSlash } from 'src/engine/api/graphql/workspace-query-runner/utils/query-runner-links.util';
describe('queryRunner LINKS util', () => {
it('should leave lowcased domain unchanged', () => {
const primaryLinkUrl = 'https://www.example.com/test';
const result = lowercaseDomainAndRemoveTrailingSlash(primaryLinkUrl);
expect(result).toBe('https://www.example.com/test');
});
it('should lowercase the domain of the primary link url', () => {
const primaryLinkUrl = 'htTps://wwW.exAmple.coM/TEST';
const result = lowercaseDomainAndRemoveTrailingSlash(primaryLinkUrl);
expect(result).toBe('https://www.example.com/TEST');
});
it('should not add a trailing slash', () => {
const primaryLinkUrl = 'https://www.example.com';
const result = lowercaseDomainAndRemoveTrailingSlash(primaryLinkUrl);
expect(result).toBe('https://www.example.com');
});
it('should not add a trailing slash', () => {
const primaryLinkUrl = 'https://www.example.com/toto/';
const result = lowercaseDomainAndRemoveTrailingSlash(primaryLinkUrl);
expect(result).toBe('https://www.example.com/toto');
});
});

View File

@ -1,7 +0,0 @@
export const lowercaseDomainAndRemoveTrailingSlash = (url: string) => {
try {
return new URL(url).toString().replace(/\/$/, '');
} catch {
return url;
}
};

View File

@ -1,7 +1,9 @@
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils'; import {
isDefined,
lowercaseUrlAndRemoveTrailingSlash,
} from 'twenty-shared/utils';
import { lowercaseDomainAndRemoveTrailingSlash } from 'src/engine/api/graphql/workspace-query-runner/utils/query-runner-links.util';
import { removeEmptyLinks } from 'src/engine/core-modules/record-transformer/utils/remove-empty-links'; import { removeEmptyLinks } from 'src/engine/core-modules/record-transformer/utils/remove-empty-links';
import { LinkMetadataNullable } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type'; import { LinkMetadataNullable } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
@ -46,14 +48,14 @@ export const transformLinksValue = (
return { return {
...value, ...value,
primaryLinkUrl: isDefined(primaryLinkUrl) primaryLinkUrl: isDefined(primaryLinkUrl)
? lowercaseDomainAndRemoveTrailingSlash(primaryLinkUrl) ? lowercaseUrlAndRemoveTrailingSlash(primaryLinkUrl)
: primaryLinkUrl, : primaryLinkUrl,
primaryLinkLabel, primaryLinkLabel,
secondaryLinks: JSON.stringify( secondaryLinks: JSON.stringify(
secondaryLinks?.map((link) => ({ secondaryLinks?.map((link) => ({
...link, ...link,
url: isDefined(link.url) url: isDefined(link.url)
? lowercaseDomainAndRemoveTrailingSlash(link.url) ? lowercaseUrlAndRemoveTrailingSlash(link.url)
: link.url, : link.url,
})), })),
), ),

View File

@ -2,11 +2,13 @@ import DataLoader from 'dataloader';
import { import {
FieldMetadataLoaderPayload, FieldMetadataLoaderPayload,
IndexFieldMetadataLoaderPayload,
IndexMetadataLoaderPayload, IndexMetadataLoaderPayload,
RelationLoaderPayload, RelationLoaderPayload,
} from 'src/engine/dataloaders/dataloader.service'; } from 'src/engine/dataloaders/dataloader.service';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto';
import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto'; import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@ -30,4 +32,9 @@ export interface IDataloaders {
IndexMetadataLoaderPayload, IndexMetadataLoaderPayload,
IndexMetadataDTO[] IndexMetadataDTO[]
>; >;
indexFieldMetadataLoader: DataLoader<
IndexFieldMetadataLoaderPayload,
IndexFieldMetadataDTO[]
>;
} }

View File

@ -2,15 +2,18 @@ import { Injectable } from '@nestjs/common';
import DataLoader from 'dataloader'; import DataLoader from 'dataloader';
import { APP_LOCALES } from 'twenty-shared/translations'; import { APP_LOCALES } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { IndexMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-metadata.interface';
import { IDataloaders } from 'src/engine/dataloaders/dataloader.interface'; import { IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service'; import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/relation/field-metadata-relation.service'; import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/relation/field-metadata-relation.service';
import { IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto';
import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto'; import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
@ -46,6 +49,12 @@ export type IndexMetadataLoaderPayload = {
objectMetadata: Pick<ObjectMetadataInterface, 'id'>; objectMetadata: Pick<ObjectMetadataInterface, 'id'>;
}; };
export type IndexFieldMetadataLoaderPayload = {
workspaceId: string;
objectMetadata: Pick<ObjectMetadataInterface, 'id'>;
indexMetadata: Pick<IndexMetadataInterface, 'id'>;
};
@Injectable() @Injectable()
export class DataloaderService { export class DataloaderService {
constructor( constructor(
@ -58,11 +67,13 @@ export class DataloaderService {
const relationLoader = this.createRelationLoader(); const relationLoader = this.createRelationLoader();
const fieldMetadataLoader = this.createFieldMetadataLoader(); const fieldMetadataLoader = this.createFieldMetadataLoader();
const indexMetadataLoader = this.createIndexMetadataLoader(); const indexMetadataLoader = this.createIndexMetadataLoader();
const indexFieldMetadataLoader = this.createIndexFieldMetadataLoader();
return { return {
relationLoader, relationLoader,
fieldMetadataLoader, fieldMetadataLoader,
indexMetadataLoader, indexMetadataLoader,
indexFieldMetadataLoader,
}; };
} }
@ -179,4 +190,49 @@ export class DataloaderService {
}, },
); );
} }
private createIndexFieldMetadataLoader() {
return new DataLoader<
IndexFieldMetadataLoaderPayload,
IndexFieldMetadataDTO[]
>(async (dataLoaderParams: IndexFieldMetadataLoaderPayload[]) => {
const workspaceId = dataLoaderParams[0].workspaceId;
const { objectMetadataMaps } =
await this.workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps(
{ workspaceId },
);
return dataLoaderParams.map(
({
objectMetadata: { id: objectMetadataId },
indexMetadata: { id: indexMetadataId },
}) => {
const indexMetadataEntity = objectMetadataMaps.byId[
objectMetadataId
].indexMetadatas.find(
(indexMetadata) => indexMetadata.id === indexMetadataId,
);
if (!isDefined(indexMetadataEntity)) {
return [];
}
return indexMetadataEntity.indexFieldMetadatas.map(
(indexFieldMetadata) => {
return {
id: indexFieldMetadata.id,
fieldMetadataId: indexFieldMetadata.fieldMetadataId,
order: indexFieldMetadata.order,
createdAt: new Date(indexFieldMetadata.createdAt),
updatedAt: new Date(indexFieldMetadata.updatedAt),
indexMetadataId,
workspaceId,
};
},
);
},
);
});
}
} }

View File

@ -14,7 +14,7 @@ import {
} from '@ptc-org/nestjs-query-graphql'; } from '@ptc-org/nestjs-query-graphql';
import { import {
IsBoolean, IsBoolean,
IsDateString, IsDate,
IsEnum, IsEnum,
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
@ -81,11 +81,11 @@ export class IndexMetadataDTO {
objectMetadataId: string; objectMetadataId: string;
@IsDateString() @IsDate()
@Field() @Field()
createdAt: Date; createdAt: Date;
@IsDateString() @IsDate()
@Field() @Field()
updatedAt: Date; updatedAt: Date;

View File

@ -9,6 +9,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto'; import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.entity'; import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.entity';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { IndexMetadataResolver } from 'src/engine/metadata-modules/index-metadata/index-metadata.resolver';
import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service'; import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service';
import { ObjectMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/object-metadata/interceptors/object-metadata-graphql-api-exception.interceptor'; import { ObjectMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/object-metadata/interceptors/object-metadata-graphql-api-exception.interceptor';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module'; import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
@ -46,7 +47,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
], ],
}), }),
], ],
providers: [IndexMetadataService], providers: [IndexMetadataService, IndexMetadataResolver],
exports: [IndexMetadataService], exports: [IndexMetadataService],
}) })
export class IndexMetadataModule {} export class IndexMetadataModule {}

View File

@ -0,0 +1,44 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Context, Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto';
import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
import { objectMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
@UseGuards(WorkspaceAuthGuard)
@Resolver(() => IndexMetadataDTO)
@UsePipes(ResolverValidationPipe)
@UseFilters(
PreventNestToAutoLogGraphqlErrorsFilter,
PermissionsGraphqlApiExceptionFilter,
)
export class IndexMetadataResolver {
@ResolveField(() => [IndexFieldMetadataDTO], { nullable: false })
async indexFieldMetadataList(
@AuthWorkspace() workspace: Workspace,
@Parent() indexMetadata: IndexMetadataDTO,
@Context() context: { loaders: IDataloaders },
): Promise<IndexFieldMetadataDTO[]> {
try {
const indexFieldMetadataItems =
await context.loaders.indexFieldMetadataLoader.load({
objectMetadata: { id: indexMetadata.objectMetadataId },
indexMetadata,
workspaceId: workspace.id,
});
return indexFieldMetadataItems;
} catch (error) {
objectMetadataGraphqlApiExceptionHandler(error);
return [];
}
}
}

View File

@ -5,10 +5,10 @@ import axios, { AxiosInstance } from 'axios';
import uniqBy from 'lodash.uniqby'; import uniqBy from 'lodash.uniqby';
import { TWENTY_COMPANIES_BASE_URL } from 'twenty-shared/constants'; import { TWENTY_COMPANIES_BASE_URL } from 'twenty-shared/constants';
import { ConnectedAccountProvider } from 'twenty-shared/types'; import { ConnectedAccountProvider } from 'twenty-shared/types';
import { lowercaseUrlAndRemoveTrailingSlash } from 'twenty-shared/utils';
import { DeepPartial, ILike, Repository } from 'typeorm'; import { DeepPartial, ILike, Repository } from 'typeorm';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { lowercaseDomainAndRemoveTrailingSlash } from 'src/engine/api/graphql/workspace-query-runner/utils/query-runner-links.util';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
@ -79,7 +79,7 @@ export class CreateCompanyService {
const companiesWithoutTrailingSlash = companies.map((company) => ({ const companiesWithoutTrailingSlash = companies.map((company) => ({
...company, ...company,
domainName: company.domainName domainName: company.domainName
? lowercaseDomainAndRemoveTrailingSlash(company.domainName) ? lowercaseUrlAndRemoveTrailingSlash(company.domainName)
: undefined, : undefined,
})); }));

View File

@ -26,6 +26,7 @@ export { getAbsoluteUrlOrThrow } from './url/getAbsoluteUrlOrThrow';
export { getUrlHostnameOrThrow } from './url/getUrlHostnameOrThrow'; export { getUrlHostnameOrThrow } from './url/getUrlHostnameOrThrow';
export { isValidHostname } from './url/isValidHostname'; export { isValidHostname } from './url/isValidHostname';
export { isValidUrl } from './url/isValidUrl'; export { isValidUrl } from './url/isValidUrl';
export { lowercaseUrlAndRemoveTrailingSlash } from './url/lowercaseUrlAndRemoveTrailingSlash';
export { isDefined } from './validation/isDefined'; export { isDefined } from './validation/isDefined';
export { isLabelIdentifierFieldMetadataTypes } from './validation/isLabelIdentifierFieldMetadataTypes'; export { isLabelIdentifierFieldMetadataTypes } from './validation/isLabelIdentifierFieldMetadataTypes';
export { isValidLocale } from './validation/isValidLocale'; export { isValidLocale } from './validation/isValidLocale';

View File

@ -0,0 +1,24 @@
import { lowercaseUrlAndRemoveTrailingSlash } from '@/utils/url/lowercaseUrlAndRemoveTrailingSlash';
describe('lowercaseUrlAndRemoveTrailingSlash', () => {
it('should leave lowcased domain unchanged', () => {
const primaryLinkUrl = 'https://www.example.com/test';
const result = lowercaseUrlAndRemoveTrailingSlash(primaryLinkUrl);
expect(result).toBe('https://www.example.com/test');
});
it('should lowercase the domain of the primary link url', () => {
const primaryLinkUrl = 'htTps://wwW.exAmple.coM/TEST';
const result = lowercaseUrlAndRemoveTrailingSlash(primaryLinkUrl);
expect(result).toBe('https://www.example.com/TEST');
});
it('should not add a trailing slash', () => {
const primaryLinkUrl = 'https://www.example.com';
const result = lowercaseUrlAndRemoveTrailingSlash(primaryLinkUrl);
expect(result).toBe('https://www.example.com');
});
});

View File

@ -0,0 +1,7 @@
export const lowercaseUrlAndRemoveTrailingSlash = (url: string) => {
try {
return new URL(url).toString().toLowerCase().replace(/\/$/, '');
} catch {
return url.toLowerCase();
}
};