From 0609b31c647e96bfb6a2310e140f7c485ce6fbe4 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:32:59 +0100 Subject: [PATCH] add WorkspaceDuplicateCriteria decorator + update duplicate resolver logic (#10128) ## Context All objects have '...duplicates' resolver but only companies and people have duplicate criteria (hard coded constant). Gql schema and resolver should be created only if duplicate criteria exist. ## Solution - Add a new @WorkspaceDuplicateCriteria decorator at object level, defining duplicate criteria for given object. - Add a new duplicate criteria field in ObjectMetadata table - Update schema and resolver building logic - Update front requests for duplicate check (only for object with criteria defined) closes https://github.com/twentyhq/twenty/issues/9828 --- .../src/generated-metadata/gql.ts | 4 +- .../src/generated-metadata/graphql.ts | 5 +- .../twenty-front/src/generated/graphql.tsx | 1 + .../object-metadata/graphql/queries.ts | 1 + .../hooks/useFindDuplicateRecords.ts | 2 + .../RecordDetailDuplicatesSection.tsx | 7 + ...DuplicateCriteriaColumnInObjectMetadata.ts | 19 +++ .../__mocks__/mockPersonObjectMetadata.ts | 93 +++++++++++++ .../__mocks__/mockPersonRecords.ts | 20 +++ ...y-find-duplicates-resolver.service.spec.ts | 125 ++++++++++++++++++ ...-query-find-duplicates-resolver.service.ts | 46 ++++--- ...orkspace-entity-duplicate-criteria.type.ts | 3 + .../workspace-resolver-builder.module.ts | 9 +- .../workspace-resolver-builder.service.ts | 25 ++++ .../workspace-resolver.factory.ts | 19 ++- .../factories/root-type.factory.ts | 93 +++++++------ .../workspace-schema-builder.module.ts | 7 +- .../constants/duplicate-criteria.constants.ts | 30 ----- .../interfaces/object-metadata.interface.ts | 3 + .../dtos/object-metadata.dto.ts | 4 + .../object-metadata/object-metadata.entity.ts | 4 + .../workspace-duplicate-criteria.decorator.ts | 14 ++ .../decorators/workspace-entity.decorator.ts | 6 + ...orkspace-entity-metadata-args.interface.ts | 7 + .../twenty-orm/utils/format-result.util.ts | 44 +++--- .../workspace-object.comparator.ts | 11 +- .../company.workspace-entity.ts | 2 + .../person.workspace-entity.ts | 6 + .../twenty-server/src/utils/typed-reflect.ts | 2 + 29 files changed, 491 insertions(+), 121 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/metadata/migrations/1738853620654-addDuplicateCriteriaColumnInObjectMetadata.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonObjectMetadata.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonRecords.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__tests__/graphql-query-find-duplicates-resolver.service.spec.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/duplicate/constants/duplicate-criteria.constants.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/decorators/workspace-duplicate-criteria.decorator.ts diff --git a/packages/twenty-front/src/generated-metadata/gql.ts b/packages/twenty-front/src/generated-metadata/gql.ts index f70dfcefa..ccfb9b4df 100644 --- a/packages/twenty-front/src/generated-metadata/gql.ts +++ b/packages/twenty-front/src/generated-metadata/gql.ts @@ -32,7 +32,7 @@ const documents = { "\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 createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\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 DeleteOneRelationMetadataItem($idToDelete: UUID!) {\n deleteOneRelation(input: { id: $idToDelete }) {\n id\n }\n }\n": types.DeleteOneRelationMetadataItemDocument, - "\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 indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\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 relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\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 duplicateCriteria\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\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 relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\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 syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc, "\n \n mutation BuildDraftServerlessFunction(\n $input: BuildDraftServerlessFunctionInput!\n ) {\n buildDraftServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.BuildDraftServerlessFunctionDocument, "\n \n mutation CreateOneServerlessFunctionItem(\n $input: CreateServerlessFunctionInput!\n ) {\n createOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.CreateOneServerlessFunctionItemDocument, @@ -139,7 +139,7 @@ export function graphql(source: "\n mutation DeleteOneRelationMetadataItem($idT /** * 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 indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\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 relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\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 indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\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 relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\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 duplicateCriteria\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\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 relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\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 duplicateCriteria\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\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 relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\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. */ diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index ef2e396bc..ed84bc5b5 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1203,6 +1203,7 @@ export type Object = { createdAt: Scalars['DateTime']['output']; dataSourceId: Scalars['String']['output']; description?: Maybe; + duplicateCriteria?: Maybe>>; fields: ObjectFieldsConnection; fieldsList: Array; icon?: Maybe; @@ -2326,7 +2327,7 @@ export type DeleteOneRelationMetadataItemMutation = { __typename?: 'Mutation', d export type ObjectMetadataItemsQueryVariables = Exact<{ [key: string]: never; }>; -export type ObjectMetadataItemsQuery = { __typename?: 'Query', objects: { __typename?: 'ObjectConnection', edges: Array<{ __typename?: 'ObjectEdge', node: { __typename?: 'Object', id: any, dataSourceId: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, createdAt: any, updatedAt: any, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, indexMetadatas: { __typename?: 'ObjectIndexMetadatasConnection', edges: Array<{ __typename?: 'IndexEdge', node: { __typename?: 'Index', id: any, createdAt: any, updatedAt: any, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, indexFieldMetadatas: { __typename?: 'IndexIndexFieldMetadatasConnection', edges: Array<{ __typename?: 'IndexFieldEdge', node: { __typename?: 'IndexField', id: any, createdAt: any, updatedAt: any, order: number, fieldMetadataId: any } }> } } }> }, fieldsList: Array<{ __typename?: 'Field', id: any, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: any, updatedAt: any, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relationDefinition?: { __typename?: 'RelationDefinition', relationId: any, direction: RelationDefinitionType, sourceObjectMetadata: { __typename?: 'Object', id: any, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: any, name: string }, targetObjectMetadata: { __typename?: 'Object', id: any, nameSingular: string, namePlural: string }, targetFieldMetadata: { __typename?: 'Field', id: any, name: string } } | null }> } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage?: boolean | null, hasPreviousPage?: boolean | null, startCursor?: any | null, endCursor?: any | null } } }; +export type ObjectMetadataItemsQuery = { __typename?: 'Query', objects: { __typename?: 'ObjectConnection', edges: Array<{ __typename?: 'ObjectEdge', node: { __typename?: 'Object', id: any, dataSourceId: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, createdAt: any, updatedAt: any, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, duplicateCriteria?: Array> | null, indexMetadatas: { __typename?: 'ObjectIndexMetadatasConnection', edges: Array<{ __typename?: 'IndexEdge', node: { __typename?: 'Index', id: any, createdAt: any, updatedAt: any, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, indexFieldMetadatas: { __typename?: 'IndexIndexFieldMetadatasConnection', edges: Array<{ __typename?: 'IndexFieldEdge', node: { __typename?: 'IndexField', id: any, createdAt: any, updatedAt: any, order: number, fieldMetadataId: any } }> } } }> }, fieldsList: Array<{ __typename?: 'Field', id: any, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: any, updatedAt: any, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relationDefinition?: { __typename?: 'RelationDefinition', relationId: any, direction: RelationDefinitionType, sourceObjectMetadata: { __typename?: 'Object', id: any, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: any, name: string }, targetObjectMetadata: { __typename?: 'Object', id: any, nameSingular: string, namePlural: string }, targetFieldMetadata: { __typename?: 'Field', id: any, name: string } } | null }> } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage?: boolean | null, hasPreviousPage?: boolean | null, startCursor?: any | null, endCursor?: any | null } } }; export type ServerlessFunctionFieldsFragment = { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, timeoutSeconds: number, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, latestVersionInputSchema?: any | null, publishedVersions: Array, createdAt: any, updatedAt: any }; @@ -2418,7 +2419,7 @@ export const UpdateOneObjectMetadataItemDocument = {"kind":"Document","definitio export const DeleteOneObjectMetadataItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOneObjectMetadataItem"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneObject"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}},{"kind":"Field","name":{"kind":"Name","value":"labelSingular"}},{"kind":"Field","name":{"kind":"Name","value":"labelPlural"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"labelIdentifierFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"imageIdentifierFieldMetadataId"}}]}}]}}]} as unknown as DocumentNode; export const DeleteOneFieldMetadataItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOneFieldMetadataItem"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneField"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"isNullable"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"settings"}}]}}]}}]} as unknown as DocumentNode; export const DeleteOneRelationMetadataItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOneRelationMetadataItem"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneRelation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; -export const ObjectMetadataItemsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ObjectMetadataItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1000"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}},{"kind":"Field","name":{"kind":"Name","value":"labelSingular"}},{"kind":"Field","name":{"kind":"Name","value":"labelPlural"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isRemote"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"labelIdentifierFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"imageIdentifierFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"shortcut"}},{"kind":"Field","name":{"kind":"Name","value":"isLabelSyncedWithName"}},{"kind":"Field","name":{"kind":"Name","value":"indexMetadatas"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"indexWhereClause"}},{"kind":"Field","name":{"kind":"Name","value":"indexType"}},{"kind":"Field","name":{"kind":"Name","value":"isUnique"}},{"kind":"Field","name":{"kind":"Name","value":"indexFieldMetadatas"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}}]}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"fieldsList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}},{"kind":"Field","name":{"kind":"Name","value":"isNullable"}},{"kind":"Field","name":{"kind":"Name","value":"isUnique"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"defaultValue"}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"settings"}},{"kind":"Field","name":{"kind":"Name","value":"isLabelSyncedWithName"}},{"kind":"Field","name":{"kind":"Name","value":"relationDefinition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"relationId"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"sourceObjectMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}}]}},{"kind":"Field","name":{"kind":"Name","value":"sourceFieldMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"targetObjectMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}}]}},{"kind":"Field","name":{"kind":"Name","value":"targetFieldMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; +export const ObjectMetadataItemsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ObjectMetadataItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1000"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}},{"kind":"Field","name":{"kind":"Name","value":"labelSingular"}},{"kind":"Field","name":{"kind":"Name","value":"labelPlural"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isRemote"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"labelIdentifierFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"imageIdentifierFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"shortcut"}},{"kind":"Field","name":{"kind":"Name","value":"isLabelSyncedWithName"}},{"kind":"Field","name":{"kind":"Name","value":"duplicateCriteria"}},{"kind":"Field","name":{"kind":"Name","value":"indexMetadatas"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"indexWhereClause"}},{"kind":"Field","name":{"kind":"Name","value":"indexType"}},{"kind":"Field","name":{"kind":"Name","value":"isUnique"}},{"kind":"Field","name":{"kind":"Name","value":"indexFieldMetadatas"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}}]}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"fieldsList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}},{"kind":"Field","name":{"kind":"Name","value":"isNullable"}},{"kind":"Field","name":{"kind":"Name","value":"isUnique"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"defaultValue"}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"settings"}},{"kind":"Field","name":{"kind":"Name","value":"isLabelSyncedWithName"}},{"kind":"Field","name":{"kind":"Name","value":"relationDefinition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"relationId"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"sourceObjectMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}}]}},{"kind":"Field","name":{"kind":"Name","value":"sourceFieldMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"targetObjectMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}}]}},{"kind":"Field","name":{"kind":"Name","value":"targetFieldMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; export const BuildDraftServerlessFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BuildDraftServerlessFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BuildDraftServerlessFunctionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"buildDraftServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"timeoutSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersionInputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"publishedVersions"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const CreateOneServerlessFunctionItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOneServerlessFunctionItem"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateServerlessFunctionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOneServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"timeoutSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersionInputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"publishedVersions"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const DeleteOneServerlessFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOneServerlessFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunctionIdInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"timeoutSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersionInputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"publishedVersions"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 2b007e112..dfbab9638 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1070,6 +1070,7 @@ export type Object = { createdAt: Scalars['DateTime']; dataSourceId: Scalars['String']; description?: Maybe; + duplicateCriteria?: Maybe>>; fields: ObjectFieldsConnection; fieldsList: Array; icon?: Maybe; diff --git a/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts b/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts index bf9619a95..3229635d2 100644 --- a/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts +++ b/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts @@ -23,6 +23,7 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql` imageIdentifierFieldMetadataId shortcut isLabelSyncedWithName + duplicateCriteria indexMetadatas(paging: { first: 100 }) { edges { node { diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts index aac408d01..05dda142e 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts @@ -17,6 +17,7 @@ export const useFindDuplicateRecords = ({ objectRecordIds = [], objectNameSingular, onCompleted, + skip, }: ObjectMetadataItemIdentifier & { objectRecordIds: string[] | undefined; onCompleted?: (data: RecordGqlConnection[]) => void; @@ -42,6 +43,7 @@ export const useFindDuplicateRecords = ({ useQuery( findDuplicateRecordsQuery, { + skip: !!skip, variables: { ids: objectRecordIds, }, diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailDuplicatesSection.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailDuplicatesSection.tsx index 74690db36..dc92ea674 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailDuplicatesSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailDuplicatesSection.tsx @@ -1,9 +1,11 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { RecordChip } from '@/object-record/components/RecordChip'; import { useFindDuplicateRecords } from '@/object-record/hooks/useFindDuplicateRecords'; import { RecordDetailRecordsList } from '@/object-record/record-show/record-detail-section/components/RecordDetailRecordsList'; import { RecordDetailRecordsListItem } from '@/object-record/record-show/record-detail-section/components/RecordDetailRecordsListItem'; import { RecordDetailSection } from '@/object-record/record-show/record-detail-section/components/RecordDetailSection'; import { RecordDetailSectionHeader } from '@/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader'; +import { isDefined } from 'twenty-shared'; export const RecordDetailDuplicatesSection = ({ objectRecordId, @@ -12,9 +14,14 @@ export const RecordDetailDuplicatesSection = ({ objectRecordId: string; objectNameSingular: string; }) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + const { results: queryResults } = useFindDuplicateRecords({ objectRecordIds: [objectRecordId], objectNameSingular, + skip: !isDefined(objectMetadataItem.duplicateCriteria), }); if (!queryResults || !queryResults[0] || queryResults[0].length === 0) diff --git a/packages/twenty-server/src/database/typeorm/metadata/migrations/1738853620654-addDuplicateCriteriaColumnInObjectMetadata.ts b/packages/twenty-server/src/database/typeorm/metadata/migrations/1738853620654-addDuplicateCriteriaColumnInObjectMetadata.ts new file mode 100644 index 000000000..9635d293a --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/metadata/migrations/1738853620654-addDuplicateCriteriaColumnInObjectMetadata.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDuplicateCriteriaColumnInObjectMetadata1738853620654 + implements MigrationInterface +{ + name = 'AddDuplicateCriteriaColumnInObjectMetadata1738853620654'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "metadata"."objectMetadata" ADD "duplicateCriteria" jsonb`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "metadata"."objectMetadata" DROP COLUMN "duplicateCriteria"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonObjectMetadata.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonObjectMetadata.ts new file mode 100644 index 000000000..b292e7fd4 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonObjectMetadata.ts @@ -0,0 +1,93 @@ +import { FieldMetadataType } from 'twenty-shared'; + +import { WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type'; +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; + +export const mockPersonObjectMetadata = ( + duplicateCriteria: WorkspaceEntityDuplicateCriteria[], +): ObjectMetadataItemWithFieldMaps => ({ + id: '', + standardId: '', + nameSingular: 'person', + namePlural: 'people', + labelSingular: 'Person', + labelPlural: 'People', + description: 'A person', + targetTableName: 'DEPRECATED', + isCustom: false, + isRemote: false, + isActive: true, + isSystem: false, + isAuditLogged: true, + duplicateCriteria: duplicateCriteria, + fromRelations: [], + toRelations: [], + labelIdentifierFieldMetadataId: '', + imageIdentifierFieldMetadataId: '', + workspaceId: '', + fields: [], + indexMetadatas: [], + fieldsById: {}, + fieldsByName: { + name: { + id: '', + objectMetadataId: '', + type: FieldMetadataType.FULL_NAME, + name: 'name', + label: 'Name', + defaultValue: { + lastName: "''", + firstName: "''", + }, + description: 'Contact’s name', + isCustom: false, + isNullable: true, + isUnique: false, + workspaceId: '', + }, + emails: { + id: '', + objectMetadataId: '', + type: FieldMetadataType.EMAILS, + name: 'emails', + label: 'Emails', + defaultValue: { + primaryEmail: "''", + additionalEmails: null, + }, + description: 'Contact’s Emails', + isCustom: false, + workspaceId: '', + }, + linkedinLink: { + id: '', + objectMetadataId: '', + type: FieldMetadataType.LINKS, + name: 'linkedinLink', + label: 'Linkedin', + defaultValue: { + primaryLinkUrl: "''", + secondaryLinks: "'[]'", + primaryLinkLabel: "''", + }, + description: 'Contact’s Linkedin account', + isCustom: false, + isNullable: true, + isUnique: false, + workspaceId: '', + }, + jobTitle: { + id: '', + objectMetadataId: '', + type: FieldMetadataType.TEXT, + name: 'jobTitle', + label: 'Job Title', + defaultValue: "''", + description: 'Contact’s job title', + isCustom: false, + isNullable: false, + isUnique: false, + workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419', + }, + }, +}); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonRecords.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonRecords.ts new file mode 100644 index 000000000..ef05cb37e --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonRecords.ts @@ -0,0 +1,20 @@ +import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +export const mockPersonRecords: Partial[] = [ + { + name: { + firstName: 'Testfirst', + lastName: 'Testlast', + }, + emails: { + primaryEmail: 'test@test.fr', + additionalEmails: [], + }, + linkedinLink: { + primaryLinkLabel: '', + primaryLinkUrl: '', + secondaryLinks: [], + }, + jobTitle: 'Test job', + }, +]; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__tests__/graphql-query-find-duplicates-resolver.service.spec.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__tests__/graphql-query-find-duplicates-resolver.service.spec.ts new file mode 100644 index 000000000..fb5694f91 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__tests__/graphql-query-find-duplicates-resolver.service.spec.ts @@ -0,0 +1,125 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { mockPersonObjectMetadata } from 'src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonObjectMetadata'; +import { mockPersonRecords } from 'src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonRecords'; +import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; +import { GraphqlQueryFindDuplicatesResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service'; +import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service'; +import { QueryResultGettersFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/query-result-getters.factory'; +import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory'; +import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; + +describe('GraphqlQueryFindDuplicatesResolverService', () => { + let service: GraphqlQueryFindDuplicatesResolverService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GraphqlQueryFindDuplicatesResolverService, + WorkspaceQueryHookService, + QueryRunnerArgsFactory, + QueryResultGettersFactory, + ApiEventEmitterService, + TwentyORMGlobalManager, + ProcessNestedRelationsHelper, + FeatureFlagService, + PermissionsService, + ], + }) + .overrideProvider(WorkspaceQueryHookService) + .useValue({}) + .overrideProvider(QueryRunnerArgsFactory) + .useValue({}) + .overrideProvider(QueryResultGettersFactory) + .useValue({}) + .overrideProvider(ApiEventEmitterService) + .useValue({}) + .overrideProvider(TwentyORMGlobalManager) + .useValue({}) + .overrideProvider(ProcessNestedRelationsHelper) + .useValue({}) + .overrideProvider(FeatureFlagService) + .useValue({}) + .overrideProvider(PermissionsService) + .useValue({}) + .compile(); + + service = module.get( + GraphqlQueryFindDuplicatesResolverService, + ); + }); + + describe('buildDuplicateConditions', () => { + it('should build conditions based on duplicate criteria from composite field', () => { + const duplicateConditons = service.buildDuplicateConditions( + mockPersonObjectMetadata([['emailsPrimaryEmail']]), + mockPersonRecords, + 'recordId', + ); + + expect(duplicateConditons).toEqual({ + or: [ + { + emailsPrimaryEmail: { + eq: 'test@test.fr', + }, + }, + ], + id: { + neq: 'recordId', + }, + }); + }); + + it('should build conditions based on duplicate criteria from basic field', () => { + const duplicateConditons = service.buildDuplicateConditions( + mockPersonObjectMetadata([['jobTitle']]), + mockPersonRecords, + 'recordId', + ); + + expect(duplicateConditons).toEqual({ + or: [ + { + jobTitle: { + eq: 'Test job', + }, + }, + ], + id: { + neq: 'recordId', + }, + }); + }); + + it('should not build conditions based on duplicate criteria if record value is null or too small', () => { + const duplicateConditons = service.buildDuplicateConditions( + mockPersonObjectMetadata([['linkedinLinkPrimaryLinkUrl']]), + mockPersonRecords, + 'recordId', + ); + + expect(duplicateConditons).toEqual({}); + }); + + it('should build conditions based on duplicate criteria and without recordId filter', () => { + const duplicateConditons = service.buildDuplicateConditions( + mockPersonObjectMetadata([['jobTitle']]), + mockPersonRecords, + ); + + expect(duplicateConditons).toEqual({ + or: [ + { + jobTitle: { + eq: 'Test job', + }, + }, + ], + }); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts index 19ca206fa..61aab2d89 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts @@ -23,11 +23,13 @@ import { import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; import { settings } from 'src/engine/constants/settings'; -import { DUPLICATE_CRITERIA_COLLECTION } from 'src/engine/core-modules/duplicate/constants/duplicate-criteria.constants'; import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; import { formatData } from 'src/engine/twenty-orm/utils/format-data.util'; -import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; +import { + formatResult, + getCompositeFieldMetadataMap, +} from 'src/engine/twenty-orm/utils/format-result.util'; @Injectable() export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseResolverService< @@ -149,7 +151,7 @@ export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseR return duplicateConnections; } - private buildDuplicateConditions( + buildDuplicateConditions( objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, records?: Partial[] | undefined, filteringByExistingRecordId?: string, @@ -158,13 +160,21 @@ export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseR return {}; } - const criteriaCollection = this.getApplicableDuplicateCriteriaCollection( + const criteriaCollection = + objectMetadataItemWithFieldMaps.duplicateCriteria || []; + + const formattedRecords = formatData( + records, objectMetadataItemWithFieldMaps, ); - const conditions = records.flatMap((record) => { + const compositeFieldMetadataMap = getCompositeFieldMetadataMap( + objectMetadataItemWithFieldMaps, + ); + + const conditions = formattedRecords.flatMap((record) => { const criteriaWithMatchingArgs = criteriaCollection.filter((criteria) => - criteria.columnNames.every((columnName) => { + criteria.every((columnName) => { const value = record[columnName] as string | undefined; return ( @@ -176,8 +186,18 @@ export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseR return criteriaWithMatchingArgs.map((criteria) => { const condition = {}; - criteria.columnNames.forEach((columnName) => { - condition[columnName] = { eq: record[columnName] }; + criteria.forEach((columnName) => { + const compositeFieldMetadata = + compositeFieldMetadataMap.get(columnName); + + if (compositeFieldMetadata) { + condition[compositeFieldMetadata.parentField] = { + ...condition[compositeFieldMetadata.parentField], + [compositeFieldMetadata.name]: { eq: record[columnName] }, + }; + } else { + condition[columnName] = { eq: record[columnName] }; + } }); return condition; @@ -197,16 +217,6 @@ export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseR return filter; } - private getApplicableDuplicateCriteriaCollection( - objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, - ) { - return DUPLICATE_CRITERIA_COLLECTION.filter( - (duplicateCriteria) => - duplicateCriteria.objectName === - objectMetadataItemWithFieldMaps.nameSingular, - ); - } - async validate( args: FindDuplicatesResolverArgs, _options: WorkspaceQueryRunnerOptions, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type.ts new file mode 100644 index 000000000..cd7e23488 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type.ts @@ -0,0 +1,3 @@ +type columnName = string; + +export type WorkspaceEntityDuplicateCriteria = columnName[]; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts index 3aafdd792..028c2b74e 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { GraphqlQueryRunnerModule } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module'; import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module'; +import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { WorkspaceResolverFactory } from './workspace-resolver.factory'; @@ -14,7 +15,11 @@ import { workspaceResolverBuilderFactories } from './factories/factories'; GraphqlQueryRunnerModule, FeatureFlagModule, ], - providers: [...workspaceResolverBuilderFactories, WorkspaceResolverFactory], - exports: [WorkspaceResolverFactory], + providers: [ + ...workspaceResolverBuilderFactories, + WorkspaceResolverFactory, + WorkspaceResolverBuilderService, + ], + exports: [WorkspaceResolverFactory, WorkspaceResolverBuilderService], }) export class WorkspaceResolverBuilderModule {} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service.ts new file mode 100644 index 000000000..f7f2cead5 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared'; + +import { WorkspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; +import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; + +import { FindDuplicatesResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory'; + +@Injectable() +export class WorkspaceResolverBuilderService { + constructor() {} + + shouldBuildResolver( + objectMetadata: ObjectMetadataInterface, + methodName: WorkspaceResolverBuilderMethodNames, + ) { + switch (methodName) { + case FindDuplicatesResolverFactory.methodName: + return isDefined(objectMetadata.duplicateCriteria); + default: + return true; + } + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts index badbe097a..deca77c7f 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts @@ -9,6 +9,7 @@ import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-res import { RestoreOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory'; import { SearchResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory'; import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory'; +import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { getResolverName } from 'src/engine/utils/get-resolver-name.util'; @@ -45,6 +46,7 @@ export class WorkspaceResolverFactory { private readonly restoreManyResolverFactory: RestoreManyResolverFactory, private readonly destroyManyResolverFactory: DestroyManyResolverFactory, private readonly searchResolverFactory: SearchResolverFactory, + private readonly workspaceResolverBuilderService: WorkspaceResolverBuilderService, ) {} async create( @@ -92,11 +94,18 @@ export class WorkspaceResolverFactory { throw new Error(`Unknown query resolver type: ${methodName}`); } - resolvers.Query[resolverName] = resolverFactory.create({ - authContext, - objectMetadataMaps, - objectMetadataItemWithFieldMaps: objectMetadata, - }); + if ( + this.workspaceResolverBuilderService.shouldBuildResolver( + objectMetadata, + methodName, + ) + ) { + resolvers.Query[resolverName] = resolverFactory.create({ + authContext, + objectMetadataMaps, + objectMetadataItemWithFieldMaps: objectMetadata, + }); + } } // Generate mutation resolvers diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/root-type.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/root-type.factory.ts index 9f92a2e58..8c1f86625 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/root-type.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/root-type.factory.ts @@ -6,6 +6,7 @@ import { WorkspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/work import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface'; import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; +import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service'; import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; import { TypeDefinitionsStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/type-definitions.storage'; import { getResolverArgs } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util'; @@ -28,6 +29,7 @@ export class RootTypeFactory { private readonly typeDefinitionsStorage: TypeDefinitionsStorage, private readonly typeMapperService: TypeMapperService, private readonly argsFactory: ArgsFactory, + private readonly workspaceResolverBuilderService: WorkspaceResolverBuilderService, ) {} create( @@ -70,53 +72,60 @@ export class RootTypeFactory { for (const objectMetadata of objectMetadataCollection) { for (const methodName of workspaceResolverMethodNames) { - const name = getResolverName(objectMetadata, methodName); - const args = getResolverArgs(methodName); - const objectType = this.typeDefinitionsStorage.getObjectTypeByKey( - objectMetadata.id, - this.getObjectTypeDefinitionKindByMethodName(methodName), - ); - const argsType = this.argsFactory.create( - { - args, - objectMetadataId: objectMetadata.id, - }, - options, - ); - - if (!objectType) { - this.logger.error( - `Could not find a GraphQL type for ${objectMetadata.id} for method ${methodName}`, + if ( + this.workspaceResolverBuilderService.shouldBuildResolver( + objectMetadata, + methodName, + ) + ) { + const name = getResolverName(objectMetadata, methodName); + const args = getResolverArgs(methodName); + const objectType = this.typeDefinitionsStorage.getObjectTypeByKey( + objectMetadata.id, + this.getObjectTypeDefinitionKindByMethodName(methodName), + ); + const argsType = this.argsFactory.create( { - objectMetadata, - methodName, - options, + args, + objectMetadataId: objectMetadata.id, }, + options, ); - throw new Error( - `Could not find a GraphQL type for ${objectMetadata.id} for method ${methodName}`, - ); + if (!objectType) { + this.logger.error( + `Could not find a GraphQL type for ${objectMetadata.id} for method ${methodName}`, + { + objectMetadata, + methodName, + options, + }, + ); + + throw new Error( + `Could not find a GraphQL type for ${objectMetadata.id} for method ${methodName}`, + ); + } + + const allowedMethodNames = [ + 'updateMany', + 'deleteMany', + 'createMany', + 'findDuplicates', + 'restoreMany', + 'destroyMany', + ]; + + const outputType = this.typeMapperService.mapToGqlType(objectType, { + isArray: allowedMethodNames.includes(methodName), + }); + + fieldConfigMap[name] = { + type: outputType, + args: argsType, + resolve: undefined, + }; } - - const allowedMethodNames = [ - 'updateMany', - 'deleteMany', - 'createMany', - 'findDuplicates', - 'restoreMany', - 'destroyMany', - ]; - - const outputType = this.typeMapperService.mapToGqlType(objectType, { - isArray: allowedMethodNames.includes(methodName), - }); - - fieldConfigMap[name] = { - type: outputType, - args: argsType, - resolve: undefined, - }; } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/workspace-schema-builder.module.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/workspace-schema-builder.module.ts index 969de9f55..ee4e859a4 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/workspace-schema-builder.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/workspace-schema-builder.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; +import { WorkspaceResolverBuilderModule } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; @@ -11,7 +12,11 @@ import { TypeMapperService } from './services/type-mapper.service'; import { TypeDefinitionsStorage } from './storages/type-definitions.storage'; @Module({ - imports: [ObjectMetadataModule, FeatureFlagModule], + imports: [ + ObjectMetadataModule, + FeatureFlagModule, + WorkspaceResolverBuilderModule, + ], providers: [ TypeDefinitionsStorage, TypeMapperService, diff --git a/packages/twenty-server/src/engine/core-modules/duplicate/constants/duplicate-criteria.constants.ts b/packages/twenty-server/src/engine/core-modules/duplicate/constants/duplicate-criteria.constants.ts deleted file mode 100644 index e79a55d37..000000000 --- a/packages/twenty-server/src/engine/core-modules/duplicate/constants/duplicate-criteria.constants.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ObjectRecordDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; - -/** - * objectName: directly reference the name of the object from the metadata tables. - * columnNames: reference the column names not the field names. - * So if we need to reference a custom field, we should directly add the column name like `_customColumn`. - * If we need to terence a composite field, we should add all children of the composite like `nameFirstName` and `nameLastName` - */ -export const DUPLICATE_CRITERIA_COLLECTION: ObjectRecordDuplicateCriteria[] = [ - { - objectName: 'company', - columnNames: ['domainName'], - }, - { - objectName: 'company', - columnNames: ['name'], - }, - { - objectName: 'person', - columnNames: ['nameFirstName', 'nameLastName'], - }, - { - objectName: 'person', - columnNames: ['linkedinLinkPrimaryLinkUrl'], - }, - { - objectName: 'person', - columnNames: ['email'], - }, -]; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface.ts index 6fc29826b..194ccf8fd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface.ts @@ -1,5 +1,7 @@ import { IndexMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-metadata.interface'; +import { WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type'; + import { FieldMetadataInterface } from './field-metadata.interface'; import { RelationMetadataInterface } from './relation-metadata.interface'; @@ -22,6 +24,7 @@ export interface ObjectMetadataInterface { isActive: boolean; isRemote: boolean; isAuditLogged: boolean; + duplicateCriteria?: WorkspaceEntityDuplicateCriteria[]; labelIdentifierFieldMetadataId?: string | null; imageIdentifierFieldMetadataId?: string | null; } diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto.ts index f3343415d..515a21224 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto.ts @@ -9,6 +9,7 @@ import { QueryOptions, } from '@ptc-org/nestjs-query-graphql'; +import { WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto'; @@ -85,4 +86,7 @@ export class ObjectMetadataDTO { @Field() isLabelSyncedWithName: boolean; + + @Field(() => [[String]], { nullable: true }) + duplicateCriteria?: WorkspaceEntityDuplicateCriteria[]; } diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts index eeea46d11..c60112528 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts @@ -12,6 +12,7 @@ import { import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; +import { WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type'; import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; @@ -69,6 +70,9 @@ export class ObjectMetadataEntity implements ObjectMetadataInterface { @Column({ default: true }) isAuditLogged: boolean; + @Column({ type: 'jsonb', nullable: true }) + duplicateCriteria?: WorkspaceEntityDuplicateCriteria[]; + @Column({ nullable: true }) shortcut: string; diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-duplicate-criteria.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-duplicate-criteria.decorator.ts new file mode 100644 index 000000000..3f4b0e651 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-duplicate-criteria.decorator.ts @@ -0,0 +1,14 @@ +import { WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type'; +import { TypedReflect } from 'src/utils/typed-reflect'; + +export function WorkspaceDuplicateCriteria( + duplicateCriteria: WorkspaceEntityDuplicateCriteria[], +): ClassDecorator { + return (target) => { + TypedReflect.defineMetadata( + 'workspace:duplicate-criteria-metadata-args', + duplicateCriteria, + target, + ); + }; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-entity.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-entity.decorator.ts index 6def389c5..77f172f51 100644 --- a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-entity.decorator.ts +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-entity.decorator.ts @@ -33,6 +33,11 @@ export function WorkspaceEntity( 'workspace:gate-metadata-args', target, ); + const duplicateCriteria = TypedReflect.getMetadata( + 'workspace:duplicate-criteria-metadata-args', + target, + ); + const objectName = convertClassNameToObjectMetadataName(target.name); metadataArgsStorage.addEntities({ @@ -51,6 +56,7 @@ export function WorkspaceEntity( isAuditLogged, isSystem, gate, + duplicateCriteria, }); }; } diff --git a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-entity-metadata-args.interface.ts b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-entity-metadata-args.interface.ts index 703da6249..ff230abe2 100644 --- a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-entity-metadata-args.interface.ts +++ b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-entity-metadata-args.interface.ts @@ -1,5 +1,7 @@ import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface'; +import { WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type'; + export interface WorkspaceEntityMetadataArgs { /** * Standard id. @@ -65,4 +67,9 @@ export interface WorkspaceEntityMetadataArgs { * Image identifier. */ readonly imageIdentifierStandardId: string | null; + + /** + * Duplicate criteria. + */ + readonly duplicateCriteria?: WorkspaceEntityDuplicateCriteria[]; } diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts index 956880cff..4d1a371cb 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts @@ -40,27 +40,10 @@ export function formatResult( throw new Error('Object metadata is missing'); } - const compositeFieldMetadataCollection = getCompositeFieldMetadataCollection( + const compositeFieldMetadataMap = getCompositeFieldMetadataMap( objectMetadataItemWithFieldMaps, ); - const compositeFieldMetadataMap = new Map( - compositeFieldMetadataCollection.flatMap((fieldMetadata) => { - const compositeType = compositeTypeDefinitions.get(fieldMetadata.type); - - if (!compositeType) return []; - - // Map each composite property to a [key, value] pair - return compositeType.properties.map((compositeProperty) => [ - computeCompositeColumnName(fieldMetadata.name, compositeProperty), - { - parentField: fieldMetadata.name, - ...compositeProperty, - }, - ]); - }), - ); - const relationMetadataMap = new Map( Object.values(objectMetadataItemWithFieldMaps.fieldsById) .filter(({ type }) => isRelationFieldMetadataType(type)) @@ -199,6 +182,31 @@ export function formatResult( return newData as T; } +export function getCompositeFieldMetadataMap( + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, +) { + const compositeFieldMetadataCollection = getCompositeFieldMetadataCollection( + objectMetadataItemWithFieldMaps, + ); + + return new Map( + compositeFieldMetadataCollection.flatMap((fieldMetadata) => { + const compositeType = compositeTypeDefinitions.get(fieldMetadata.type); + + if (!compositeType) return []; + + // Map each composite property to a [key, value] pair + return compositeType.properties.map((compositeProperty) => [ + computeCompositeColumnName(fieldMetadata.name, compositeProperty), + { + parentField: fieldMetadata.name, + ...compositeProperty, + }, + ]); + }), + ); +} + function formatFieldMetadataValue( value: any, fieldMetadata: FieldMetadataInterface, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-object.comparator.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-object.comparator.ts index 350fdaf53..df553b5d6 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-object.comparator.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-object.comparator.ts @@ -63,9 +63,18 @@ export class WorkspaceObjectComparator { for (const difference of objectMetadataDifference) { // We only handle CHANGE here as REMOVE and CREATE are handled earlier. if (difference.type === 'CHANGE') { + // If the old value and the new value are both null, skip + // Database is storing null, and we can get undefined here + if ( + difference.oldValue === null && + (difference.value === null || difference.value === undefined) + ) { + continue; + } + const property = difference.path[0]; - objectPropertiesToUpdate[property] = difference.value; + objectPropertiesToUpdate[property] = standardObjectMetadata[property]; } } diff --git a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts index b52f7ba46..3814ca23a 100644 --- a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts +++ b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts @@ -14,6 +14,7 @@ import { RelationOnDeleteAction, } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; +import { WorkspaceDuplicateCriteria } from 'src/engine/twenty-orm/decorators/workspace-duplicate-criteria.decorator'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; @@ -57,6 +58,7 @@ export const SEARCH_FIELDS_FOR_COMPANY: FieldTypeAndNameMetadata[] = [ shortcut: 'C', labelIdentifierStandardId: COMPANY_STANDARD_FIELD_IDS.name, }) +@WorkspaceDuplicateCriteria([['name'], ['domainNamePrimaryLinkUrl']]) export class CompanyWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceField({ standardId: COMPANY_STANDARD_FIELD_IDS.name, diff --git a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts index b7f664def..2ca11afad 100644 --- a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts +++ b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts @@ -15,6 +15,7 @@ import { RelationOnDeleteAction, } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; +import { WorkspaceDuplicateCriteria } from 'src/engine/twenty-orm/decorators/workspace-duplicate-criteria.decorator'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; @@ -62,6 +63,11 @@ export const SEARCH_FIELDS_FOR_PERSON: FieldTypeAndNameMetadata[] = [ labelIdentifierStandardId: PERSON_STANDARD_FIELD_IDS.name, imageIdentifierStandardId: PERSON_STANDARD_FIELD_IDS.avatarUrl, }) +@WorkspaceDuplicateCriteria([ + ['nameFirstName', 'nameLastName'], + ['linkedinLinkPrimaryLinkUrl'], + ['emailsPrimaryEmail'], +]) export class PersonWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceField({ standardId: PERSON_STANDARD_FIELD_IDS.name, diff --git a/packages/twenty-server/src/utils/typed-reflect.ts b/packages/twenty-server/src/utils/typed-reflect.ts index 650d926a1..600085632 100644 --- a/packages/twenty-server/src/utils/typed-reflect.ts +++ b/packages/twenty-server/src/utils/typed-reflect.ts @@ -2,6 +2,7 @@ import 'reflect-metadata'; import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface'; +import { WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type'; import { EnvironmentVariablesMetadataMap } from 'src/engine/core-modules/environment/decorators/environment-variables-metadata.decorator'; export interface ReflectMetadataTypeMap { @@ -12,6 +13,7 @@ export interface ReflectMetadataTypeMap { ['workspace:is-primary-field-metadata-args']: true; ['workspace:is-deprecated-field-metadata-args']: true; ['workspace:is-unique-metadata-args']: true; + ['workspace:duplicate-criteria-metadata-args']: WorkspaceEntityDuplicateCriteria[]; ['environment-variables']: EnvironmentVariablesMetadataMap; }