From 62e9994f4784cfd9233003fd8384ce7acb1b76e6 Mon Sep 17 00:00:00 2001 From: Monu Kumar Date: Thu, 20 Aug 2026 22:17:26 +0530 Subject: [PATCH] STRATCONN-6637 [Hubspot] Fix duplicate ids in associated record batch requests Custom Object V2 sent one associated-record input per association payload. A mapping that associates the same record under two association labels therefore repeated that record's id within a single HubSpot batch, which the id property's uniqueness constraint rejects with a 400. The contact upsert had already committed by then, so records synced while their companies and associations were never created. Dedupe the request inputs by id_field_value in readAssociatedRecords and upsertAssociatedRecords, leaving the grouped payload list untouched so every label still gets its association. returnAssociatedRecordsWithIds matches responses by property value rather than by index, so a request carrying fewer inputs than its group still stamps record_id on all of them. Also add from_record_id to the deDuplicateAssociations key. Without it, two records associating to the same target under the same label collapsed into one and the second silently lost its association - a latent data-loss bug that the input dedupe above would otherwise have masked. Co-Authored-By: Claude Opus 5 (1M context) --- .../multi-label-associations.test.ts | 473 ++++++++++++++++++ .../hubspot-association-functions.ts | 33 +- .../functions/validation-functions.ts | 1 + 3 files changed, 503 insertions(+), 4 deletions(-) create mode 100644 packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/multi-label-associations.test.ts diff --git a/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/multi-label-associations.test.ts b/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/multi-label-associations.test.ts new file mode 100644 index 00000000000..501f367bce7 --- /dev/null +++ b/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/multi-label-associations.test.ts @@ -0,0 +1,473 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' +import Definition from '../../index' +import { Settings } from '../../generated-types' +import { HUBSPOT_BASE_URL } from '../../properties' + +let testDestination = createTestIntegration(Definition) +const settings: Settings = {} + +const payload = { + event: 'Test Custom Object Event', + type: 'track', + userId: 'user_id_1', + properties: { + email: 'test@test.com', + company_id: 'company_id_1' + } +} as Partial + +const mapping = { + __segment_internal_sync_mode: 'upsert', + object_details: { + object_type: 'contact', + id_field_name: 'email', + id_field_value: { '@path': '$.properties.email' }, + property_group: 'contactinformation' + }, + association_sync_mode: 'upsert', + enable_batching: true, + batch_size: 100, + associations: [ + { + object_type: 'company', + association_label: 'HUBSPOT_DEFINED:279', + id_field_name: 'kompany', + id_field_value: { '@path': '$.properties.company_id' } + }, + { + object_type: 'company', + association_label: 'HUBSPOT_DEFINED:1', + id_field_name: 'kompany', + id_field_value: { '@path': '$.properties.company_id' } + } + ] +} + +beforeEach((done) => { + testDestination = createTestIntegration(Definition) + nock.cleanAll() + done() +}) + +describe('Hubspot.upsertObject', () => { + describe('associated records referenced under multiple association labels', () => { + it('should upsert the associated record once, and create one association per label', async () => { + const upsertObjectReq = { + inputs: [ + { + idProperty: 'email', + id: 'test@test.com', + properties: { + email: 'test@test.com' + } + } + ] + } + + const upsertObjectResp = { + results: [ + { + id: '62102303560', + properties: { + email: 'test@test.com' + } + } + ] + } + + // company_id_1 is referenced by both association labels, but must appear only once in this request + const upsertAssocCompanyRecordReq = { + inputs: [ + { + idProperty: 'kompany', + id: 'company_id_1', + properties: { + kompany: 'company_id_1' + } + } + ] + } + + const upsertAssocCompanyRecordResp = { + results: [ + { + id: '798758764867', + properties: { + kompany: 'company_id_1' + } + } + ] + } + + // both labels are still associated to the same company record + const upsertCompanyAssociationReq = { + inputs: [ + { + types: [ + { + associationCategory: 'HUBSPOT_DEFINED', + associationTypeId: '279' + } + ], + from: { + id: '62102303560' + }, + to: { + id: '798758764867' + } + }, + { + types: [ + { + associationCategory: 'HUBSPOT_DEFINED', + associationTypeId: '1' + } + ], + from: { + id: '62102303560' + }, + to: { + id: '798758764867' + } + } + ] + } + + nock(HUBSPOT_BASE_URL).post('/crm/v3/objects/contact/batch/upsert', upsertObjectReq).reply(200, upsertObjectResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v3/objects/company/batch/upsert', upsertAssocCompanyRecordReq) + .reply(200, upsertAssocCompanyRecordResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v4/associations/contact/company/batch/create', upsertCompanyAssociationReq) + .reply(200) + + const responses = await testDestination.testAction('upsertObject', { + event: createTestEvent(payload), + settings, + useDefaultMappings: true, + mapping + }) + + expect(responses.length).toBe(3) + expect(nock.isDone()).toBe(true) + }) + + it('should read the associated record once when the association sync mode is read', async () => { + const upsertObjectReq = { + inputs: [ + { + idProperty: 'email', + id: 'test@test.com', + properties: { + email: 'test@test.com' + } + } + ] + } + + const upsertObjectResp = { + results: [ + { + id: '62102303560', + properties: { + email: 'test@test.com' + } + } + ] + } + + const readAssocCompanyRecordReq = { + idProperty: 'kompany', + properties: ['kompany'], + inputs: [{ id: 'company_id_1' }] + } + + const readAssocCompanyRecordResp = { + results: [ + { + id: '798758764867', + properties: { + kompany: 'company_id_1' + } + } + ] + } + + const upsertCompanyAssociationReq = { + inputs: [ + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '279' }], + from: { id: '62102303560' }, + to: { id: '798758764867' } + }, + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '1' }], + from: { id: '62102303560' }, + to: { id: '798758764867' } + } + ] + } + + nock(HUBSPOT_BASE_URL).post('/crm/v3/objects/contact/batch/upsert', upsertObjectReq).reply(200, upsertObjectResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v3/objects/company/batch/read', readAssocCompanyRecordReq) + .reply(200, readAssocCompanyRecordResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v4/associations/contact/company/batch/create', upsertCompanyAssociationReq) + .reply(200) + + const responses = await testDestination.testAction('upsertObject', { + event: createTestEvent(payload), + settings, + useDefaultMappings: true, + mapping: { ...mapping, association_sync_mode: 'read' } + }) + + expect(responses.length).toBe(3) + expect(nock.isDone()).toBe(true) + }) + }) + + describe('multiple records associated to the same record under the same label', () => { + it('should keep one association per record instead of de-duplicating across records', async () => { + const payload2 = { + ...payload, + properties: { + email: 'test2@test.com', + company_id: 'company_id_1' + } + } as Partial + + const singleLabelMapping = { + ...mapping, + associations: [ + { + object_type: 'company', + association_label: 'HUBSPOT_DEFINED:1', + id_field_name: 'kompany', + id_field_value: { '@path': '$.properties.company_id' } + } + ] + } + + const upsertObjectReq = { + inputs: [ + { + idProperty: 'email', + id: 'test@test.com', + properties: { + email: 'test@test.com' + } + }, + { + idProperty: 'email', + id: 'test2@test.com', + properties: { + email: 'test2@test.com' + } + } + ] + } + + const upsertObjectResp = { + results: [ + { + id: 'hubspot_contact_id_value_1', + properties: { + email: 'test@test.com' + } + }, + { + id: 'hubspot_contact_id_value_2', + properties: { + email: 'test2@test.com' + } + } + ] + } + + // both contacts reference the same company, which must be upserted only once + const upsertAssocCompanyRecordReq = { + inputs: [ + { + idProperty: 'kompany', + id: 'company_id_1', + properties: { + kompany: 'company_id_1' + } + } + ] + } + + const upsertAssocCompanyRecordResp = { + results: [ + { + id: '798758764867', + properties: { + kompany: 'company_id_1' + } + } + ] + } + + // neither contact may lose its association to the shared company + const upsertCompanyAssociationReq = { + inputs: [ + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '1' }], + from: { id: 'hubspot_contact_id_value_1' }, + to: { id: '798758764867' } + }, + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '1' }], + from: { id: 'hubspot_contact_id_value_2' }, + to: { id: '798758764867' } + } + ] + } + + nock(HUBSPOT_BASE_URL).post('/crm/v3/objects/contact/batch/upsert', upsertObjectReq).reply(200, upsertObjectResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v3/objects/company/batch/upsert', upsertAssocCompanyRecordReq) + .reply(200, upsertAssocCompanyRecordResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v4/associations/contact/company/batch/create', upsertCompanyAssociationReq) + .reply(200) + + const responses = await testDestination.testBatchAction('upsertObject', { + events: [createTestEvent(payload), createTestEvent(payload2)], + settings, + useDefaultMappings: true, + mapping: singleLabelMapping + }) + + expect(responses.length).toBe(3) + expect(nock.isDone()).toBe(true) + }) + }) + + describe('multiple records, each associated to the same record under multiple labels', () => { + it('should upsert the associated record once and create every record and label combination', async () => { + const payload2 = { + ...payload, + properties: { + email: 'test2@test.com', + company_id: 'company_id_1' + } + } as Partial + + const upsertObjectReq = { + inputs: [ + { + idProperty: 'email', + id: 'test@test.com', + properties: { + email: 'test@test.com' + } + }, + { + idProperty: 'email', + id: 'test2@test.com', + properties: { + email: 'test2@test.com' + } + } + ] + } + + const upsertObjectResp = { + results: [ + { + id: 'hubspot_contact_id_value_1', + properties: { + email: 'test@test.com' + } + }, + { + id: 'hubspot_contact_id_value_2', + properties: { + email: 'test2@test.com' + } + } + ] + } + + // four association payloads (2 contacts x 2 labels) collapse to a single record input + const upsertAssocCompanyRecordReq = { + inputs: [ + { + idProperty: 'kompany', + id: 'company_id_1', + properties: { + kompany: 'company_id_1' + } + } + ] + } + + const upsertAssocCompanyRecordResp = { + results: [ + { + id: '798758764867', + properties: { + kompany: 'company_id_1' + } + } + ] + } + + // ... and fan back out to every contact and label combination + const upsertCompanyAssociationReq = { + inputs: [ + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '279' }], + from: { id: 'hubspot_contact_id_value_1' }, + to: { id: '798758764867' } + }, + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '1' }], + from: { id: 'hubspot_contact_id_value_1' }, + to: { id: '798758764867' } + }, + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '279' }], + from: { id: 'hubspot_contact_id_value_2' }, + to: { id: '798758764867' } + }, + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '1' }], + from: { id: 'hubspot_contact_id_value_2' }, + to: { id: '798758764867' } + } + ] + } + + nock(HUBSPOT_BASE_URL).post('/crm/v3/objects/contact/batch/upsert', upsertObjectReq).reply(200, upsertObjectResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v3/objects/company/batch/upsert', upsertAssocCompanyRecordReq) + .reply(200, upsertAssocCompanyRecordResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v4/associations/contact/company/batch/create', upsertCompanyAssociationReq) + .reply(200) + + const responses = await testDestination.testBatchAction('upsertObject', { + events: [createTestEvent(payload), createTestEvent(payload2)], + settings, + useDefaultMappings: true, + mapping + }) + + expect(responses.length).toBe(3) + expect(nock.isDone()).toBe(true) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/hubspot-association-functions.ts b/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/hubspot-association-functions.ts index 8c00f4cdbad..1cca64ceb8d 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/hubspot-association-functions.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/hubspot-association-functions.ts @@ -77,6 +77,27 @@ export async function sendAssociatedRecords( } } +/** + * Returns one payload per unique `id_field_value` within a group. + * + * A group can legitimately hold several payloads for the same record - for example when the same record is + * associated under more than one association label. Those payloads must all be kept for the association + * requests, but the record requests only identify records by `id_field_value`, so sending one input per + * payload would repeat the same record within a single batch. HubSpot rejects such a batch. + * + * Grouping guarantees `object_type` and `id_field_name` are identical across the group, so `id_field_value` + * alone is a sufficient key. + */ +function uniqueRecordsById(payloads: AssociationPayload[]): AssociationPayload[] { + const uniquePayloads = new Map() + + for (const payload of payloads) { + uniquePayloads.set(payload.object_details.id_field_value, payload) + } + + return Array.from(uniquePayloads.values()) +} + export async function readAssociatedRecords( client: Client, groupedPayloads: AssociationPayload[][] @@ -87,7 +108,7 @@ export async function readAssociatedRecords( return await client.batchObjectRequest(AssociationSyncMode.Read, objectType, { idProperty: payloads[0].object_details.id_field_name, properties: [payloads[0].object_details.id_field_name], - inputs: payloads.map((payload) => { + inputs: uniqueRecordsById(payloads).map((payload) => { return { id: payload.object_details.id_field_value } @@ -106,7 +127,7 @@ async function upsertAssociatedRecords( const { object_type: objectType } = payloads[0].object_details return await client.batchObjectRequest(AssociationSyncMode.Upsert, objectType, { - inputs: payloads.map((payload) => { + inputs: uniqueRecordsById(payloads).map((payload) => { return { idProperty: payload.object_details.id_field_name, id: payload.object_details.id_field_value, @@ -142,7 +163,11 @@ function returnAssociatedRecordsWithIds( .filter((payload) => (payload as AssociationPayloadWithId).object_details.record_id) as AssociationPayloadWithId[] } -export async function sendAssociations(client: Client, payloads: AssociationPayloadWithId[], action: AssociationsAction) { +export async function sendAssociations( + client: Client, + payloads: AssociationPayloadWithId[], + action: AssociationsAction +) { const groupedPayloads: AssociationPayloadWithId[][] = groupPayloads(payloads as AssociationPayload[], [ 'object_type' ]) as AssociationPayloadWithId[][] @@ -183,4 +208,4 @@ export async function sendAssociations(client: Client, payloads: AssociationPayl function getAssociationType(associationLabel: string): AssociationType { const [associationCategory, associationTypeId] = associationLabel.split(':') return { associationCategory, associationTypeId } as AssociationType -} \ No newline at end of file +} 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 9e07a0e14fe..9ae1d9a9117 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 @@ -250,6 +250,7 @@ export function deDuplicateAssociations(associationGroups: AssociationPayload[][ const associationKey = (assoc: AssociationPayload) => JSON.stringify({ + from_record_id: assoc.object_details.from_record_id, object_type: assoc.object_details.object_type, association_label: assoc.association_details.association_label, id_field_name: assoc.object_details.id_field_name,