Skip to content

Commit f23b55f

Browse files
committed
Update hubspt custom event action to handle numeric strings
1 parent fbf248e commit f23b55f

File tree

5 files changed

+342
-8
lines changed

5 files changed

+342
-8
lines changed

packages/destination-actions/src/destinations/hubspot/customEvent/__tests__/index.test.ts

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,4 +815,288 @@ describe('Hubspot.customEvent', () => {
815815
)
816816
})
817817
})
818+
819+
describe('numeric string handling', () => {
820+
const numericStringPayload = {
821+
timestamp: timestamp,
822+
event: 'Custom Event 2',
823+
messageId: 'aaa-bbb-ccc',
824+
type: 'track',
825+
userId: 'user_id_1',
826+
properties: {
827+
custom_prop_str: 'Hello String!',
828+
custom_prop_numeric_string: '123' // will be inferred as number but should be string
829+
}
830+
} as Partial<SegmentEvent>
831+
832+
const expectedNumericStringPayload = {
833+
eventName: 'pe23132826_custom_event_2',
834+
objectId: undefined,
835+
836+
utk: undefined,
837+
occurredAt: timestamp,
838+
properties: {
839+
custom_prop_str: 'Hello String!',
840+
custom_prop_numeric_string: '123' // converted to string
841+
}
842+
}
843+
844+
const multipleNumericStringsPayload = {
845+
timestamp: timestamp,
846+
event: 'Custom Event 3',
847+
messageId: 'aaa-bbb-ccc',
848+
type: 'track',
849+
userId: 'user_id_1',
850+
properties: {
851+
prop1: 123,
852+
prop2: 456.78,
853+
prop3: 0,
854+
prop4: 'regular string'
855+
}
856+
} as Partial<SegmentEvent>
857+
858+
const expectedMultipleNumericStringsPayload = {
859+
eventName: 'pe23132826_custom_event_3',
860+
objectId: undefined,
861+
862+
utk: undefined,
863+
occurredAt: timestamp,
864+
properties: {
865+
prop1: '123',
866+
prop2: '456.78',
867+
prop3: '0',
868+
prop4: 'regular string'
869+
}
870+
}
871+
872+
const numericTypePayload = {
873+
timestamp: timestamp,
874+
event: 'Custom Event 6',
875+
messageId: 'aaa-bbb-ccc',
876+
type: 'track',
877+
userId: 'user_id_1',
878+
properties: {
879+
numeric_prop: 123,
880+
string_prop: 'test'
881+
}
882+
} as Partial<SegmentEvent>
883+
884+
const expectedNumericTypePayload = {
885+
eventName: 'pe23132826_custom_event_6',
886+
objectId: undefined,
887+
888+
utk: undefined,
889+
occurredAt: timestamp,
890+
properties: {
891+
numeric_prop: 123, // should stay as number
892+
string_prop: 'test'
893+
}
894+
}
895+
896+
it('should convert numeric string from cache and send event without hitting HubSpot', async () => {
897+
const event = createTestEvent(numericStringPayload)
898+
899+
// fetches the event definition from Hubspot
900+
nock('https://api.hubapi.com')
901+
.get('/events/v3/event-definitions/custom_event_2/?includeProperties=true')
902+
.reply(200, {
903+
name: 'custom_event_2',
904+
fullyQualifiedName: 'pe23132826_custom_event_2',
905+
properties: [
906+
{
907+
name: 'custom_prop_str',
908+
type: 'string',
909+
archived: false
910+
},
911+
{
912+
name: 'custom_prop_numeric_string',
913+
type: 'string', // HubSpot has this as string, not number
914+
archived: false
915+
}
916+
]
917+
})
918+
919+
// sends an event completion to Hubspot
920+
nock('https://api.hubapi.com').post('/events/v3/send', expectedNumericStringPayload).reply(200, {})
921+
922+
const responses = await testDestination.testAction('customEvent', {
923+
event,
924+
settings,
925+
useDefaultMappings: true,
926+
mapping: upsertMapping,
927+
subscriptionMetadata
928+
})
929+
930+
expect(responses.length).toBe(2)
931+
expect(responses[1].status).toBe(200)
932+
933+
// sends an event completion to Hubspot without first fetching the event definition
934+
nock('https://api.hubapi.com').post('/events/v3/send', expectedNumericStringPayload).reply(200, {})
935+
936+
const responses2 = await testDestination.testAction('customEvent', {
937+
event: createTestEvent(numericStringPayload),
938+
settings,
939+
useDefaultMappings: true,
940+
mapping: upsertMapping,
941+
subscriptionMetadata
942+
})
943+
944+
expect(responses2.length).toBe(1)
945+
expect(responses2[0].status).toBe(200)
946+
})
947+
948+
it('should handle multiple numeric strings in a single event', async () => {
949+
const event = createTestEvent(multipleNumericStringsPayload)
950+
951+
// fetches the event definition from Hubspot
952+
nock('https://api.hubapi.com')
953+
.get('/events/v3/event-definitions/custom_event_3/?includeProperties=true')
954+
.reply(200, {
955+
name: 'custom_event_3',
956+
fullyQualifiedName: 'pe23132826_custom_event_3',
957+
properties: [
958+
{
959+
name: 'prop1',
960+
type: 'string',
961+
archived: false
962+
},
963+
{
964+
name: 'prop2',
965+
type: 'string',
966+
archived: false
967+
},
968+
{
969+
name: 'prop3',
970+
type: 'string',
971+
archived: false
972+
},
973+
{
974+
name: 'prop4',
975+
type: 'string',
976+
archived: false
977+
}
978+
]
979+
})
980+
981+
// sends an event completion to Hubspot
982+
nock('https://api.hubapi.com').post('/events/v3/send', expectedMultipleNumericStringsPayload).reply(200, {})
983+
984+
const responses = await testDestination.testAction('customEvent', {
985+
event,
986+
settings,
987+
useDefaultMappings: true,
988+
mapping: upsertMapping,
989+
subscriptionMetadata
990+
})
991+
992+
expect(responses.length).toBe(2)
993+
expect(responses[1].status).toBe(200)
994+
})
995+
996+
it('should handle numeric strings with partial property match', async () => {
997+
const event = createTestEvent({
998+
timestamp: timestamp,
999+
event: 'Custom Event 5',
1000+
messageId: 'aaa-bbb-ccc',
1001+
type: 'track',
1002+
userId: 'user_id_1',
1003+
properties: {
1004+
existing_prop: 123,
1005+
new_prop: 'new value'
1006+
}
1007+
} as Partial<SegmentEvent>)
1008+
1009+
// fetches the event definition from Hubspot
1010+
nock('https://api.hubapi.com')
1011+
.get('/events/v3/event-definitions/custom_event_5/?includeProperties=true')
1012+
.reply(200, {
1013+
name: 'custom_event_5',
1014+
fullyQualifiedName: 'pe23132826_custom_event_5',
1015+
properties: [
1016+
{
1017+
name: 'existing_prop',
1018+
type: 'string', // numeric string
1019+
archived: false
1020+
}
1021+
// new_prop doesn't exist yet
1022+
]
1023+
})
1024+
1025+
const expectedHubspotCreatePropertyPayload = {
1026+
name: 'new_prop',
1027+
label: 'new_prop',
1028+
type: 'string',
1029+
description: 'new_prop - (created by Segment)'
1030+
}
1031+
1032+
// creates property on Hubspot
1033+
nock('https://api.hubapi.com')
1034+
.post('/events/v3/event-definitions/pe23132826_custom_event_5/property', expectedHubspotCreatePropertyPayload)
1035+
.reply(200, {})
1036+
1037+
// sends an event completion to Hubspot
1038+
nock('https://api.hubapi.com')
1039+
.post('/events/v3/send', {
1040+
eventName: 'pe23132826_custom_event_5',
1041+
objectId: undefined,
1042+
1043+
utk: undefined,
1044+
occurredAt: timestamp,
1045+
properties: {
1046+
existing_prop: '123',
1047+
new_prop: 'new value'
1048+
}
1049+
})
1050+
.reply(200, {})
1051+
1052+
const responses = await testDestination.testAction('customEvent', {
1053+
event,
1054+
settings,
1055+
useDefaultMappings: true,
1056+
mapping: upsertMapping,
1057+
subscriptionMetadata
1058+
})
1059+
1060+
expect(responses.length).toBe(3)
1061+
expect(responses[2].status).toBe(200)
1062+
})
1063+
1064+
it('should not convert numeric values when HubSpot schema expects number type', async () => {
1065+
const event = createTestEvent(numericTypePayload)
1066+
1067+
// fetches the event definition from Hubspot
1068+
nock('https://api.hubapi.com')
1069+
.get('/events/v3/event-definitions/custom_event_6/?includeProperties=true')
1070+
.reply(200, {
1071+
name: 'custom_event_6',
1072+
fullyQualifiedName: 'pe23132826_custom_event_6',
1073+
properties: [
1074+
{
1075+
name: 'numeric_prop',
1076+
type: 'number', // HubSpot expects number, not string
1077+
archived: false
1078+
},
1079+
{
1080+
name: 'string_prop',
1081+
type: 'string',
1082+
archived: false
1083+
}
1084+
]
1085+
})
1086+
1087+
// sends an event completion to Hubspot
1088+
nock('https://api.hubapi.com').post('/events/v3/send', expectedNumericTypePayload).reply(200, {})
1089+
1090+
const responses = await testDestination.testAction('customEvent', {
1091+
event,
1092+
settings,
1093+
useDefaultMappings: true,
1094+
mapping: upsertMapping,
1095+
subscriptionMetadata
1096+
})
1097+
1098+
expect(responses.length).toBe(2)
1099+
expect(responses[1].status).toBe(200)
1100+
})
1101+
})
8181102
})

