From b0d089e65574cf74b9ff5faa38a4f52c6ce81667 Mon Sep 17 00:00:00 2001 From: Harsh Joshi Date: Fri, 3 Oct 2025 23:37:10 +0530 Subject: [PATCH 1/2] Add de duplication for assosiations --- .../upsertObject/__tests__/validate.test.ts | 81 ++++++++++++++++++- .../functions/validation-functions.ts | 57 +++++++++---- .../hubspot/upsertObject/index.ts | 10 ++- 3 files changed, 130 insertions(+), 18 deletions(-) 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 442682540d6..ca6d46c5204 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 f9c1906cd10..cac349505b0 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 8832d4a117b..7209197f70a 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, From 8ea449ee33a0a3bc19de6206acbcebf5a3ec1522 Mon Sep 17 00:00:00 2001 From: harsh-joshi99 <129737395+harsh-joshi99@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:04:48 +0530 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../hubspot/upsertObject/functions/validation-functions.ts | 7 ++++++- .../src/destinations/hubspot/upsertObject/index.ts | 3 +-- 2 files changed, 7 insertions(+), 3 deletions(-) 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 cac349505b0..9e07a0e14fe 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 @@ -249,7 +249,12 @@ export function deDuplicateAssociations(associationGroups: AssociationPayload[][ 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}` + JSON.stringify({ + object_type: assoc.object_details.object_type, + association_label: assoc.association_details.association_label, + id_field_name: assoc.object_details.id_field_name, + id_field_value: assoc.object_details.id_field_value + }) const uniqueAssociationsMap = new Map() diff --git a/packages/destination-actions/src/destinations/hubspot/upsertObject/index.ts b/packages/destination-actions/src/destinations/hubspot/upsertObject/index.ts index 7209197f70a..cdbe123236f 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertObject/index.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertObject/index.ts @@ -120,8 +120,7 @@ const send = async ( } const fromRecordPayloads = await sendFromRecords(client, validPayloads, objectType, syncMode) - let associationPayloads = createAssociationPayloads(fromRecordPayloads, 'associations') - associationPayloads = deDuplicateAssociations(associationPayloads) + const associationPayloads = deDuplicateAssociations(createAssociationPayloads(fromRecordPayloads, 'associations')) const associatedRecords = await sendAssociatedRecords( client, associationPayloads,