From 1b04dfe3c66009c9a39370d74cedbb44fa6c652b Mon Sep 17 00:00:00 2001 From: rostaklein Date: Sat, 24 Feb 2024 19:12:21 +0100 Subject: [PATCH] feat: find duplicate objects init (#4038) * feat: find duplicate objects backend init * refactor: move duplicate criteria to constants * fix: correct constant usage after type change * feat: skip query generation in case its not necessary * feat: filter out existing duplicate * feat: FE queries and hooks * feat: show duplicates on FE * refactor: should-skip-query moved to workspace utils * refactor: naming improvements * refactor: current record typings/parsing improvements * refactor: throw error if existing record not found * fix: domain -> domainName duplicate criteria * refactor: fieldNames -> columnNames * docs: add explanation to duplicate criteria collection * feat: add person linkedinLinkUrl as duplicate criteria * feat: throw early when bot id and data are empty * refactor: trying to improve readability of filter criteria query * refactor: naming improvements * refactor: remove shouldSkipQuery * feat: resolve empty array in case of empty filter * feat: hide whole section in case of no duplicates * feat: FE display list the same way as relations * test: basic unit test coverage * Refactor Record detail section front * Use Create as input argument of findDuplicates * Improve coverage * Fix --------- Co-authored-by: Charles Bochet --- packages/twenty-front/nyc.config.cjs | 13 +- .../hooks/useObjectMetadataItem.ts | 9 + .../hooks/useFindDuplicateRecords.ts | 79 ++++++++ .../useGenerateFindDuplicateRecordsQuery.ts | 42 +++++ .../components/RecordShowContainer.tsx | 7 +- .../components/RecordDetailSectionHeader.tsx | 62 +++++++ .../RecordDuplicatesFieldCardSection.tsx | 51 +++++ .../RecordRelationFieldCardContent.tsx | 0 .../RecordRelationFieldCardSection.tsx | 134 +++++--------- .../ui/input/components/IconPicker.tsx | 2 +- .../components/SelectableList.tsx | 2 +- ...to-chunks.test.ts => arrayToCunks.test.ts} | 2 +- .../array/__tests__/moveArrayItem.test.ts | 33 ++++ .../{array-to-chunks.ts => arrayToChunks.ts} | 0 .../workspace/utils/get-resolver-name.util.ts | 2 + .../find-duplicates-query.factory.spec.ts | 175 ++++++++++++++++++ .../factories/factories.ts | 2 + .../find-duplicates-query.factory.ts | 146 +++++++++++++++ .../interfaces/record.interface.ts | 5 + .../workspace-query-builder-options.ts | 13 ++ .../workspace-query-builder.factory.ts | 25 +++ .../types/workspace-query-hook.type.ts | 6 +- .../workspace-query-runner.service.ts | 72 +++++++ .../constants/duplicate-criteria.constants.ts | 30 +++ .../factories/factories.ts | 3 + .../find-duplicates-resolver.factory.ts | 38 ++++ .../workspace-resolvers-builder.interface.ts | 6 + .../workspace-resolver.factory.ts | 3 + .../factories/root-type.factory.ts | 2 +- .../utils/get-resolver-args.util.ts | 11 ++ 30 files changed, 875 insertions(+), 100 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts create mode 100644 packages/twenty-front/src/modules/object-record/hooks/useGenerateFindDuplicateRecordsQuery.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDuplicatesFieldCardSection.tsx rename packages/twenty-front/src/modules/object-record/{record-relation-card => record-show/record-detail-section}/components/RecordRelationFieldCardContent.tsx (100%) rename packages/twenty-front/src/modules/object-record/{record-relation-card => record-show/record-detail-section}/components/RecordRelationFieldCardSection.tsx (68%) rename packages/twenty-front/src/utils/array/__tests__/{array-to-chunks.test.ts => arrayToCunks.test.ts} (75%) create mode 100644 packages/twenty-front/src/utils/array/__tests__/moveArrayItem.test.ts rename packages/twenty-front/src/utils/array/{array-to-chunks.ts => arrayToChunks.ts} (100%) create mode 100644 packages/twenty-server/src/workspace/workspace-query-builder/factories/__tests__/find-duplicates-query.factory.spec.ts create mode 100644 packages/twenty-server/src/workspace/workspace-query-builder/factories/find-duplicates-query.factory.ts create mode 100644 packages/twenty-server/src/workspace/workspace-query-builder/utils-test/workspace-query-builder-options.ts create mode 100644 packages/twenty-server/src/workspace/workspace-resolver-builder/constants/duplicate-criteria.constants.ts create mode 100644 packages/twenty-server/src/workspace/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts diff --git a/packages/twenty-front/nyc.config.cjs b/packages/twenty-front/nyc.config.cjs index 9ed6d6da5..bf92d9cde 100644 --- a/packages/twenty-front/nyc.config.cjs +++ b/packages/twenty-front/nyc.config.cjs @@ -6,17 +6,18 @@ const globalCoverage = { }; const modulesCoverage = { - statements: 50, - lines: 50, - functions: 45, + statements: 75, + lines: 75, + functions: 70, include: ['src/modules/**/*'], + exclude: ['src/**/*.ts'], }; const pagesCoverage = { - statements: 50, - lines: 50, + statements: 60, + lines: 60, functions: 45, - exclude: ['src/generated/**/*', 'src/modules/**/*', '*.ts'], + exclude: ['src/generated/**/*', 'src/modules/**/*', 'src/**/*.ts'], }; const storybookStoriesFolders = process.env.STORYBOOK_SCOPE; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts index f25ddeaa3..f7abee464 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts @@ -16,6 +16,7 @@ import { useGenerateCreateManyRecordMutation } from '@/object-record/hooks/useGe import { useGenerateCreateOneRecordMutation } from '@/object-record/hooks/useGenerateCreateOneRecordMutation'; import { useGenerateDeleteManyRecordMutation } from '@/object-record/hooks/useGenerateDeleteManyRecordMutation'; import { useGenerateExecuteQuickActionOnOneRecordMutation } from '@/object-record/hooks/useGenerateExecuteQuickActionOnOneRecordMutation'; +import { useGenerateFindDuplicateRecordsQuery } from '@/object-record/hooks/useGenerateFindDuplicateRecordsQuery'; import { useGenerateFindManyRecordsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsQuery'; import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery'; import { useGenerateUpdateOneRecordMutation } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation'; @@ -91,6 +92,13 @@ export const useObjectMetadataItem = ( depth, }); + const generateFindDuplicateRecordsQuery = + useGenerateFindDuplicateRecordsQuery(); + const findDuplicateRecordsQuery = generateFindDuplicateRecordsQuery({ + objectMetadataItem, + depth, + }); + const generateFindOneRecordQuery = useGenerateFindOneRecordQuery(); const findOneRecordQuery = generateFindOneRecordQuery({ objectMetadataItem, @@ -136,6 +144,7 @@ export const useObjectMetadataItem = ( getRecordFromCache, modifyRecordFromCache, findManyRecordsQuery, + findDuplicateRecordsQuery, findOneRecordQuery, createOneRecordMutation, updateOneRecordMutation, diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts new file mode 100644 index 000000000..a9a282c73 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts @@ -0,0 +1,79 @@ +import { useMemo } from 'react'; +import { useQuery } from '@apollo/client'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; +import { useMapConnectionToRecords } from '@/object-record/hooks/useMapConnectionToRecords'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { logError } from '~/utils/logError'; + +import { ObjectRecordQueryResult } from '../types/ObjectRecordQueryResult'; + +export const useFindDuplicateRecords = ({ + objectRecordId = '', + objectNameSingular, + onCompleted, + depth, +}: ObjectMetadataItemIdentifier & { + objectRecordId: string | undefined; + onCompleted?: (data: ObjectRecordConnection) => void; + skip?: boolean; + depth?: number; +}) => { + const findDuplicateQueryStateIdentifier = objectNameSingular; + + const { objectMetadataItem, findDuplicateRecordsQuery } = + useObjectMetadataItem({ objectNameSingular }, depth); + + const { enqueueSnackBar } = useSnackBar(); + + const { data, loading, error } = useQuery>( + findDuplicateRecordsQuery, + { + variables: { + id: objectRecordId, + }, + onCompleted: (data) => { + onCompleted?.(data[objectMetadataItem.nameSingular]); + }, + onError: (error) => { + logError( + `useFindDuplicateRecords for "${objectMetadataItem.nameSingular}" error : ` + + error, + ); + enqueueSnackBar( + `Error during useFindDuplicateRecords for "${objectMetadataItem.nameSingular}", ${error.message}`, + { + variant: 'error', + }, + ); + }, + }, + ); + + const objectRecordConnection = + data?.[`${objectMetadataItem.nameSingular}Duplicates`]; + + const mapConnectionToRecords = useMapConnectionToRecords(); + + const records = useMemo( + () => + mapConnectionToRecords({ + objectRecordConnection, + objectNameSingular, + depth: 5, + }) as T[], + [mapConnectionToRecords, objectRecordConnection, objectNameSingular], + ); + + return { + objectMetadataItem, + records, + totalCount: objectRecordConnection?.totalCount || 0, + loading, + error, + queryStateIdentifier: findDuplicateQueryStateIdentifier, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindDuplicateRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindDuplicateRecordsQuery.ts new file mode 100644 index 000000000..160d6ba2f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindDuplicateRecordsQuery.ts @@ -0,0 +1,42 @@ +import { gql } from '@apollo/client'; + +import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useGenerateFindDuplicateRecordsQuery = () => { + const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); + + return ({ + objectMetadataItem, + depth, + }: { + objectMetadataItem: ObjectMetadataItem; + depth?: number; + }) => gql` + query FindDuplicate${capitalize(objectMetadataItem.nameSingular)}($id: ID) { + ${objectMetadataItem.nameSingular}Duplicates(id: $id){ + edges { + node { + id + ${objectMetadataItem.fields + .map((field) => + mapFieldMetadataToGraphQLQuery({ + field, + maxDepthForRelations: depth, + }), + ) + .join('\n')} + } + cursor + } + pageInfo { + hasNextPage + startCursor + endCursor + } + totalCount + } + } + `; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx index 6adf25e26..3067d4d1b 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx @@ -13,7 +13,8 @@ import { import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell'; import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox'; import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope'; -import { RecordRelationFieldCardSection } from '@/object-record/record-relation-card/components/RecordRelationFieldCardSection'; +import { RecordDuplicatesFieldCardSection } from '@/object-record/record-show/record-detail-section/components/RecordDuplicatesFieldCardSection'; +import { RecordRelationFieldCardSection } from '@/object-record/record-show/record-detail-section/components/RecordRelationFieldCardSection'; import { recordLoadingFamilyState } from '@/object-record/record-store/states/recordLoadingFamilyState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { recordStoreIdentifierFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreIdentifierSelector'; @@ -193,6 +194,10 @@ export const RecordShowContainer = ({ ))} + {relationFieldMetadataItems .filter((item) => { const relationObjectMetadataItem = item.toRelationMetadata diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader.tsx new file mode 100644 index 000000000..35beab269 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import styled from '@emotion/styled'; + +import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; + +const StyledHeader = styled.header<{ isDropdownOpen?: boolean }>` + align-items: center; + display: flex; + height: 24px; + margin-bottom: ${({ theme }) => theme.spacing(2)}; + padding: ${() => (useIsMobile() ? '0 12px' : 'unset')}; +`; + +const StyledTitle = styled.div` + align-items: flex-end; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledTitleLabel = styled.div` + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; + +const StyledLink = styled(Link)` + color: ${({ theme }) => theme.font.color.light}; + text-decoration: none; + font-size: ${({ theme }) => theme.font.size.sm}; + + :hover { + color: ${({ theme }) => theme.font.color.secondary}; + } +`; + +type RecordDetailSectionHeaderProps = { + title: string; + link?: { to: string; label: string }; + rightAdornment?: React.ReactNode; + hideRightAdornmentOnMouseLeave?: boolean; +}; + +export const RecordDetailSectionHeader = ({ + title, + link, + rightAdornment, + hideRightAdornmentOnMouseLeave = true, +}: RecordDetailSectionHeaderProps) => { + const [isHovered, setIsHovered] = useState(false); + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + {title} + {link && {link.label}} + + {hideRightAdornmentOnMouseLeave && !isHovered! ? null : rightAdornment} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDuplicatesFieldCardSection.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDuplicatesFieldCardSection.tsx new file mode 100644 index 000000000..e3beaafe2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDuplicatesFieldCardSection.tsx @@ -0,0 +1,51 @@ +import styled from '@emotion/styled'; + +import { RecordChip } from '@/object-record/components/RecordChip'; +import { useFindDuplicateRecords } from '@/object-record/hooks/useFindDuplicateRecords'; +import { RecordDetailSectionHeader } from '@/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader'; +import { Card } from '@/ui/layout/card/components/Card'; +import { CardContent } from '@/ui/layout/card/components/CardContent'; +import { Section } from '@/ui/layout/section/components/Section'; + +const StyledCardContent = styled(CardContent)` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(4)}; + padding: ${({ theme }) => theme.spacing(3)}; +`; + +export const RecordDuplicatesFieldCardSection = ({ + objectRecordId, + objectNameSingular, +}: { + objectRecordId: string; + objectNameSingular: string; +}) => { + const { records: duplicateRecords } = useFindDuplicateRecords({ + objectRecordId, + objectNameSingular, + }); + + if (duplicateRecords.length === 0) { + return null; + } + + return ( +
+ + + {duplicateRecords.slice(0, 5).map((duplicateRecord, index) => ( + + + + ))} + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardContent.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordRelationFieldCardContent.tsx similarity index 100% rename from packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardContent.tsx rename to packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordRelationFieldCardContent.tsx diff --git a/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordRelationFieldCardSection.tsx similarity index 68% rename from packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx rename to packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordRelationFieldCardSection.tsx index ec49fc348..49e84bb8d 100644 --- a/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordRelationFieldCardSection.tsx @@ -1,6 +1,5 @@ import { useCallback, useContext } from 'react'; -import { Link } from 'react-router-dom'; -import { css, useTheme } from '@emotion/react'; +import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import qs from 'qs'; import { useRecoilValue } from 'recoil'; @@ -10,7 +9,8 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata'; -import { RecordRelationFieldCardContent } from '@/object-record/record-relation-card/components/RecordRelationFieldCardContent'; +import { RecordDetailSectionHeader } from '@/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader'; +import { RecordRelationFieldCardContent } from '@/object-record/record-show/record-detail-section/components/RecordRelationFieldCardContent'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { SingleEntitySelectMenuItemsWithSearch } from '@/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch'; @@ -26,7 +26,6 @@ import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope'; import { Section } from '@/ui/layout/section/components/Section'; -import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { FilterQueryParams } from '@/views/hooks/internal/useFiltersFromQueryParams'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; @@ -34,51 +33,6 @@ const StyledAddDropdown = styled(Dropdown)` margin-left: auto; `; -const StyledHeader = styled.header<{ isDropdownOpen?: boolean }>` - align-items: center; - display: flex; - margin-bottom: ${({ theme }) => theme.spacing(2)}; - padding: ${() => (useIsMobile() ? '0 12px' : 'unset')}; - - ${({ isDropdownOpen, theme }) => - isDropdownOpen - ? '' - : css` - .displayOnHover { - opacity: 0; - pointer-events: none; - transition: opacity ${theme.animation.duration.instant}s ease; - } - `} - - &:hover { - .displayOnHover { - opacity: 1; - pointer-events: auto; - } - } -`; - -const StyledTitle = styled.div` - align-items: flex-end; - display: flex; - gap: ${({ theme }) => theme.spacing(1)}; -`; - -const StyledTitleLabel = styled.div` - font-weight: ${({ theme }) => theme.font.weight.medium}; -`; - -const StyledLink = styled(Link)` - color: ${({ theme }) => theme.font.color.light}; - text-decoration: none; - font-size: ${({ theme }) => theme.font.size.sm}; - - :hover { - color: ${({ theme }) => theme.font.color.secondary}; - } -`; - const StyledCardNoContent = styled.div` border: 1px solid ${({ theme }) => theme.border.color.medium}; border-radius: ${({ theme }) => theme.border.radius.sm}; @@ -181,46 +135,50 @@ export const RecordRelationFieldCardSection = () => { return (
- - - {fieldDefinition.label} - {isFromManyObjects && ( - - All ({relationRecords.length}) - - )} - - - - } - dropdownComponents={ - - + - - } - dropdownHotkeyScope={{ - scope: dropdownId, - }} - /> - - + } + dropdownComponents={ + + + + } + dropdownHotkeyScope={{ + scope: dropdownId, + }} + /> + + } + /> {relationRecords.length === 0 && ( diff --git a/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx b/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx index 2f85a7175..2c4c19516 100644 --- a/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx @@ -14,7 +14,7 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; -import { arrayToChunks } from '~/utils/array/array-to-chunks'; +import { arrayToChunks } from '~/utils/array/arrayToChunks'; import { IconButton, IconButtonVariant } from '../button/components/IconButton'; import { LightIconButton } from '../button/components/LightIconButton'; diff --git a/packages/twenty-front/src/modules/ui/layout/selectable-list/components/SelectableList.tsx b/packages/twenty-front/src/modules/ui/layout/selectable-list/components/SelectableList.tsx index 69c928acd..c0830dac5 100644 --- a/packages/twenty-front/src/modules/ui/layout/selectable-list/components/SelectableList.tsx +++ b/packages/twenty-front/src/modules/ui/layout/selectable-list/components/SelectableList.tsx @@ -3,7 +3,7 @@ import { ReactNode, useEffect } from 'react'; import { useSelectableListHotKeys } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListHotKeys'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { SelectableListScope } from '@/ui/layout/selectable-list/scopes/SelectableListScope'; -import { arrayToChunks } from '~/utils/array/array-to-chunks'; +import { arrayToChunks } from '~/utils/array/arrayToChunks'; type SelectableListProps = { children: ReactNode; diff --git a/packages/twenty-front/src/utils/array/__tests__/array-to-chunks.test.ts b/packages/twenty-front/src/utils/array/__tests__/arrayToCunks.test.ts similarity index 75% rename from packages/twenty-front/src/utils/array/__tests__/array-to-chunks.test.ts rename to packages/twenty-front/src/utils/array/__tests__/arrayToCunks.test.ts index 59f16b244..9d2033950 100644 --- a/packages/twenty-front/src/utils/array/__tests__/array-to-chunks.test.ts +++ b/packages/twenty-front/src/utils/array/__tests__/arrayToCunks.test.ts @@ -1,4 +1,4 @@ -import { arrayToChunks } from '~/utils/array/array-to-chunks'; +import { arrayToChunks } from '~/utils/array/arrayToChunks'; describe('arrayToChunks', () => { it('should split an array into subarrays of a given size', () => { diff --git a/packages/twenty-front/src/utils/array/__tests__/moveArrayItem.test.ts b/packages/twenty-front/src/utils/array/__tests__/moveArrayItem.test.ts new file mode 100644 index 000000000..148599901 --- /dev/null +++ b/packages/twenty-front/src/utils/array/__tests__/moveArrayItem.test.ts @@ -0,0 +1,33 @@ +import { moveArrayItem } from '~/utils/array/moveArrayItem'; + +describe('moveArrayItem', () => { + it('should return an empty array if provided with empty array', () => { + expect(moveArrayItem([], { fromIndex: 0, toIndex: 0 })).toEqual([]); + }); + + it('should return the same array if fromIndex is larger than array.length', () => { + expect(moveArrayItem([1, 2], { fromIndex: 3, toIndex: 0 })).toEqual([1, 2]); + }); + + it('should return the same array if toIndex is larger than array.length', () => { + expect(moveArrayItem([1, 2], { fromIndex: 0, toIndex: 3 })).toEqual([1, 2]); + }); + + it('should return the same array if fromIndex is smaller than 0', () => { + expect(moveArrayItem([1, 2], { fromIndex: -1, toIndex: 0 })).toEqual([ + 1, 2, + ]); + }); + + it('should return the same array if toIndex is smaller than 0', () => { + expect(moveArrayItem([1, 2], { fromIndex: 1, toIndex: -1 })).toEqual([ + 1, 2, + ]); + }); + + it('should move array items based on fromIndex and toIndex', () => { + expect(moveArrayItem([1, 2, 3], { fromIndex: 0, toIndex: 1 })).toEqual([ + 2, 1, 3, + ]); + }); +}); diff --git a/packages/twenty-front/src/utils/array/array-to-chunks.ts b/packages/twenty-front/src/utils/array/arrayToChunks.ts similarity index 100% rename from packages/twenty-front/src/utils/array/array-to-chunks.ts rename to packages/twenty-front/src/utils/array/arrayToChunks.ts diff --git a/packages/twenty-server/src/workspace/utils/get-resolver-name.util.ts b/packages/twenty-server/src/workspace/utils/get-resolver-name.util.ts index 4b101ffb3..e2de8f2d5 100644 --- a/packages/twenty-server/src/workspace/utils/get-resolver-name.util.ts +++ b/packages/twenty-server/src/workspace/utils/get-resolver-name.util.ts @@ -13,6 +13,8 @@ export const getResolverName = ( return `${camelCase(objectMetadata.namePlural)}`; case 'findOne': return `${camelCase(objectMetadata.nameSingular)}`; + case 'findDuplicates': + return `${camelCase(objectMetadata.nameSingular)}Duplicates`; case 'createMany': return `create${pascalCase(objectMetadata.namePlural)}`; case 'createOne': diff --git a/packages/twenty-server/src/workspace/workspace-query-builder/factories/__tests__/find-duplicates-query.factory.spec.ts b/packages/twenty-server/src/workspace/workspace-query-builder/factories/__tests__/find-duplicates-query.factory.spec.ts new file mode 100644 index 000000000..96472a36d --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-query-builder/factories/__tests__/find-duplicates-query.factory.spec.ts @@ -0,0 +1,175 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { RecordFilter } from 'src/workspace/workspace-query-builder/interfaces/record.interface'; +import { FindDuplicatesResolverArgs } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +import { ArgsAliasFactory } from 'src/workspace/workspace-query-builder/factories/args-alias.factory'; +import { FieldsStringFactory } from 'src/workspace/workspace-query-builder/factories/fields-string.factory'; +import { FindDuplicatesQueryFactory } from 'src/workspace/workspace-query-builder/factories/find-duplicates-query.factory'; +import { workspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/utils-test/workspace-query-builder-options'; + +describe('FindDuplicatesQueryFactory', () => { + let service: FindDuplicatesQueryFactory; + const argAliasCreate = jest.fn(); + + beforeEach(async () => { + jest.resetAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FindDuplicatesQueryFactory, + { + provide: FieldsStringFactory, + useValue: { + create: jest.fn().mockResolvedValue('fieldsString'), + // Mock implementation of FieldsStringFactory methods if needed + }, + }, + { + provide: ArgsAliasFactory, + useValue: { + create: argAliasCreate, + // Mock implementation of ArgsAliasFactory methods if needed + }, + }, + ], + }).compile(); + + service = module.get( + FindDuplicatesQueryFactory, + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should return (first: 0) as a filter when args are missing', async () => { + const args: FindDuplicatesResolverArgs = {}; + + const query = await service.create(args, workspaceQueryBuilderOptions); + + expect(query.trim()).toEqual(`query { + objectNameCollection(first: 0) { + fieldsString + } + }`); + }); + + it('should use firstName and lastName as a filter when both args are present', async () => { + argAliasCreate.mockReturnValue({ + nameFirstName: 'John', + nameLastName: 'Doe', + }); + + const args: FindDuplicatesResolverArgs = { + data: { + name: { + firstName: 'John', + lastName: 'Doe', + }, + } as unknown as RecordFilter, + }; + + const query = await service.create(args, { + ...workspaceQueryBuilderOptions, + objectMetadataItem: { + ...workspaceQueryBuilderOptions.objectMetadataItem, + nameSingular: 'person', + }, + }); + + expect(query.trim()).toEqual(`query { + personCollection(filter: {or:[{nameFirstName:{ilike:\"%John%\"},nameLastName:{ilike:\"%Doe%\"}}]}) { + fieldsString + } + }`); + }); + + it('should return (first: 0) as a filter when only firstName is present', async () => { + argAliasCreate.mockReturnValue({ + nameFirstName: 'John', + }); + + const args: FindDuplicatesResolverArgs = { + data: { + name: { + firstName: 'John', + }, + } as unknown as RecordFilter, + }; + + const query = await service.create(args, { + ...workspaceQueryBuilderOptions, + objectMetadataItem: { + ...workspaceQueryBuilderOptions.objectMetadataItem, + nameSingular: 'person', + }, + }); + + expect(query.trim()).toEqual(`query { + personCollection(first: 0) { + fieldsString + } + }`); + }); + + it('should use "currentRecord" as query args when its present', async () => { + argAliasCreate.mockReturnValue({ + nameFirstName: 'John', + }); + + const args: FindDuplicatesResolverArgs = { + id: 'uuid', + }; + + const query = await service.create( + args, + { + ...workspaceQueryBuilderOptions, + objectMetadataItem: { + ...workspaceQueryBuilderOptions.objectMetadataItem, + nameSingular: 'person', + }, + }, + { + nameFirstName: 'Peter', + nameLastName: 'Parker', + }, + ); + + expect(query.trim()).toEqual(`query { + personCollection(filter: {id:{neq:\"uuid\"},or:[{nameFirstName:{ilike:\"%Peter%\"},nameLastName:{ilike:\"%Parker%\"}}]}) { + fieldsString + } + }`); + }); + }); + + describe('buildQueryForExistingRecord', () => { + it(`should include all the fields that exist for person inside "duplicateCriteriaCollection" constant`, async () => { + const query = service.buildQueryForExistingRecord('uuid', { + ...workspaceQueryBuilderOptions, + objectMetadataItem: { + ...workspaceQueryBuilderOptions.objectMetadataItem, + nameSingular: 'person', + }, + }); + + expect(query.trim()).toEqual(`query { + personCollection(filter: { id: { eq: \"uuid\" }}){ + edges { + node { + __typename + nameFirstName +nameLastName +linkedinLinkUrl +email + } + } + } + }`); + }); + }); +}); diff --git a/packages/twenty-server/src/workspace/workspace-query-builder/factories/factories.ts b/packages/twenty-server/src/workspace/workspace-query-builder/factories/factories.ts index 611b33ac8..0a876fe94 100644 --- a/packages/twenty-server/src/workspace/workspace-query-builder/factories/factories.ts +++ b/packages/twenty-server/src/workspace/workspace-query-builder/factories/factories.ts @@ -10,6 +10,7 @@ import { FindOneQueryFactory } from './find-one-query.factory'; import { UpdateOneQueryFactory } from './update-one-query.factory'; import { UpdateManyQueryFactory } from './update-many-query.factory'; import { DeleteManyQueryFactory } from './delete-many-query.factory'; +import { FindDuplicatesQueryFactory } from './find-duplicates-query.factory'; export const workspaceQueryBuilderFactories = [ ArgsAliasFactory, @@ -21,6 +22,7 @@ export const workspaceQueryBuilderFactories = [ FieldsStringFactory, FindManyQueryFactory, FindOneQueryFactory, + FindDuplicatesQueryFactory, UpdateOneQueryFactory, UpdateManyQueryFactory, DeleteManyQueryFactory, diff --git a/packages/twenty-server/src/workspace/workspace-query-builder/factories/find-duplicates-query.factory.ts b/packages/twenty-server/src/workspace/workspace-query-builder/factories/find-duplicates-query.factory.ts new file mode 100644 index 000000000..b5bf98fc7 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-query-builder/factories/find-duplicates-query.factory.ts @@ -0,0 +1,146 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import isEmpty from 'lodash.isempty'; + +import { WorkspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/interfaces/workspace-query-builder-options.interface'; +import { RecordFilter } from 'src/workspace/workspace-query-builder/interfaces/record.interface'; +import { FindDuplicatesResolverArgs } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; +import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/object-metadata.interface'; + +import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util'; +import { stringifyWithoutKeyQuote } from 'src/workspace/workspace-query-builder/utils/stringify-without-key-quote.util'; +import { ArgsAliasFactory } from 'src/workspace/workspace-query-builder/factories/args-alias.factory'; +import { duplicateCriteriaCollection } from 'src/workspace/workspace-resolver-builder/constants/duplicate-criteria.constants'; + +import { FieldsStringFactory } from './fields-string.factory'; + +@Injectable() +export class FindDuplicatesQueryFactory { + private readonly logger = new Logger(FindDuplicatesQueryFactory.name); + + constructor( + private readonly fieldsStringFactory: FieldsStringFactory, + private readonly argsAliasFactory: ArgsAliasFactory, + ) {} + + async create( + args: FindDuplicatesResolverArgs, + options: WorkspaceQueryBuilderOptions, + currentRecord?: Record, + ) { + const fieldsString = await this.fieldsStringFactory.create( + options.info, + options.fieldMetadataCollection, + options.objectMetadataCollection, + ); + + const argsData = this.getFindDuplicateBy( + args, + options, + currentRecord, + ); + + const duplicateCondition = this.buildDuplicateCondition( + options.objectMetadataItem, + argsData, + args.id, + ); + + const filters = stringifyWithoutKeyQuote(duplicateCondition); + + return ` + query { + ${computeObjectTargetTable(options.objectMetadataItem)}Collection${ + isEmpty(duplicateCondition?.or) + ? '(first: 0)' + : `(filter: ${filters})` + } { + ${fieldsString} + } + } + `; + } + + getFindDuplicateBy( + args: FindDuplicatesResolverArgs, + options: WorkspaceQueryBuilderOptions, + currentRecord?: Record, + ) { + if (currentRecord) { + return currentRecord; + } + + return this.argsAliasFactory.create( + args.data ?? {}, + options.fieldMetadataCollection, + ); + } + + buildQueryForExistingRecord( + id: string, + options: WorkspaceQueryBuilderOptions, + ) { + return ` + query { + ${computeObjectTargetTable( + options.objectMetadataItem, + )}Collection(filter: { id: { eq: "${id}" }}){ + edges { + node { + __typename + ${this.getApplicableDuplicateCriteriaCollection( + options.objectMetadataItem, + ) + .flatMap((dc) => dc.columnNames) + .join('\n')} + } + } + } + } + `; + } + + private buildDuplicateCondition( + objectMetadataItem: ObjectMetadataInterface, + argsData?: Record, + filteringByExistingRecordId?: string, + ) { + if (!argsData) { + return; + } + + const criteriaCollection = + this.getApplicableDuplicateCriteriaCollection(objectMetadataItem); + + const criteriaWithMatchingArgs = criteriaCollection.filter((criteria) => + criteria.columnNames.every((columnName) => !!argsData[columnName]), + ); + + const filterCriteria = criteriaWithMatchingArgs.map((criteria) => + Object.fromEntries( + criteria.columnNames.map((columnName) => [ + columnName, + { ilike: `%${argsData[columnName]}%` }, + ]), + ), + ); + + return { + // when filtering by an existing record, we need to filter that explicit record out + ...(filteringByExistingRecordId && { + id: { neq: filteringByExistingRecordId }, + }), + // keep condition as "or" to get results by more duplicate criteria + or: filterCriteria, + }; + } + + private getApplicableDuplicateCriteriaCollection( + objectMetadataItem: ObjectMetadataInterface, + ) { + return duplicateCriteriaCollection.filter( + (duplicateCriteria) => + duplicateCriteria.objectName === objectMetadataItem.nameSingular, + ); + } +} diff --git a/packages/twenty-server/src/workspace/workspace-query-builder/interfaces/record.interface.ts b/packages/twenty-server/src/workspace/workspace-query-builder/interfaces/record.interface.ts index d152f9449..b9847baed 100644 --- a/packages/twenty-server/src/workspace/workspace-query-builder/interfaces/record.interface.ts +++ b/packages/twenty-server/src/workspace/workspace-query-builder/interfaces/record.interface.ts @@ -19,3 +19,8 @@ export enum OrderByDirection { export type RecordOrderBy = { [Property in keyof Record]?: OrderByDirection; }; + +export interface RecordDuplicateCriteria { + objectName: string; + columnNames: string[]; +} diff --git a/packages/twenty-server/src/workspace/workspace-query-builder/utils-test/workspace-query-builder-options.ts b/packages/twenty-server/src/workspace/workspace-query-builder/utils-test/workspace-query-builder-options.ts new file mode 100644 index 000000000..7b052cdb3 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-query-builder/utils-test/workspace-query-builder-options.ts @@ -0,0 +1,13 @@ +import { GraphQLResolveInfo } from 'graphql'; + +import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/object-metadata.interface'; +import { WorkspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/interfaces/workspace-query-builder-options.interface'; + +import { objectMetadataItem } from 'src/utils/utils-test/object-metadata-item'; + +export const workspaceQueryBuilderOptions: WorkspaceQueryBuilderOptions = { + fieldMetadataCollection: [], + info: {} as GraphQLResolveInfo, + objectMetadataCollection: [], + objectMetadataItem: objectMetadataItem as ObjectMetadataInterface, +}; diff --git a/packages/twenty-server/src/workspace/workspace-query-builder/workspace-query-builder.factory.ts b/packages/twenty-server/src/workspace/workspace-query-builder/workspace-query-builder.factory.ts index bdc104b0a..a1e1a4e0b 100644 --- a/packages/twenty-server/src/workspace/workspace-query-builder/workspace-query-builder.factory.ts +++ b/packages/twenty-server/src/workspace/workspace-query-builder/workspace-query-builder.factory.ts @@ -14,6 +14,7 @@ import { DeleteOneResolverArgs, UpdateManyResolverArgs, DeleteManyResolverArgs, + FindDuplicatesResolverArgs, } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { FindManyQueryFactory } from './factories/find-many-query.factory'; @@ -29,6 +30,7 @@ import { DeleteManyQueryFactory, DeleteManyQueryFactoryOptions, } from './factories/delete-many-query.factory'; +import { FindDuplicatesQueryFactory } from './factories/find-duplicates-query.factory'; @Injectable() export class WorkspaceQueryBuilderFactory { @@ -37,6 +39,7 @@ export class WorkspaceQueryBuilderFactory { constructor( private readonly findManyQueryFactory: FindManyQueryFactory, private readonly findOneQueryFactory: FindOneQueryFactory, + private readonly findDuplicatesQueryFactory: FindDuplicatesQueryFactory, private readonly createManyQueryFactory: CreateManyQueryFactory, private readonly updateOneQueryFactory: UpdateOneQueryFactory, private readonly deleteOneQueryFactory: DeleteOneQueryFactory, @@ -61,6 +64,28 @@ export class WorkspaceQueryBuilderFactory { return this.findOneQueryFactory.create(args, options); } + findDuplicates( + args: FindDuplicatesResolverArgs, + options: WorkspaceQueryBuilderOptions, + existingRecord?: Record, + ): Promise { + return this.findDuplicatesQueryFactory.create( + args, + options, + existingRecord, + ); + } + + findDuplicatesExistingRecord( + id: string, + options: WorkspaceQueryBuilderOptions, + ): string { + return this.findDuplicatesQueryFactory.buildQueryForExistingRecord( + id, + options, + ); + } + createMany( args: CreateManyResolverArgs, options: WorkspaceQueryBuilderOptions, diff --git a/packages/twenty-server/src/workspace/workspace-query-runner/workspace-pre-query-hook/types/workspace-query-hook.type.ts b/packages/twenty-server/src/workspace/workspace-query-runner/workspace-pre-query-hook/types/workspace-query-hook.type.ts index c98882941..9cc680bdf 100644 --- a/packages/twenty-server/src/workspace/workspace-query-runner/workspace-pre-query-hook/types/workspace-query-hook.type.ts +++ b/packages/twenty-server/src/workspace/workspace-query-runner/workspace-pre-query-hook/types/workspace-query-hook.type.ts @@ -3,6 +3,7 @@ import { CreateOneResolverArgs, DeleteManyResolverArgs, DeleteOneResolverArgs, + FindDuplicatesResolverArgs, FindManyResolverArgs, FindOneResolverArgs, UpdateManyResolverArgs, @@ -16,6 +17,7 @@ export type ExecutePreHookMethod = | 'deleteOne' | 'findMany' | 'findOne' + | 'findDuplicates' | 'updateMany' | 'updateOne'; @@ -45,4 +47,6 @@ export type WorkspacePreQueryHookPayload = T extends 'createMany' ? UpdateManyResolverArgs : T extends 'updateOne' ? UpdateOneResolverArgs - : never; + : T extends 'findDuplicates' + ? FindDuplicatesResolverArgs + : never; diff --git a/packages/twenty-server/src/workspace/workspace-query-runner/workspace-query-runner.service.ts b/packages/twenty-server/src/workspace/workspace-query-runner/workspace-query-runner.service.ts index 915ecf598..3ae9a304a 100644 --- a/packages/twenty-server/src/workspace/workspace-query-runner/workspace-query-runner.service.ts +++ b/packages/twenty-server/src/workspace/workspace-query-runner/workspace-query-runner.service.ts @@ -6,6 +6,8 @@ import { } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import isEmpty from 'lodash.isempty'; + import { IConnection } from 'src/utils/pagination/interfaces/connection.interface'; import { Record as IRecord, @@ -17,6 +19,7 @@ import { CreateOneResolverArgs, DeleteManyResolverArgs, DeleteOneResolverArgs, + FindDuplicatesResolverArgs, FindManyResolverArgs, FindOneResolverArgs, UpdateManyResolverArgs, @@ -40,6 +43,7 @@ import { ObjectRecordCreateEvent } from 'src/integrations/event-emitter/types/ob import { ObjectRecordUpdateEvent } from 'src/integrations/event-emitter/types/object-record-update.event'; import { WorkspacePreQueryHookService } from 'src/workspace/workspace-query-runner/workspace-pre-query-hook/workspace-pre-query-hook.service'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { NotFoundError } from 'src/filters/utils/graphql-errors.util'; import { WorkspaceQueryRunnerOptions } from './interfaces/query-runner-option.interface'; import { @@ -136,6 +140,74 @@ export class WorkspaceQueryRunnerService { return parsedResult?.edges?.[0]?.node; } + async findDuplicates( + args: FindDuplicatesResolverArgs, + options: WorkspaceQueryRunnerOptions, + ): Promise | undefined> { + if (!args.data && !args.id) { + throw new BadRequestException( + 'You have to provide either "data" or "id" argument', + ); + } + + if (!args.id && isEmpty(args.data)) { + throw new BadRequestException( + 'The "data" condition can not be empty when ID input not provided', + ); + } + + const { workspaceId, userId, objectMetadataItem } = options; + + let existingRecord: Record | undefined; + + if (args.id) { + const existingRecordQuery = + this.workspaceQueryBuilderFactory.findDuplicatesExistingRecord( + args.id, + options, + ); + + const existingRecordResult = await this.execute( + existingRecordQuery, + workspaceId, + ); + + const parsedResult = this.parseResult>( + existingRecordResult, + objectMetadataItem, + '', + ); + + existingRecord = parsedResult?.edges?.[0]?.node; + + if (!existingRecord) { + throw new NotFoundError(`Object with id ${args.id} not found`); + } + } + + const query = await this.workspaceQueryBuilderFactory.findDuplicates( + args, + options, + existingRecord, + ); + + await this.workspacePreQueryHookService.executePreHooks( + userId, + workspaceId, + objectMetadataItem.nameSingular, + 'findDuplicates', + args, + ); + + const result = await this.execute(query, workspaceId); + + return this.parseResult>( + result, + objectMetadataItem, + '', + ); + } + async createMany( args: CreateManyResolverArgs, options: WorkspaceQueryRunnerOptions, diff --git a/packages/twenty-server/src/workspace/workspace-resolver-builder/constants/duplicate-criteria.constants.ts b/packages/twenty-server/src/workspace/workspace-resolver-builder/constants/duplicate-criteria.constants.ts new file mode 100644 index 000000000..e9abb45b1 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-resolver-builder/constants/duplicate-criteria.constants.ts @@ -0,0 +1,30 @@ +import { RecordDuplicateCriteria } from 'src/workspace/workspace-query-builder/interfaces/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 duplicateCriteriaCollection: RecordDuplicateCriteria[] = [ + { + objectName: 'company', + columnNames: ['domainName'], + }, + { + objectName: 'company', + columnNames: ['name'], + }, + { + objectName: 'person', + columnNames: ['nameFirstName', 'nameLastName'], + }, + { + objectName: 'person', + columnNames: ['linkedinLinkUrl'], + }, + { + objectName: 'person', + columnNames: ['email'], + }, +]; diff --git a/packages/twenty-server/src/workspace/workspace-resolver-builder/factories/factories.ts b/packages/twenty-server/src/workspace/workspace-resolver-builder/factories/factories.ts index 435266120..2ee2f2706 100644 --- a/packages/twenty-server/src/workspace/workspace-resolver-builder/factories/factories.ts +++ b/packages/twenty-server/src/workspace/workspace-resolver-builder/factories/factories.ts @@ -1,5 +1,6 @@ import { UpdateManyResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/update-many-resolver.factory'; +import { FindDuplicatesResolverFactory } from './find-duplicates-resolver.factory'; import { FindManyResolverFactory } from './find-many-resolver.factory'; import { FindOneResolverFactory } from './find-one-resolver.factory'; import { CreateManyResolverFactory } from './create-many-resolver.factory'; @@ -12,6 +13,7 @@ import { ExecuteQuickActionOnOneResolverFactory } from './execute-quick-action-o export const workspaceResolverBuilderFactories = [ FindManyResolverFactory, FindOneResolverFactory, + FindDuplicatesResolverFactory, CreateManyResolverFactory, CreateOneResolverFactory, UpdateOneResolverFactory, @@ -25,6 +27,7 @@ export const workspaceResolverBuilderMethodNames = { queries: [ FindManyResolverFactory.methodName, FindOneResolverFactory.methodName, + FindDuplicatesResolverFactory.methodName, ], mutations: [ CreateManyResolverFactory.methodName, diff --git a/packages/twenty-server/src/workspace/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts b/packages/twenty-server/src/workspace/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts new file mode 100644 index 000000000..3d2e3ac18 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; + +import { + FindDuplicatesResolverArgs, + Resolver, +} from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; +import { WorkspaceSchemaBuilderContext } from 'src/workspace/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; +import { WorkspaceResolverBuilderFactoryInterface } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; + +import { WorkspaceQueryRunnerService } from 'src/workspace/workspace-query-runner/workspace-query-runner.service'; + +@Injectable() +export class FindDuplicatesResolverFactory + implements WorkspaceResolverBuilderFactoryInterface +{ + public static methodName = 'findDuplicates' as const; + + constructor( + private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService, + ) {} + + create( + context: WorkspaceSchemaBuilderContext, + ): Resolver { + const internalContext = context; + + return (_source, args, context, info) => { + return this.workspaceQueryRunnerService.findDuplicates(args, { + objectMetadataItem: internalContext.objectMetadataItem, + workspaceId: internalContext.workspaceId, + userId: internalContext.userId, + info, + fieldMetadataCollection: internalContext.fieldMetadataCollection, + objectMetadataCollection: internalContext.objectMetadataCollection, + }); + }; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts b/packages/twenty-server/src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts index 8f44bbf9e..66675ee6b 100644 --- a/packages/twenty-server/src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts +++ b/packages/twenty-server/src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts @@ -26,6 +26,11 @@ export interface FindOneResolverArgs { filter?: Filter; } +export interface FindDuplicatesResolverArgs { + id?: string; + data?: Data; +} + export interface CreateOneResolverArgs { data: Data; } @@ -81,5 +86,6 @@ export type ResolverArgs = | DeleteOneResolverArgs | FindManyResolverArgs | FindOneResolverArgs + | FindDuplicatesResolverArgs | UpdateManyResolverArgs | UpdateOneResolverArgs; diff --git a/packages/twenty-server/src/workspace/workspace-resolver-builder/workspace-resolver.factory.ts b/packages/twenty-server/src/workspace/workspace-resolver-builder/workspace-resolver.factory.ts index 19e6dd00f..ff955425e 100644 --- a/packages/twenty-server/src/workspace/workspace-resolver-builder/workspace-resolver.factory.ts +++ b/packages/twenty-server/src/workspace/workspace-resolver-builder/workspace-resolver.factory.ts @@ -9,6 +9,7 @@ import { UpdateManyResolverFactory } from 'src/workspace/workspace-resolver-buil import { DeleteManyResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/delete-many-resolver.factory'; import { ExecuteQuickActionOnOneResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/execute-quick-action-on-one-resolver.factory'; +import { FindDuplicatesResolverFactory } from './factories/find-duplicates-resolver.factory'; import { FindManyResolverFactory } from './factories/find-many-resolver.factory'; import { FindOneResolverFactory } from './factories/find-one-resolver.factory'; import { CreateManyResolverFactory } from './factories/create-many-resolver.factory'; @@ -28,6 +29,7 @@ export class WorkspaceResolverFactory { constructor( private readonly findManyResolverFactory: FindManyResolverFactory, private readonly findOneResolverFactory: FindOneResolverFactory, + private readonly findDuplicatesResolverFactory: FindDuplicatesResolverFactory, private readonly createManyResolverFactory: CreateManyResolverFactory, private readonly createOneResolverFactory: CreateOneResolverFactory, private readonly updateOneResolverFactory: UpdateOneResolverFactory, @@ -49,6 +51,7 @@ export class WorkspaceResolverFactory { >([ ['findMany', this.findManyResolverFactory], ['findOne', this.findOneResolverFactory], + ['findDuplicates', this.findDuplicatesResolverFactory], ['createMany', this.createManyResolverFactory], ['createOne', this.createOneResolverFactory], ['updateOne', this.updateOneResolverFactory], diff --git a/packages/twenty-server/src/workspace/workspace-schema-builder/factories/root-type.factory.ts b/packages/twenty-server/src/workspace/workspace-schema-builder/factories/root-type.factory.ts index b15e1e7b3..d7fd5e627 100644 --- a/packages/twenty-server/src/workspace/workspace-schema-builder/factories/root-type.factory.ts +++ b/packages/twenty-server/src/workspace/workspace-schema-builder/factories/root-type.factory.ts @@ -74,7 +74,7 @@ export class RootTypeFactory { const args = getResolverArgs(methodName); const objectType = this.typeDefinitionsStorage.getObjectTypeByKey( objectMetadata.id, - methodName === 'findMany' + ['findMany', 'findDuplicates'].includes(methodName) ? ObjectTypeDefinitionKind.Connection : ObjectTypeDefinitionKind.Plain, ); diff --git a/packages/twenty-server/src/workspace/workspace-schema-builder/utils/get-resolver-args.util.ts b/packages/twenty-server/src/workspace/workspace-schema-builder/utils/get-resolver-args.util.ts index 793641c10..bdf67209c 100644 --- a/packages/twenty-server/src/workspace/workspace-schema-builder/utils/get-resolver-args.util.ts +++ b/packages/twenty-server/src/workspace/workspace-schema-builder/utils/get-resolver-args.util.ts @@ -69,6 +69,17 @@ export const getResolverArgs = ( isNullable: false, }, }; + case 'findDuplicates': + return { + id: { + type: FieldMetadataType.UUID, + isNullable: true, + }, + data: { + kind: InputTypeDefinitionKind.Create, + isNullable: true, + }, + }; case 'deleteOne': return { id: {