packages/destination-actions/src/destinations/hubspot/customEvent/functions/cache-functions.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { PayloadValidationError, StatsContext } from '@segment/actions-core'
22
import { SubscriptionMetadata } from '@segment/actions-core/destination-kit'
33
import { SegmentProperty, Schema, CachableSchema, SchemaDiff } from '../types'
44
import { LRUCache } from 'lru-cache'
5+
import { Payload } from '../generated-types'
56

67
export const cache = new LRUCache<string, CachableSchema>({
78
max: 2000,
@@ -42,21 +43,27 @@ export async function saveSchemaToCache(
4243

4344
export function compareSchemas(schema1: Schema, schema2: CachableSchema | undefined): SchemaDiff {
4445
if (schema2 === undefined) {
45-
return { match: 'no_match', missingProperties: {} }
46+
return { match: 'no_match', missingProperties: {}, numericStrings: [] }
4647
}
4748

4849
if (schema1.name !== schema2.name && schema1.name !== schema2.fullyQualifiedName) {
4950
throw new PayloadValidationError("Hubspot.CustomEvent.compareSchemas: Schema names don't match")
5051
}
5152

5253
const missingProperties: { [key: string]: SegmentProperty } = {}
54+
const numericStrings: string[] = []
5355

5456
for (const [key, prop1] of Object.entries(schema1.properties)) {
5557
const prop2 = schema2.properties[key]
5658
if (prop2 === undefined) {
5759
missingProperties[key] = prop1
5860
continue
5961
}
62+
// Handle case where we inferred number but HubSpot/cache has string
63+
if (prop1.type === 'number' && prop2.type === 'string') {
64+
numericStrings.push(key)
65+
continue
66+
}
6067
if (prop1.stringFormat === prop2.stringFormat && prop1.type === prop2.type) {
6168
continue
6269
} else {
@@ -66,6 +73,23 @@ export function compareSchemas(schema1: Schema, schema2: CachableSchema | undefi
6673

6774
return {
6875
match: Object.keys(missingProperties).length > 0 ? 'properties_missing' : 'full_match',
69-
missingProperties
76+
missingProperties,
77+
numericStrings
78+
}
79+
}
80+
81+
/**
82+
* Converts numeric property values to strings when the schema indicates they should be strings.
83+
* This handles the case where numeric strings like "123" get coerced to numbers during schema inference,
84+
* but HubSpot actually has the property defined as a string type.
85+
*/
86+
export function convertNumericStrings(validPayload: Payload, numericStrings: string[]): void {
87+
if (!validPayload.properties || numericStrings.length === 0) {
88+
return
89+
}
90+
for (const propName of numericStrings) {
91+
if (validPayload.properties[propName] !== undefined) {
92+
validPayload.properties[propName] = String(validPayload.properties[propName])
93+
}
7094
}
7195
}

packages/destination-actions/src/destinations/hubspot/customEvent/functions/hubspot-event-schema-functions.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ import {
1111
PropertyCreateResp
1212
} from '../types'
1313
import { Client } from '../client'
14+
import { Payload } from '../generated-types'
1415

15-
export async function getSchemaFromHubspot(client: Client, schema: Schema): Promise<CachableSchema | undefined> {
16+
export async function getSchemaFromHubspot(
17+
client: Client,
18+
schema: Schema,
19+
validPayload: Payload
20+
): Promise<CachableSchema | undefined> {
1621
const response = await client.getEventDefinition(schema.name)
1722

1823
switch (response.status) {
@@ -50,13 +55,26 @@ export async function getSchemaFromHubspot(client: Client, schema: Schema): Prom
5055
)
5156
}
5257

53-
if (prop.type === 'number' && ['datetime', 'string', 'enumeration'].includes(maybeMatch.type)) {
58+
// If we inferred type as number but HubSpot has string, convert back to string
59+
if (prop.type === 'number' && maybeMatch.type === 'string') {
60+
// Convert the payload property value to string
61+
if (validPayload.properties && validPayload.properties[propName] !== undefined) {
62+
validPayload.properties[propName] = String(validPayload.properties[propName])
63+
}
64+
// Update schema to match HubSpot's type
65+
props[propName] = {
66+
type: 'string',
67+
stringFormat: 'string'
68+
}
69+
}
70+
// For other type mismatches with number, still throw error
71+
else if (prop.type === 'number' && ['datetime', 'enumeration'].includes(maybeMatch.type)) {
5472
throw new PayloadValidationError(
5573
`Hubspot.CustomEvent.getSchemaFromHubspot: Expected type ${prop.type} for property ${propName} - Hubspot returned type ${maybeMatch.type}`
5674
)
75+
} else {
76+
props[propName] = schema.properties[propName]
5777
}
58-
59-
props[propName] = schema.properties[propName]
6078
}
6179
return props
6280
})()

0 commit comments

Comments
 (0)