From ad944e1d2c42c33fca886fc2c98ebaeb8e12fefd Mon Sep 17 00:00:00 2001 From: neo773 <62795688+neo773@users.noreply.github.com> Date: Mon, 8 Sep 2025 21:20:26 +0530 Subject: [PATCH] merge records composite type (#14005) /closes #13989 --------- Co-authored-by: Charles Bochet --- ...aphql-query-merge-many-resolver.service.ts | 25 +- .../__tests__/merge-field-values.util.spec.ts | 90 ++++ .../select-priority-field-value.util.spec.ts | 111 +++++ .../utils/merge-array-field-values.util.ts | 29 ++ .../utils/merge-emails-field-values.util.ts | 53 +++ .../utils/merge-field-values.util.ts | 44 ++ .../utils/merge-links-field-values.util.ts | 69 +++ .../utils/merge-phones-field-values.util.ts | 71 +++ .../utils/parse-additional-items.util.ts | 19 + .../utils/select-priority-field-value.util.ts | 24 ++ .../composite-types/emails.composite-type.ts | 2 +- ...son-by-primary-or-additional-email.spec.ts | 4 +- .../constants/company-gql-fields.constants.ts | 16 + .../constants/person-gql-fields.constants.ts | 13 + .../companies-merge-many.integration-spec.ts | 252 +++++++++++ .../people-merge-many.integration-spec.ts | 408 ++++++++++++++++++ .../merge-many-operation-factory.util.ts | 36 ++ .../utils/delete-records-by-ids.ts | 25 ++ packages/twenty-ui/package.json | 2 +- 19 files changed, 1277 insertions(+), 16 deletions(-) create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/merge-field-values.util.spec.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/select-priority-field-value.util.spec.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-array-field-values.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-emails-field-values.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-field-values.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-links-field-values.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-phones-field-values.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/parse-additional-items.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/select-priority-field-value.util.ts create mode 100644 packages/twenty-server/test/integration/constants/company-gql-fields.constants.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/object-generated/companies-merge-many.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/object-generated/people-merge-many.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/merge-many-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/utils/delete-records-by-ids.ts diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service.ts index 5a266a0b8c5..474a5e270bb 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service.ts @@ -26,6 +26,7 @@ import { import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return'; import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util'; +import { mergeFieldValues } from 'src/engine/api/graphql/graphql-query-runner/utils/merge-field-values.util'; import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; @@ -188,15 +189,19 @@ export class GraphqlQueryMergeManyResolverService extends GraphqlQueryBaseResolv } else if (recordsWithValues.length === 1) { mergedResult[fieldName] = recordsWithValues[0].value; } else { - const priorityValue = recordsWithValues.find( - (item) => item.recordId === priorityRecordId, - ); + const fieldMetadata = Object.values( + objectMetadataItemWithFieldMaps.fieldsById, + ).find((field) => field?.name === fieldName); - if (priorityValue) { - mergedResult[fieldName] = priorityValue.value; - } else { - mergedResult[fieldName] = recordsWithValues[0].value; + if (!fieldMetadata) { + return; } + + mergedResult[fieldName] = mergeFieldValues( + fieldMetadata.type, + recordsWithValues, + priorityRecordId, + ); } }); @@ -211,11 +216,7 @@ export class GraphqlQueryMergeManyResolverService extends GraphqlQueryBaseResolv objectMetadataItemWithFieldMaps.fieldsById, ).find((field) => field?.name === fieldName); - if (fieldMetadata?.isSystem) { - return true; - } - - return false; + return fieldMetadata?.isSystem ?? false; } private createDryRunResponse( diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/merge-field-values.util.spec.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/merge-field-values.util.spec.ts new file mode 100644 index 00000000000..7d0afe3a952 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/merge-field-values.util.spec.ts @@ -0,0 +1,90 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { mergeFieldValues } from 'src/engine/api/graphql/graphql-query-runner/utils/merge-field-values.util'; + +describe('mergeFieldValues', () => { + const PRIORITY_RECORD_ID = 'priority-record-id'; + const RECORDS_WITH_VALUES = [ + { value: 'value1', recordId: 'record1' }, + { value: 'value2', recordId: PRIORITY_RECORD_ID }, + { value: 'value3', recordId: 'record3' }, + ]; + + describe('default field types', () => { + it('should return priority field value for text fields', () => { + const result = mergeFieldValues( + FieldMetadataType.TEXT, + RECORDS_WITH_VALUES, + PRIORITY_RECORD_ID, + ); + + expect(result).toBe('value2'); + }); + + it('should throw error when priority record is not found', () => { + const recordsWithoutPriorityValue = [ + { value: 'value1', recordId: 'record1' }, + { value: null, recordId: PRIORITY_RECORD_ID }, + { value: 'value3', recordId: 'record3' }, + ]; + + expect(() => + mergeFieldValues( + FieldMetadataType.TEXT, + recordsWithoutPriorityValue, + 'non-existent-id', + ), + ).toThrow('Priority record with ID non-existent-id not found'); + }); + }); + + describe('array field types', () => { + it('should merge array values', () => { + const arrayRecords = [ + { value: ['a', 'b'], recordId: 'record1' }, + { value: ['b', 'c'], recordId: PRIORITY_RECORD_ID }, + { value: ['c', 'd'], recordId: 'record3' }, + ]; + + const result = mergeFieldValues( + FieldMetadataType.ARRAY, + arrayRecords, + PRIORITY_RECORD_ID, + ); + + expect(result).toEqual(['a', 'b', 'c', 'd']); + }); + }); + + describe('arrayable field types', () => { + it('should merge emails for EMAILS field', () => { + const emailRecords = [ + { + value: { + primaryEmail: 'first@example.com', + additionalEmails: ['extra1@example.com'], + }, + recordId: 'record1', + }, + { + value: { + primaryEmail: 'priority@example.com', + additionalEmails: ['extra2@example.com'], + }, + recordId: PRIORITY_RECORD_ID, + }, + ]; + + const result = mergeFieldValues( + FieldMetadataType.EMAILS, + emailRecords, + PRIORITY_RECORD_ID, + ); + + expect(result).toEqual({ + primaryEmail: 'priority@example.com', + additionalEmails: ['extra1@example.com', 'extra2@example.com'], + }); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/select-priority-field-value.util.spec.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/select-priority-field-value.util.spec.ts new file mode 100644 index 00000000000..329f66e739f --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/select-priority-field-value.util.spec.ts @@ -0,0 +1,111 @@ +import { selectPriorityFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/select-priority-field-value.util'; + +describe('selectPriorityFieldValue', () => { + it('should return priority record value when available', () => { + const recordsWithValues = [ + { + recordId: '1', + value: 'priority value', + }, + { + recordId: '2', + value: 'other value', + }, + ]; + + const result = selectPriorityFieldValue(recordsWithValues, '1'); + + expect(result).toBe('priority value'); + }); + + it('should throw error when priority record not found', () => { + const recordsWithValues = [ + { + recordId: '2', + value: 'first value', + }, + { + recordId: '3', + value: 'second value', + }, + ]; + + expect(() => selectPriorityFieldValue(recordsWithValues, '1')).toThrow( + 'Priority record with ID 1 not found in merge candidates', + ); + }); + + it('should return null when priority record has no value', () => { + const recordsWithValues = [ + { + recordId: '1', + value: null, + }, + { + recordId: '2', + value: 'fallback value', + }, + ]; + + const result = selectPriorityFieldValue(recordsWithValues, '1'); + + expect(result).toBeNull(); + }); + + it('should return null when priority record has empty string', () => { + const recordsWithValues = [ + { + recordId: '1', + value: '', + }, + { + recordId: '2', + value: 'fallback value', + }, + ]; + + const result = selectPriorityFieldValue(recordsWithValues, '1'); + + expect(result).toBeNull(); + }); + + it('should return null when priority record has undefined value', () => { + const recordsWithValues = [ + { + recordId: '1', + value: undefined, + }, + { + recordId: '2', + value: 'fallback value', + }, + ]; + + const result = selectPriorityFieldValue(recordsWithValues, '1'); + + expect(result).toBeNull(); + }); + + it('should throw error when no records exist', () => { + expect(() => selectPriorityFieldValue([], '1')).toThrow( + 'Priority record with ID 1 not found in merge candidates', + ); + }); + + it('should handle complex object values', () => { + const recordsWithValues = [ + { + recordId: '1', + value: { name: 'priority object', id: 1 }, + }, + { + recordId: '2', + value: { name: 'fallback object', id: 2 }, + }, + ]; + + const result = selectPriorityFieldValue(recordsWithValues, '1'); + + expect(result).toEqual({ name: 'priority object', id: 1 }); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-array-field-values.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-array-field-values.util.ts new file mode 100644 index 00000000000..a20a9afd4d1 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-array-field-values.util.ts @@ -0,0 +1,29 @@ +import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util'; + +type RecordWithValue = { value: T; recordId: string }; + +export const mergeArrayFieldValues = ( + recordsWithValues: RecordWithValue[], +): T[] | null => { + const allValues: T[] = []; + + recordsWithValues.forEach((record) => { + if (record.value === null || record.value === undefined) { + return; + } + + if (!Array.isArray(record.value)) { + throw new Error( + `Expected array value but received ${typeof record.value}`, + ); + } + + allValues.push( + ...record.value.filter((value) => hasRecordFieldValue(value)), + ); + }); + + const uniqueValues = Array.from(new Set(allValues)); + + return uniqueValues.length > 0 ? uniqueValues : null; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-emails-field-values.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-emails-field-values.util.ts new file mode 100644 index 00000000000..1112875f6e4 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-emails-field-values.util.ts @@ -0,0 +1,53 @@ +import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util'; +import { parseArrayOrJsonStringToArray } from 'src/engine/api/graphql/graphql-query-runner/utils/parse-additional-items.util'; +import { type EmailsMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/emails.composite-type'; + +export const mergeEmailsFieldValues = ( + recordsWithValues: { value: EmailsMetadata; recordId: string }[], + priorityRecordId: string, +): EmailsMetadata => { + if (recordsWithValues.length === 0) { + return { + primaryEmail: '', + additionalEmails: null, + }; + } + + let primaryEmail = ''; + const priorityRecord = recordsWithValues.find( + (record) => record.recordId === priorityRecordId, + ); + + if ( + priorityRecord && + hasRecordFieldValue(priorityRecord.value.primaryEmail) + ) { + primaryEmail = priorityRecord.value.primaryEmail; + } else { + const fallbackRecord = recordsWithValues.find((record) => + hasRecordFieldValue(record.value.primaryEmail), + ); + + primaryEmail = fallbackRecord?.value.primaryEmail || ''; + } + + const allAdditionalEmails: string[] = []; + + recordsWithValues.forEach((record) => { + const additionalEmails = parseArrayOrJsonStringToArray( + record.value.additionalEmails, + ); + + allAdditionalEmails.push( + ...additionalEmails.filter((email) => hasRecordFieldValue(email)), + ); + }); + + const uniqueAdditionalEmails = Array.from(new Set(allAdditionalEmails)); + + return { + primaryEmail, + additionalEmails: + uniqueAdditionalEmails.length > 0 ? uniqueAdditionalEmails : null, + }; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-field-values.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-field-values.util.ts new file mode 100644 index 00000000000..bccd850dbde --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-field-values.util.ts @@ -0,0 +1,44 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { type EmailsMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/emails.composite-type'; +import { type LinksMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type'; +import { type PhonesMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type'; + +import { mergeArrayFieldValues } from './merge-array-field-values.util'; +import { mergeEmailsFieldValues } from './merge-emails-field-values.util'; +import { mergeLinksFieldValues } from './merge-links-field-values.util'; +import { mergePhonesFieldValues } from './merge-phones-field-values.util'; +import { selectPriorityFieldValue } from './select-priority-field-value.util'; + +export const mergeFieldValues = ( + fieldType: FieldMetadataType, + recordsWithValues: { value: unknown; recordId: string }[], + priorityRecordId: string, +): unknown => { + switch (fieldType) { + case FieldMetadataType.ARRAY: + case FieldMetadataType.MULTI_SELECT: + return mergeArrayFieldValues(recordsWithValues); + + case FieldMetadataType.EMAILS: + return mergeEmailsFieldValues( + recordsWithValues as { value: EmailsMetadata; recordId: string }[], + priorityRecordId, + ); + + case FieldMetadataType.PHONES: + return mergePhonesFieldValues( + recordsWithValues as { value: PhonesMetadata; recordId: string }[], + priorityRecordId, + ); + + case FieldMetadataType.LINKS: + return mergeLinksFieldValues( + recordsWithValues as { value: LinksMetadata; recordId: string }[], + priorityRecordId, + ); + + default: + return selectPriorityFieldValue(recordsWithValues, priorityRecordId); + } +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-links-field-values.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-links-field-values.util.ts new file mode 100644 index 00000000000..a2d9fbd9ee7 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-links-field-values.util.ts @@ -0,0 +1,69 @@ +import uniqBy from 'lodash.uniqby'; + +import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util'; +import { + type LinkMetadata, + type LinksMetadata, +} from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type'; + +import { parseArrayOrJsonStringToArray } from './parse-additional-items.util'; + +export const mergeLinksFieldValues = ( + recordsWithValues: { value: LinksMetadata; recordId: string }[], + priorityRecordId: string, +): LinksMetadata => { + if (recordsWithValues.length === 0) { + return { + primaryLinkUrl: '', + primaryLinkLabel: '', + secondaryLinks: null, + }; + } + + let primaryLinkUrl = ''; + let primaryLinkLabel = ''; + + const priorityRecord = recordsWithValues.find( + (record) => record.recordId === priorityRecordId, + ); + + if ( + priorityRecord && + hasRecordFieldValue(priorityRecord.value.primaryLinkUrl) + ) { + primaryLinkUrl = priorityRecord.value.primaryLinkUrl; + primaryLinkLabel = priorityRecord.value.primaryLinkLabel; + } else { + const fallbackRecord = recordsWithValues.find((record) => + hasRecordFieldValue(record.value.primaryLinkUrl), + ); + + if (fallbackRecord) { + primaryLinkUrl = fallbackRecord.value.primaryLinkUrl; + primaryLinkLabel = fallbackRecord.value.primaryLinkLabel; + } + } + + const allSecondaryLinks: LinkMetadata[] = []; + + recordsWithValues.forEach((record) => { + const secondaryLinks = parseArrayOrJsonStringToArray( + record.value.secondaryLinks, + ); + + allSecondaryLinks.push( + ...secondaryLinks.filter((link) => hasRecordFieldValue(link.url)), + ); + }); + + const uniqueSecondaryLinks = uniqBy(allSecondaryLinks, 'url'); + + const result = { + primaryLinkLabel, + primaryLinkUrl, + secondaryLinks: + uniqueSecondaryLinks.length > 0 ? uniqueSecondaryLinks : null, + }; + + return result; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-phones-field-values.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-phones-field-values.util.ts new file mode 100644 index 00000000000..caba7a0237c --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/merge-phones-field-values.util.ts @@ -0,0 +1,71 @@ +import { type CountryCode } from 'libphonenumber-js'; +import uniqBy from 'lodash.uniqby'; + +import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util'; +import { + type AdditionalPhoneMetadata, + type PhonesMetadata, +} from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type'; + +export const mergePhonesFieldValues = ( + recordsWithValues: { value: PhonesMetadata; recordId: string }[], + priorityRecordId: string, +): PhonesMetadata => { + if (recordsWithValues.length === 0) { + return { + primaryPhoneNumber: '', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '', + additionalPhones: null, + }; + } + + let primaryPhoneNumber = ''; + let primaryPhoneCountryCode: CountryCode | null = null; + let primaryPhoneCallingCode = ''; + + const priorityRecord = recordsWithValues.find( + (record) => record.recordId === priorityRecordId, + ); + + if ( + priorityRecord && + hasRecordFieldValue(priorityRecord.value.primaryPhoneNumber) + ) { + primaryPhoneNumber = priorityRecord.value.primaryPhoneNumber; + primaryPhoneCountryCode = priorityRecord.value.primaryPhoneCountryCode; + primaryPhoneCallingCode = priorityRecord.value.primaryPhoneCallingCode; + } else { + const fallbackRecord = recordsWithValues.find((record) => + hasRecordFieldValue(record.value.primaryPhoneNumber), + ); + + if (fallbackRecord) { + primaryPhoneNumber = fallbackRecord.value.primaryPhoneNumber; + primaryPhoneCountryCode = fallbackRecord.value.primaryPhoneCountryCode; + primaryPhoneCallingCode = fallbackRecord.value.primaryPhoneCallingCode; + } + } + + const allAdditionalPhones: AdditionalPhoneMetadata[] = []; + + recordsWithValues.forEach((record) => { + if (Array.isArray(record.value.additionalPhones)) { + allAdditionalPhones.push( + ...record.value.additionalPhones.filter((phone) => + hasRecordFieldValue(phone.number), + ), + ); + } + }); + + const uniqueAdditionalPhones = uniqBy(allAdditionalPhones, 'number'); + + return { + primaryPhoneNumber, + primaryPhoneCountryCode: primaryPhoneCountryCode!, + primaryPhoneCallingCode, + additionalPhones: + uniqueAdditionalPhones.length > 0 ? uniqueAdditionalPhones : null, + }; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/parse-additional-items.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/parse-additional-items.util.ts new file mode 100644 index 00000000000..17ffa8dd4e6 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/parse-additional-items.util.ts @@ -0,0 +1,19 @@ +export const parseArrayOrJsonStringToArray = ( + value: T[] | string | null | undefined, +): T[] => { + if (Array.isArray(value)) { + return value; + } + + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + return []; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/select-priority-field-value.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/select-priority-field-value.util.ts new file mode 100644 index 00000000000..6d1afdbb2bc --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/select-priority-field-value.util.ts @@ -0,0 +1,24 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util'; + +export const selectPriorityFieldValue = ( + recordsWithValues: { value: T; recordId: string }[], + priorityRecordId: string, +): T | null => { + const priorityRecord = recordsWithValues.find( + (record) => record.recordId === priorityRecordId, + ); + + if (!isDefined(priorityRecord)) { + throw new Error( + `Priority record with ID ${priorityRecordId} not found in merge candidates`, + ); + } + + if (hasRecordFieldValue(priorityRecord.value)) { + return priorityRecord.value; + } + + return null; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/emails.composite-type.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/emails.composite-type.ts index c0b79d8baeb..180acb97977 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/emails.composite-type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/emails.composite-type.ts @@ -23,5 +23,5 @@ export const emailsCompositeType: CompositeType = { export type EmailsMetadata = { primaryEmail: string; - additionalEmails: object | null; + additionalEmails: string[] | null; }; diff --git a/packages/twenty-server/src/modules/match-participant/utils/__tests__/find-person-by-primary-or-additional-email.spec.ts b/packages/twenty-server/src/modules/match-participant/utils/__tests__/find-person-by-primary-or-additional-email.spec.ts index c58beb8f99b..9c7b50fe3f1 100644 --- a/packages/twenty-server/src/modules/match-participant/utils/__tests__/find-person-by-primary-or-additional-email.spec.ts +++ b/packages/twenty-server/src/modules/match-participant/utils/__tests__/find-person-by-primary-or-additional-email.spec.ts @@ -127,14 +127,14 @@ describe('findPersonByPrimaryOrAdditionalEmail', () => { id: 'person-1', emails: { primaryEmail: 'other@example.com', - additionalEmails: [], + additionalEmails: null, }, }, { id: 'person-2', emails: { primaryEmail: 'test@example.com', - additionalEmails: [], + additionalEmails: null, }, }, ] as PersonWorkspaceEntity[]; diff --git a/packages/twenty-server/test/integration/constants/company-gql-fields.constants.ts b/packages/twenty-server/test/integration/constants/company-gql-fields.constants.ts new file mode 100644 index 00000000000..f4de6354c8f --- /dev/null +++ b/packages/twenty-server/test/integration/constants/company-gql-fields.constants.ts @@ -0,0 +1,16 @@ +export const COMPANY_GQL_FIELDS = ` + id + name + domainName { + primaryLinkLabel + primaryLinkUrl + secondaryLinks + } + linkedinLink { + primaryLinkLabel + primaryLinkUrl + secondaryLinks + } + createdAt + deletedAt +`; diff --git a/packages/twenty-server/test/integration/constants/person-gql-fields.constants.ts b/packages/twenty-server/test/integration/constants/person-gql-fields.constants.ts index 2aa86e41670..441c44af5a4 100644 --- a/packages/twenty-server/test/integration/constants/person-gql-fields.constants.ts +++ b/packages/twenty-server/test/integration/constants/person-gql-fields.constants.ts @@ -11,6 +11,19 @@ export const PERSON_GQL_FIELDS = ` } emails { primaryEmail + additionalEmails + } + phones { + primaryPhoneNumber + primaryPhoneCountryCode + primaryPhoneCallingCode + additionalPhones + } + whatsapp { + primaryPhoneNumber + primaryPhoneCountryCode + primaryPhoneCallingCode + additionalPhones } createdAt deletedAt diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/companies-merge-many.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/companies-merge-many.integration-spec.ts new file mode 100644 index 00000000000..c8e11340996 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-generated/companies-merge-many.integration-spec.ts @@ -0,0 +1,252 @@ +import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants'; +import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { mergeManyOperationFactory } from 'test/integration/graphql/utils/merge-many-operation-factory.util'; +import { deleteRecordsByIds } from 'test/integration/utils/delete-records-by-ids'; + +describe('companies merge resolvers (integration)', () => { + let createdCompanyIds: string[] = []; + + afterEach(async () => { + if (createdCompanyIds.length > 0) { + await deleteRecordsByIds('company', createdCompanyIds); + createdCompanyIds = []; + } + }); + + describe('merging links composite fields', () => { + it('should merge linkedinLink composite field correctly', async () => { + const createCompaniesOperation = createManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS, + data: [ + { + name: 'Company A', + linkedinLink: { + primaryLinkUrl: 'https://linkedin.com/company/company-a', + primaryLinkLabel: 'Main LinkedIn', + secondaryLinks: [ + { + url: 'linkedin.com/company/subsidiary-a1', + label: 'Subsidiary A1', + }, + { + url: 'linkedin.com/company/subsidiary-a2', + label: 'Subsidiary A2', + }, + ], + }, + }, + { + name: 'Company B', + linkedinLink: { + primaryLinkUrl: 'https://linkedin.com/company/company-b', + primaryLinkLabel: 'Main LinkedIn', + secondaryLinks: [ + { + url: 'linkedin.com/company/subsidiary-b1', + label: 'Subsidiary B1', + }, + { + url: 'linkedin.com/company/subsidiary-b2', + label: 'Subsidiary B2', + }, + ], + }, + }, + ], + }); + + const createResponse = await makeGraphqlAPIRequest( + createCompaniesOperation, + ); + + expect(createResponse.body.data.createCompanies).toHaveLength(2); + + const company1Id = createResponse.body.data.createCompanies[0].id; + const company2Id = createResponse.body.data.createCompanies[1].id; + + createdCompanyIds.push(company1Id, company2Id); + + const mergeOperation = mergeManyOperationFactory({ + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS, + ids: [company1Id, company2Id], + conflictPriorityIndex: 0, + }); + + const mergeResponse = await makeGraphqlAPIRequest(mergeOperation); + + expect(mergeResponse.body.errors).toBeUndefined(); + + const mergedCompany = mergeResponse.body.data.mergeCompanies; + + expect(mergedCompany.linkedinLink.primaryLinkUrl).toBe( + 'https://linkedin.com/company/company-a', + ); + expect(mergedCompany.linkedinLink.primaryLinkLabel).toBe('Main LinkedIn'); + expect(mergedCompany.linkedinLink.secondaryLinks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + url: 'linkedin.com/company/subsidiary-a1', + }), + expect.objectContaining({ + url: 'linkedin.com/company/subsidiary-a2', + }), + expect.objectContaining({ + url: 'linkedin.com/company/subsidiary-b1', + }), + expect.objectContaining({ + url: 'linkedin.com/company/subsidiary-b2', + }), + ]), + ); + expect(mergedCompany.linkedinLink.secondaryLinks).toHaveLength(4); + }); + + it('should merge links with deduplication', async () => { + const createCompaniesOperation = createManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS, + data: [ + { + name: 'Tech Corp', + linkedinLink: { + primaryLinkUrl: 'https://linkedin.com/company/tech-corp', + primaryLinkLabel: '', + secondaryLinks: [ + { + url: 'linkedin.com/company/shared-subsidiary', + label: 'Shared Sub', + }, + { + url: 'linkedin.com/company/tech-division', + label: 'Tech Division', + }, + ], + }, + }, + { + name: 'Corp Tech', + linkedinLink: { + primaryLinkUrl: 'https://linkedin.com/company/corp-tech', + primaryLinkLabel: '', + secondaryLinks: [ + { + url: 'linkedin.com/company/shared-subsidiary', + label: 'Shared Sub Different Label', + }, + { + url: 'linkedin.com/company/corp-division', + label: 'Corp Division', + }, + ], + }, + }, + ], + }); + + const createResponse = await makeGraphqlAPIRequest( + createCompaniesOperation, + ); + const company1Id = createResponse.body.data.createCompanies[0].id; + const company2Id = createResponse.body.data.createCompanies[1].id; + + createdCompanyIds.push(company1Id, company2Id); + + const mergeOperation = mergeManyOperationFactory({ + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS, + ids: [company1Id, company2Id], + conflictPriorityIndex: 0, + }); + + const mergeResponse = await makeGraphqlAPIRequest(mergeOperation); + const mergedCompany = mergeResponse.body.data.mergeCompanies; + + expect(mergedCompany.linkedinLink.primaryLinkUrl).toBe( + 'https://linkedin.com/company/tech-corp', + ); + const secondaryLinks = mergedCompany.linkedinLink.secondaryLinks; + + expect(secondaryLinks).toHaveLength(3); + + const urls = secondaryLinks.map((link: { url: string }) => link.url); + + expect(urls).toEqual( + expect.arrayContaining([ + 'linkedin.com/company/shared-subsidiary', + 'linkedin.com/company/tech-division', + 'linkedin.com/company/corp-division', + ]), + ); + + const duplicateCount = urls.filter( + (url: string) => url === 'linkedin.com/company/shared-subsidiary', + ).length; + + expect(duplicateCount).toBe(1); + }); + + it('should respect priority index for links unique constraint', async () => { + const createCompaniesOperation = createManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS, + data: [ + { + name: 'Priority First', + linkedinLink: { + primaryLinkUrl: 'https://linkedin.com/company/first-priority', + primaryLinkLabel: 'First Label', + secondaryLinks: [ + { url: 'linkedin.com/company/first-sub', label: 'First Sub' }, + ], + }, + }, + { + name: 'Priority Second', + linkedinLink: { + primaryLinkUrl: 'https://linkedin.com/company/second-priority', + primaryLinkLabel: 'Second Label', + secondaryLinks: [ + { url: 'linkedin.com/company/second-sub', label: 'Second Sub' }, + ], + }, + }, + ], + }); + + const createResponse = await makeGraphqlAPIRequest( + createCompaniesOperation, + ); + const company1Id = createResponse.body.data.createCompanies[0].id; + const company2Id = createResponse.body.data.createCompanies[1].id; + + createdCompanyIds.push(company1Id, company2Id); + + const mergeWithPriority1 = mergeManyOperationFactory({ + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS, + ids: [company1Id, company2Id], + conflictPriorityIndex: 1, + }); + + const mergeResponse = await makeGraphqlAPIRequest(mergeWithPriority1); + const mergedCompany = mergeResponse.body.data.mergeCompanies; + + expect(mergedCompany.linkedinLink.primaryLinkUrl).toBe( + 'https://linkedin.com/company/second-priority', + ); + expect(mergedCompany.linkedinLink.primaryLinkLabel).toBe('Second Label'); + expect(mergedCompany.linkedinLink.secondaryLinks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ url: 'linkedin.com/company/first-sub' }), + expect.objectContaining({ url: 'linkedin.com/company/second-sub' }), + ]), + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/people-merge-many.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/people-merge-many.integration-spec.ts new file mode 100644 index 00000000000..83b003f3e51 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-generated/people-merge-many.integration-spec.ts @@ -0,0 +1,408 @@ +import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; +import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { findOneOperationFactory } from 'test/integration/graphql/utils/find-one-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { mergeManyOperationFactory } from 'test/integration/graphql/utils/merge-many-operation-factory.util'; +import { deleteRecordsByIds } from 'test/integration/utils/delete-records-by-ids'; + +describe('people merge resolvers (integration)', () => { + let createdPersonIds: string[] = []; + + afterEach(async () => { + if (createdPersonIds.length > 0) { + await deleteRecordsByIds('person', createdPersonIds); + createdPersonIds = []; + } + }); + + describe('merging composite fields', () => { + it('should merge emails composite field correctly', async () => { + const createPersonsOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + data: [ + { + name: { + firstName: 'John', + lastName: 'Doe', + }, + emails: { + primaryEmail: 'john@example.com', + additionalEmails: [ + 'john.alt@example.com', + 'john.work@example.com', + ], + }, + }, + { + name: { + firstName: 'Jane', + lastName: 'Doe', + }, + emails: { + primaryEmail: 'jane@example.com', + additionalEmails: [ + 'jane.alt@example.com', + 'jane.personal@example.com', + ], + }, + }, + ], + }); + + const createResponse = await makeGraphqlAPIRequest( + createPersonsOperation, + ); + + expect(createResponse.body.data.createPeople).toHaveLength(2); + + const person1Id = createResponse.body.data.createPeople[0].id; + const person2Id = createResponse.body.data.createPeople[1].id; + + createdPersonIds.push(person1Id, person2Id); + + const mergeOperation = mergeManyOperationFactory({ + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + ids: [person1Id, person2Id], + conflictPriorityIndex: 0, + }); + + const mergeResponse = await makeGraphqlAPIRequest(mergeOperation); + + expect(mergeResponse.body.errors).toBeUndefined(); + + const mergedPerson = mergeResponse.body.data.mergePeople; + + expect(mergedPerson.emails.primaryEmail).toBe('john@example.com'); + expect(mergedPerson.emails.additionalEmails).toEqual( + expect.arrayContaining([ + 'john.alt@example.com', + 'john.work@example.com', + 'jane.alt@example.com', + 'jane.personal@example.com', + ]), + ); + expect(mergedPerson.emails.additionalEmails).toHaveLength(4); + }); + + it('should merge emails with deduplication', async () => { + const createPersonsOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + data: [ + { + name: { + firstName: 'Alice', + lastName: 'Smith', + }, + emails: { + primaryEmail: 'alice@example.com', + additionalEmails: [ + 'shared@example.com', + 'alice.work@example.com', + ], + }, + }, + { + name: { + firstName: 'Bob', + lastName: 'Smith', + }, + emails: { + primaryEmail: 'bob@example.com', + additionalEmails: ['shared@example.com', 'bob.work@example.com'], + }, + }, + ], + }); + + const createResponse = await makeGraphqlAPIRequest( + createPersonsOperation, + ); + const person1Id = createResponse.body.data.createPeople[0].id; + const person2Id = createResponse.body.data.createPeople[1].id; + + createdPersonIds.push(person1Id, person2Id); + + const mergeOperation = mergeManyOperationFactory({ + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + ids: [person1Id, person2Id], + conflictPriorityIndex: 0, + }); + + const mergeResponse = await makeGraphqlAPIRequest(mergeOperation); + const mergedPerson = mergeResponse.body.data.mergePeople; + + expect(mergedPerson.emails.primaryEmail).toBe('alice@example.com'); + const additionalEmails = mergedPerson.emails.additionalEmails; + + expect(additionalEmails).toHaveLength(3); + expect(additionalEmails).toEqual( + expect.arrayContaining([ + 'shared@example.com', + 'alice.work@example.com', + 'bob.work@example.com', + ]), + ); + + const duplicateCount = additionalEmails.filter( + (email: string) => email === 'shared@example.com', + ).length; + + expect(duplicateCount).toBe(1); + }); + + it('should respect priority index for unique constraint fields', async () => { + const createPersonsOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + data: [ + { + name: { + firstName: 'First', + lastName: 'Person', + }, + emails: { + primaryEmail: 'first@example.com', + additionalEmails: ['first.extra@example.com'], + }, + }, + { + name: { + firstName: 'Second', + lastName: 'Person', + }, + emails: { + primaryEmail: 'second@example.com', + additionalEmails: ['second.extra@example.com'], + }, + }, + ], + }); + + const createResponse = await makeGraphqlAPIRequest( + createPersonsOperation, + ); + const person1Id = createResponse.body.data.createPeople[0].id; + const person2Id = createResponse.body.data.createPeople[1].id; + + createdPersonIds.push(person1Id, person2Id); + + const mergeWithPriority1 = mergeManyOperationFactory({ + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + ids: [person1Id, person2Id], + conflictPriorityIndex: 1, + }); + + const mergeResponse = await makeGraphqlAPIRequest(mergeWithPriority1); + const mergedPerson = mergeResponse.body.data.mergePeople; + + expect(mergedPerson.emails.primaryEmail).toBe('second@example.com'); + expect(mergedPerson.emails.additionalEmails).toEqual( + expect.arrayContaining([ + 'first.extra@example.com', + 'second.extra@example.com', + ]), + ); + }); + + it('should handle dry run mode', async () => { + const createPersonsOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + data: [ + { + name: { + firstName: 'Test1', + lastName: 'User', + }, + emails: { + primaryEmail: 'test1@example.com', + additionalEmails: ['test1.extra@example.com'], + }, + }, + { + name: { + firstName: 'Test2', + lastName: 'User', + }, + emails: { + primaryEmail: 'test2@example.com', + additionalEmails: ['test2.extra@example.com'], + }, + }, + ], + }); + + const createResponse = await makeGraphqlAPIRequest( + createPersonsOperation, + ); + const person1Id = createResponse.body.data.createPeople[0].id; + const person2Id = createResponse.body.data.createPeople[1].id; + + createdPersonIds.push(person1Id, person2Id); + + const dryRunMergeOperation = mergeManyOperationFactory({ + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + ids: [person1Id, person2Id], + conflictPriorityIndex: 0, + dryRun: true, + }); + + const dryRunResponse = await makeGraphqlAPIRequest(dryRunMergeOperation); + + expect(dryRunResponse.body.errors).toBeUndefined(); + + const dryRunResult = dryRunResponse.body.data.mergePeople; + + expect(dryRunResult.emails.primaryEmail).toBe('test1@example.com'); + expect(dryRunResult.emails.additionalEmails).toEqual( + expect.arrayContaining([ + 'test1.extra@example.com', + 'test2.extra@example.com', + ]), + ); + + const findOriginalPersons = findOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + filter: { + id: { + eq: person2Id, + }, + }, + }); + + const findResponse = await makeGraphqlAPIRequest(findOriginalPersons); + + expect(findResponse.body.data.person).toBeTruthy(); + expect(findResponse.body.data.person.emails.primaryEmail).toBe( + 'test2@example.com', + ); + }); + + it('should merge phones and whatsapp composite fields correctly', async () => { + const createPersonsOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + data: [ + { + name: { + firstName: 'Alice', + lastName: 'Johnson', + }, + phones: { + primaryPhoneNumber: '5551234567', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '+1', + additionalPhones: [ + { + number: '5559876543', + callingCode: '+1', + countryCode: 'US', + }, + ], + }, + whatsapp: { + primaryPhoneNumber: '810407803', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', + additionalPhones: [ + { + number: '8104078034', + callingCode: '+91', + countryCode: 'IN', + }, + ], + }, + }, + { + name: { + firstName: 'Bob', + lastName: 'Johnson', + }, + phones: { + primaryPhoneNumber: '4445556789', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '+1', + additionalPhones: [ + { + number: '4441112222', + callingCode: '+1', + countryCode: 'US', + }, + ], + }, + whatsapp: { + primaryPhoneNumber: '987654321', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', + additionalPhones: [ + { + number: '123456789', + callingCode: '+44', + countryCode: 'GB', + }, + ], + }, + }, + ], + }); + + const createResponse = await makeGraphqlAPIRequest( + createPersonsOperation, + ); + + expect(createResponse.body.data.createPeople).toHaveLength(2); + + const person1Id = createResponse.body.data.createPeople[0].id; + const person2Id = createResponse.body.data.createPeople[1].id; + + createdPersonIds.push(person1Id, person2Id); + + const mergeOperation = mergeManyOperationFactory({ + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + ids: [person1Id, person2Id], + conflictPriorityIndex: 0, + }); + + const mergeResponse = await makeGraphqlAPIRequest(mergeOperation); + + expect(mergeResponse.body.errors).toBeUndefined(); + + const mergedPerson = mergeResponse.body.data.mergePeople; + + expect(mergedPerson.phones.primaryPhoneNumber).toBe('5551234567'); + expect(mergedPerson.phones.primaryPhoneCountryCode).toBe('US'); + expect(mergedPerson.phones.primaryPhoneCallingCode).toBe('+1'); + expect(mergedPerson.phones.additionalPhones).toEqual( + expect.arrayContaining([ + expect.objectContaining({ number: '5559876543' }), + expect.objectContaining({ number: '4441112222' }), + ]), + ); + expect(mergedPerson.phones.additionalPhones).toHaveLength(2); + + expect(mergedPerson.whatsapp.primaryPhoneNumber).toBe('810407803'); + expect(mergedPerson.whatsapp.primaryPhoneCountryCode).toBe('FR'); + expect(mergedPerson.whatsapp.primaryPhoneCallingCode).toBe('+33'); + expect(mergedPerson.whatsapp.additionalPhones).toEqual( + expect.arrayContaining([ + expect.objectContaining({ number: '8104078034' }), + expect.objectContaining({ number: '123456789' }), + ]), + ); + expect(mergedPerson.whatsapp.additionalPhones).toHaveLength(2); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/merge-many-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/merge-many-operation-factory.util.ts new file mode 100644 index 00000000000..7a14993fe6d --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/merge-many-operation-factory.util.ts @@ -0,0 +1,36 @@ +import gql from 'graphql-tag'; +import { capitalize } from 'twenty-shared/utils'; + +type MergeManyOperationFactoryParams = { + objectMetadataPluralName: string; + gqlFields: string; + ids: string[]; + conflictPriorityIndex: number; + dryRun?: boolean; +}; + +export const mergeManyOperationFactory = ({ + objectMetadataPluralName, + gqlFields, + ids, + conflictPriorityIndex, + dryRun = false, +}: MergeManyOperationFactoryParams) => { + const capitalizedObjectName = capitalize(objectMetadataPluralName); + const mutationName = `merge${capitalizedObjectName}`; + + return { + query: gql` + mutation Merge${capitalizedObjectName}($ids: [UUID!]!, $conflictPriorityIndex: Int!, $dryRun: Boolean! = false) { + ${mutationName}(ids: $ids, conflictPriorityIndex: $conflictPriorityIndex, dryRun: $dryRun) { + ${gqlFields} + } + } + `, + variables: { + ids, + conflictPriorityIndex, + dryRun, + }, + }; +}; diff --git a/packages/twenty-server/test/integration/utils/delete-records-by-ids.ts b/packages/twenty-server/test/integration/utils/delete-records-by-ids.ts new file mode 100644 index 00000000000..786a916d3c3 --- /dev/null +++ b/packages/twenty-server/test/integration/utils/delete-records-by-ids.ts @@ -0,0 +1,25 @@ +const TEST_SCHEMA_NAME = 'workspace_1wgvd1injqtife6y4rvfbu3h5'; + +export const deleteRecordsByIds = async ( + objectNameSingular: string, + recordIds: string[], +) => { + if (!recordIds.length) { + return; + } + + try { + // Create placeholders for parameterized query: $1, $2, $3, etc. + const placeholders = recordIds + .map((_, index) => `$${index + 1}`) + .join(', '); + + // @ts-expect-error legacy noImplicitAny + await global.testDataSource.query( + `DELETE from "${TEST_SCHEMA_NAME}"."${objectNameSingular}" WHERE id IN (${placeholders})`, + recordIds, + ); + } catch { + /* empty */ + } +}; diff --git a/packages/twenty-ui/package.json b/packages/twenty-ui/package.json index d36989f015c..3867bba9ee1 100644 --- a/packages/twenty-ui/package.json +++ b/packages/twenty-ui/package.json @@ -46,8 +46,8 @@ }, "files": [ "dist", - "accessibility", "assets", + "accessibility", "components", "display", "feedback",