diff --git a/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/validate.test.ts b/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/validate.test.ts index 442682540d..ca6d46c520 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/validate.test.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/validate.test.ts @@ -1,4 +1,9 @@ -import { mergeAndDeduplicateById, validate, ensureValidTimestamps } from '../functions/validation-functions' +import { + mergeAndDeduplicateById, + validate, + ensureValidTimestamps, + deDuplicateAssociations +} from '../functions/validation-functions' import { Payload } from '../generated-types' const payload: Payload = { @@ -228,3 +233,77 @@ describe('ensureValidTimestamps', () => { expect(ts).not.toBeNaN() }) }) + +describe('deDuplicateAssociations', () => { + const createAssociationPayload = (id: string) => ({ + object_details: { + object_type: 'contact', + id_field_name: 'email', + id_field_value: id, + from_record_id: 'from-123' + }, + association_details: { + association_label: 'HUBSPOT_DEFINED:primary' + } + }) + + it('returns empty array when association groups is empty', () => { + expect(deDuplicateAssociations([])).toEqual([]) + }) + + it('returns the same array when associations are unique within groups', () => { + const associationGroup1 = [createAssociationPayload('a@example.com'), createAssociationPayload('b@example.com')] + const associationGroup2 = [createAssociationPayload('c@example.com'), createAssociationPayload('d@example.com')] + + const result = deDuplicateAssociations([associationGroup1, associationGroup2]) + expect(result).toHaveLength(2) + expect(result[0]).toHaveLength(2) + expect(result[1]).toHaveLength(2) + }) + + it('removes duplicate associations within each group', () => { + const assocPayload = createAssociationPayload('a@example.com') + const duplicateGroup = [assocPayload, { ...assocPayload }, { ...assocPayload }] + + const result = deDuplicateAssociations([duplicateGroup]) + expect(result).toHaveLength(1) + expect(result[0]).toHaveLength(1) + expect(result[0][0]).toEqual(assocPayload) + }) + + it('keeps the last occurrence of a duplicate association within a group', () => { + const assoc1 = createAssociationPayload('a@example.com') + const assoc2 = { + ...createAssociationPayload('a@example.com'), + object_details: { + ...createAssociationPayload('a@example.com').object_details, + extra: 'last' + } + } + + const associationGroup = [assoc1, assoc2] + + const result = deDuplicateAssociations([associationGroup]) + expect(result).toHaveLength(1) + expect(result[0]).toHaveLength(1) + expect(result[0][0]).toEqual(assoc2) + }) + + it('handles multiple groups with different duplicates', () => { + const group1 = [ + createAssociationPayload('a@example.com'), + createAssociationPayload('a@example.com') // duplicate + ] + + const group2 = [ + createAssociationPayload('b@example.com'), + createAssociationPayload('c@example.com'), + createAssociationPayload('c@example.com') // duplicate + ] + + const result = deDuplicateAssociations([group1, group2]) + expect(result).toHaveLength(2) + expect(result[0]).toHaveLength(1) // deduped to 1 + expect(result[1]).toHaveLength(2) // deduped to 2 + }) +}) diff --git a/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/validation-functions.ts b/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/validation-functions.ts index f9c1906cd1..cac349505b 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/validation-functions.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/validation-functions.ts @@ -1,6 +1,6 @@ import { PayloadValidationError, StatsContext } from '@segment/actions-core' import { Payload } from '../generated-types' -import { Association } from '../types' +import { Association, AssociationPayload } from '../types' import { getListPayloadType, getListName } from '../functions/hubspot-list-functions' export function validate(payloads: Payload[], flag?: boolean): Payload[] { @@ -21,23 +21,21 @@ export function validate(payloads: Payload[], flag?: boolean): Payload[] { ) } - if (flag === true){ - const hasEngageAudience = cleaned.some((p) => getListPayloadType(p) === 'is_engage_audience_payload') - const hasNonEngageAudience = cleaned.some((p) => getListPayloadType(p) === 'is_non_engage_audience_payload') - - if (hasEngageAudience && hasNonEngageAudience){ - throw new PayloadValidationError( - 'Engage and non Engage payloads cannot be mixed in the same batch.' - ) + if (flag === true) { + const hasEngageAudience = cleaned.some((p) => getListPayloadType(p) === 'is_engage_audience_payload') + const hasNonEngageAudience = cleaned.some((p) => getListPayloadType(p) === 'is_non_engage_audience_payload') + + if (hasEngageAudience && hasNonEngageAudience) { + throw new PayloadValidationError('Engage and non Engage payloads cannot be mixed in the same batch.') } - const listNames = Array.from( - new Set(cleaned.map((p) => getListName(p)).filter(Boolean)) - ) + const listNames = Array.from(new Set(cleaned.map((p) => getListName(p)).filter(Boolean))) - if (listNames.length > 1){ + if (listNames.length > 1) { throw new PayloadValidationError( - `When updating List membership, all payloads must reference the same list. Found multiple lists in the batch: ${listNames.slice(0, 3).join(', ')}` + `When updating List membership, all payloads must reference the same list. Found multiple lists in the batch: ${listNames + .slice(0, 3) + .join(', ')}` ) } } @@ -66,7 +64,7 @@ export function validate(payloads: Payload[], flag?: boolean): Payload[] { return fieldsToCheck.every((field) => field !== null && field !== '') }) }) - + return cleaned } @@ -235,3 +233,32 @@ function isValidTimestamp(ts: unknown): ts is string | number | Date { } return false } + +/** + * Removes duplicate associations from the provided array based on a composite key + * + * @param associationGroups - An array of arrays of `AssociationPayload` objects. + * @returns An array of arrays of unique `AssociationPayload` objects. + */ +export function deDuplicateAssociations(associationGroups: AssociationPayload[][]): AssociationPayload[][] { + if (!associationGroups || associationGroups.length === 0) { + return associationGroups + } + + return associationGroups.map((group) => { + if (!group || group.length === 0) return group + + const associationKey = (assoc: AssociationPayload) => + `${assoc.object_details.object_type}|${assoc.association_details.association_label}|${assoc.object_details.id_field_name}|${assoc.object_details.id_field_value}` + + const uniqueAssociationsMap = new Map() + + for (const assoc of group) { + uniqueAssociationsMap.set(associationKey(assoc), assoc) + } + + const dedupedGroup = Array.from(uniqueAssociationsMap.values()) + + return dedupedGroup + }) +} diff --git a/packages/destination-actions/src/destinations/hubspot/upsertObject/index.ts b/packages/destination-actions/src/destinations/hubspot/upsertObject/index.ts index 8832d4a117..7209197f70 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertObject/index.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertObject/index.ts @@ -7,7 +7,12 @@ import { Client } from './client' import { AssociationSyncMode, SyncMode, SchemaMatch, RequestData } from './types' import { dynamicFields } from './functions/dynamic-field-functions' import { getSchemaFromCache, saveSchemaToCache } from './functions/cache-functions' -import { ensureValidTimestamps, mergeAndDeduplicateById, validate } from './functions/validation-functions' +import { + deDuplicateAssociations, + ensureValidTimestamps, + mergeAndDeduplicateById, + validate +} from './functions/validation-functions' import { objectSchema, compareSchemas } from './functions/schema-functions' import { sendFromRecords } from './functions/hubspot-record-functions' import { getListName, ensureList, sendLists } from './functions/hubspot-list-functions' @@ -115,7 +120,8 @@ const send = async ( } const fromRecordPayloads = await sendFromRecords(client, validPayloads, objectType, syncMode) - const associationPayloads = createAssociationPayloads(fromRecordPayloads, 'associations') + let associationPayloads = createAssociationPayloads(fromRecordPayloads, 'associations') + associationPayloads = deDuplicateAssociations(associationPayloads) const associatedRecords = await sendAssociatedRecords( client, associationPayloads,