Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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('[email protected]'), createAssociationPayload('[email protected]')]
const associationGroup2 = [createAssociationPayload('[email protected]'), createAssociationPayload('[email protected]')]

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('[email protected]')
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('[email protected]')
const assoc2 = {
...createAssociationPayload('[email protected]'),
object_details: {
...createAssociationPayload('[email protected]').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('[email protected]'),
createAssociationPayload('[email protected]') // duplicate
]

const group2 = [
createAssociationPayload('[email protected]'),
createAssociationPayload('[email protected]'),
createAssociationPayload('[email protected]') // 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
})
})
Original file line number Diff line number Diff line change
@@ -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[] {
Expand All @@ -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(', ')}`
)
}
}
Expand Down Expand Up @@ -66,7 +64,7 @@ export function validate(payloads: Payload[], flag?: boolean): Payload[] {
return fieldsToCheck.every((field) => field !== null && field !== '')
})
})

return cleaned
}

Expand Down Expand Up @@ -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<string, AssociationPayload>()

for (const assoc of group) {
uniqueAssociationsMap.set(associationKey(assoc), assoc)
}

const dedupedGroup = Array.from(uniqueAssociationsMap.values())

return dedupedGroup
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
Loading