From a1a43b6bcad0560533a103d6160a2ac598cc8676 Mon Sep 17 00:00:00 2001 From: Innovative-GauravKochar <117165746+Innovative-GauravKochar@users.noreply.github.com> Date: Tue, 12 Sep 2023 14:37:33 +0530 Subject: [PATCH] [Hubspot Cloud Actions]- Implemented Batching for Upsert Contact (#1514) * Added batching support for Upsert Contact * added unit test cases for batch upsertContact * email case insensitivity * added log and updated Method * removed logs and unwanted code * moved the code to seperate methods * added a test case for handled error * Added enable_batching to UpsertContact * worked on debugging batched requrest error * remove unnecessary * skipresponseCloning * updated snapshots --------- Co-authored-by: Sayan Das Co-authored-by: Gaurav Kochar --- .../src/destinations/hubspot/index.ts | 1 + .../__tests__/__helpers__/test-utils.ts | 110 ++++ .../__snapshots__/index.test.ts.snap | 469 ++++++++++++++++++ .../upsertContact/__tests__/index.test.ts | 269 ++++++++++ .../hubspot/upsertContact/generated-types.ts | 4 + .../hubspot/upsertContact/index.ts | 250 +++++++++- 6 files changed, 1097 insertions(+), 6 deletions(-) create mode 100644 packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__helpers__/test-utils.ts create mode 100644 packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__snapshots__/index.test.ts.snap diff --git a/packages/destination-actions/src/destinations/hubspot/index.ts b/packages/destination-actions/src/destinations/hubspot/index.ts index f0a9302885..4789739d95 100644 --- a/packages/destination-actions/src/destinations/hubspot/index.ts +++ b/packages/destination-actions/src/destinations/hubspot/index.ts @@ -45,6 +45,7 @@ const destination: DestinationDefinition = { }, extendRequest({ auth }) { return { + skipResponseCloning: true, headers: { authorization: `Bearer ${auth?.accessToken}` } diff --git a/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__helpers__/test-utils.ts b/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__helpers__/test-utils.ts new file mode 100644 index 0000000000..ee1b416882 --- /dev/null +++ b/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__helpers__/test-utils.ts @@ -0,0 +1,110 @@ +import { createTestEvent } from '@segment/actions-core' +import { ContactBatchResponse } from '../../../upsertContact' + +export type BatchContactListItem = { + id?: string + email: string + firstname: string + lastname: string + lifecyclestage?: string | undefined +} + +export const createBatchTestEvents = (batchContactList: BatchContactListItem[]) => + batchContactList.map((contact) => + createTestEvent({ + type: 'identify', + traits: { + email: contact.email, + firstname: contact.firstname, + lastname: contact.lastname, + address: { + city: 'San Francisco', + country: 'USA', + postal_code: '600001', + state: 'California', + street: 'Vancover st' + }, + graduation_date: 1664533942262, + lifecyclestage: contact?.lifecyclestage ?? 'subscriber', + company: 'Some Company', + phone: '+13134561129', + website: 'somecompany.com' + } + }) + ) + +export const generateBatchReadResponse = (batchContactList: BatchContactListItem[]) => { + const batchReadResponse: ContactBatchResponse = { + status: 'COMPLETE', + results: [], + numErrors: 0, + errors: [] + } + + const notFoundEmails: string[] = [] + + for (const contact of batchContactList) { + // Set success response + if (contact.id) { + batchReadResponse.results.push({ + id: contact.id, + properties: { + createdate: '2023-07-06T12:47:47.626Z', + email: contact.email, + hs_object_id: contact.id, + lastmodifieddate: '2023-07-06T12:48:02.784Z' + } + }) + } else { + // Push to not found list if id is not present + notFoundEmails.push(contact.email) + } + } + + // Set error response + if (notFoundEmails.length > 0) { + batchReadResponse.numErrors = 1 + batchReadResponse.errors = [ + { + status: 'error', + category: 'OBJECT_NOT_FOUND', + message: 'Could not get some CONTACT objects, they may be deleted or not exist. Check that ids are valid.', + context: { + ids: notFoundEmails + } + } + ] + } + + return batchReadResponse +} + +export const generateBatchCreateResponse = (batchContactList: BatchContactListItem[]) => { + const batchCreateResponse: ContactBatchResponse = { + status: 'COMPLETE', + results: [] + } + + batchContactList.forEach((contact, index) => { + // Set success response + // if (!contact.id) { + const contactResponse: any = { + id: contact.id ? contact.id : (101 + index).toString(), + properties: { + email: contact.email, + firstname: contact.firstname, + lastname: contact.lastname, + createdate: '2023-07-06T12:47:47.626Z', + lastmodifieddate: '2023-07-06T12:48:02.784Z' + } + } + if (Object.prototype.hasOwnProperty.call(contact, 'lifecyclestage')) { + contactResponse.properties.lifecyclestage = contact.lifecyclestage + } + + batchCreateResponse.results.push(contactResponse) + // } + }) + + return batchCreateResponse +} diff --git a/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__snapshots__/index.test.ts.snap b/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000000..906ae8f77a --- /dev/null +++ b/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__snapshots__/index.test.ts.snap @@ -0,0 +1,469 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HubSpot.upsertContactBatch should create and update contact successfully 1`] = ` +Object { + "afterResponse": Array [ + [Function], + [Function], + [Function], + ], + "beforeRequest": Array [ + [Function], + ], + "body": "{\\"properties\\":[\\"email\\",\\"lifecyclestage\\"],\\"idProperty\\":\\"email\\",\\"inputs\\":[{\\"id\\":\\"userone@somecompany.com\\"},{\\"id\\":\\"usertwo@somecompany.com\\"},{\\"id\\":\\"userthree@somecompany.com\\"},{\\"id\\":\\"userfour@somecompany.com\\"}]}", + "headers": Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer undefined", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, + }, + "json": Object { + "idProperty": "email", + "inputs": Array [ + Object { + "id": "userone@somecompany.com", + }, + Object { + "id": "usertwo@somecompany.com", + }, + Object { + "id": "userthree@somecompany.com", + }, + Object { + "id": "userfour@somecompany.com", + }, + ], + "properties": Array [ + "email", + "lifecyclestage", + ], + }, + "method": "POST", + "signal": AbortSignal {}, + "skipResponseCloning": true, + "statsContext": Object {}, + "throwHttpErrors": true, + "timeout": 10000, +} +`; + +exports[`HubSpot.upsertContactBatch should create and update contact successfully 2`] = ` +Object { + "errors": Array [ + Object { + "category": "OBJECT_NOT_FOUND", + "context": Object { + "ids": Array [ + "userone@somecompany.com", + "usertwo@somecompany.com", + ], + }, + "message": "Could not get some CONTACT objects, they may be deleted or not exist. Check that ids are valid.", + "status": "error", + }, + ], + "numErrors": 1, + "results": Array [ + Object { + "id": "103", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "userthree@somecompany.com", + "hs_object_id": "103", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + }, + }, + Object { + "id": "104", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "userfour@somecompany.com", + "hs_object_id": "104", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + }, + }, + ], + "status": "COMPLETE", +} +`; + +exports[`HubSpot.upsertContactBatch should create and update contact successfully 3`] = ` +Object { + "results": Array [ + Object { + "id": "101", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "userone@somecompany.com", + "firstname": "User", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + "lastname": "One", + "lifecyclestage": "lead", + }, + }, + Object { + "id": "102", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "usertwo@somecompany.com", + "firstname": "User", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + "lastname": "Two", + "lifecyclestage": "subscriber", + }, + }, + ], + "status": "COMPLETE", +} +`; + +exports[`HubSpot.upsertContactBatch should create and update contact successfully 4`] = ` +Object { + "results": Array [ + Object { + "id": "103", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "userthree@somecompany.com", + "firstname": "User", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + "lastname": "Three", + "lifecyclestage": "subscriber", + }, + }, + Object { + "id": "104", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "userfour@somecompany.com", + "firstname": "User", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + "lastname": "Four", + "lifecyclestage": "lead", + }, + }, + ], + "status": "COMPLETE", +} +`; + +exports[`HubSpot.upsertContactBatch should create contact successfully 1`] = ` +Object { + "afterResponse": Array [ + [Function], + [Function], + [Function], + ], + "beforeRequest": Array [ + [Function], + ], + "body": "{\\"properties\\":[\\"email\\",\\"lifecyclestage\\"],\\"idProperty\\":\\"email\\",\\"inputs\\":[{\\"id\\":\\"userone@somecompany.com\\"},{\\"id\\":\\"usertwo@somecompany.com\\"}]}", + "headers": Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer undefined", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, + }, + "json": Object { + "idProperty": "email", + "inputs": Array [ + Object { + "id": "userone@somecompany.com", + }, + Object { + "id": "usertwo@somecompany.com", + }, + ], + "properties": Array [ + "email", + "lifecyclestage", + ], + }, + "method": "POST", + "signal": AbortSignal {}, + "skipResponseCloning": true, + "statsContext": Object {}, + "throwHttpErrors": true, + "timeout": 10000, +} +`; + +exports[`HubSpot.upsertContactBatch should create contact successfully 2`] = ` +Object { + "errors": Array [ + Object { + "category": "OBJECT_NOT_FOUND", + "context": Object { + "ids": Array [ + "userone@somecompany.com", + "usertwo@somecompany.com", + ], + }, + "message": "Could not get some CONTACT objects, they may be deleted or not exist. Check that ids are valid.", + "status": "error", + }, + ], + "numErrors": 1, + "results": Array [], + "status": "COMPLETE", +} +`; + +exports[`HubSpot.upsertContactBatch should create contact successfully 3`] = ` +Object { + "results": Array [ + Object { + "id": "101", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "userone@somecompany.com", + "firstname": "User", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + "lastname": "One", + "lifecyclestage": "lead", + }, + }, + Object { + "id": "102", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "usertwo@somecompany.com", + "firstname": "User", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + "lastname": "Two", + "lifecyclestage": "subscriber", + }, + }, + ], + "status": "COMPLETE", +} +`; + +exports[`HubSpot.upsertContactBatch should reset lifecyclestage and update if lifecyclestage is to be moved backwards 1`] = ` +Object { + "afterResponse": Array [ + [Function], + [Function], + [Function], + ], + "beforeRequest": Array [ + [Function], + ], + "body": "{\\"properties\\":[\\"email\\",\\"lifecyclestage\\"],\\"idProperty\\":\\"email\\",\\"inputs\\":[{\\"id\\":\\"userone@somecompany.com\\"}]}", + "headers": Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer undefined", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, + }, + "json": Object { + "idProperty": "email", + "inputs": Array [ + Object { + "id": "userone@somecompany.com", + }, + ], + "properties": Array [ + "email", + "lifecyclestage", + ], + }, + "method": "POST", + "signal": AbortSignal {}, + "skipResponseCloning": true, + "statsContext": Object {}, + "throwHttpErrors": true, + "timeout": 10000, +} +`; + +exports[`HubSpot.upsertContactBatch should reset lifecyclestage and update if lifecyclestage is to be moved backwards 2`] = ` +Object { + "errors": Array [], + "numErrors": 0, + "results": Array [ + Object { + "id": "103", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "userone@somecompany.com", + "hs_object_id": "103", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + }, + }, + ], + "status": "COMPLETE", +} +`; + +exports[`HubSpot.upsertContactBatch should reset lifecyclestage and update if lifecyclestage is to be moved backwards 3`] = ` +Object { + "results": Array [ + Object { + "id": "103", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "userone@somecompany.com", + "firstname": "User", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + "lastname": "One", + "lifecyclestage": "lead", + }, + }, + ], + "status": "COMPLETE", +} +`; + +exports[`HubSpot.upsertContactBatch should reset lifecyclestage and update if lifecyclestage is to be moved backwards 4`] = ` +Object { + "results": Array [ + Object { + "id": "103", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "userone@somecompany.com", + "firstname": "User", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + "lastname": "One", + "lifecyclestage": "", + }, + }, + ], + "status": "COMPLETE", +} +`; + +exports[`HubSpot.upsertContactBatch should reset lifecyclestage and update if lifecyclestage is to be moved backwards 5`] = ` +Object { + "results": Array [ + Object { + "id": "103", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "userone@somecompany.com", + "firstname": "User", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + "lastname": "One", + "lifecyclestage": "subscriber", + }, + }, + ], + "status": "COMPLETE", +} +`; + +exports[`HubSpot.upsertContactBatch should update contact successfully 1`] = ` +Object { + "afterResponse": Array [ + [Function], + [Function], + [Function], + ], + "beforeRequest": Array [ + [Function], + ], + "body": "{\\"properties\\":[\\"email\\",\\"lifecyclestage\\"],\\"idProperty\\":\\"email\\",\\"inputs\\":[{\\"id\\":\\"userthree@somecompany.com\\"},{\\"id\\":\\"userfour@somecompany.com\\"}]}", + "headers": Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer undefined", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, + }, + "json": Object { + "idProperty": "email", + "inputs": Array [ + Object { + "id": "userthree@somecompany.com", + }, + Object { + "id": "userfour@somecompany.com", + }, + ], + "properties": Array [ + "email", + "lifecyclestage", + ], + }, + "method": "POST", + "signal": AbortSignal {}, + "skipResponseCloning": true, + "statsContext": Object {}, + "throwHttpErrors": true, + "timeout": 10000, +} +`; + +exports[`HubSpot.upsertContactBatch should update contact successfully 2`] = ` +Object { + "errors": Array [], + "numErrors": 0, + "results": Array [ + Object { + "id": "103", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "userthree@somecompany.com", + "hs_object_id": "103", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + }, + }, + Object { + "id": "104", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "userfour@somecompany.com", + "hs_object_id": "104", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + }, + }, + ], + "status": "COMPLETE", +} +`; + +exports[`HubSpot.upsertContactBatch should update contact successfully 3`] = ` +Object { + "results": Array [ + Object { + "id": "103", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "userthree@somecompany.com", + "firstname": "User", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + "lastname": "Three", + "lifecyclestage": "subscriber", + }, + }, + Object { + "id": "104", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "userfour@somecompany.com", + "firstname": "User", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + "lastname": "Four", + "lifecyclestage": "lead", + }, + }, + ], + "status": "COMPLETE", +} +`; diff --git a/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/index.test.ts b/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/index.test.ts index b14f9abae4..2f9edadb11 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/index.test.ts @@ -3,8 +3,47 @@ import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Destination from '../../index' import { HUBSPOT_BASE_URL } from '../../properties' +import { + BatchContactListItem, + generateBatchReadResponse, + generateBatchCreateResponse, + createBatchTestEvents +} from './__helpers__/test-utils' + let testDestination = createTestIntegration(Destination) +const createContactList: BatchContactListItem[] = [ + { + email: 'userone@somecompany.com', + firstname: 'User', + lastname: 'One', + lifecyclestage: 'lead' + }, + { + email: 'usertwo@somecompany.com', + firstname: 'User', + lastname: 'Two', + lifecyclestage: 'subscriber' + } +] + +const updateContactList: BatchContactListItem[] = [ + { + id: '103', + email: 'userthree@somecompany.com', + firstname: 'User', + lastname: 'Three', + lifecyclestage: 'subscriber' + }, + { + id: '104', + email: 'userfour@somecompany.com', + firstname: 'User', + lastname: 'Four', + lifecyclestage: 'lead' + } +] + beforeEach((done) => { // Re-Initialize the destination before each test // This is done to mitigate a bug where action responses persist into other tests @@ -14,6 +53,7 @@ beforeEach((done) => { }) const testEmail = 'vep@beri.dz' + const event = createTestEvent({ type: 'identify', traits: { @@ -34,6 +74,7 @@ const event = createTestEvent({ website: 'segment.inc1' } }) + const mapping = { lifecyclestage: { '@path': '$.traits.lifecyclestage' @@ -382,3 +423,231 @@ describe('HubSpot.upsertContact', () => { }) }) }) + +describe('HubSpot.upsertContactBatch', () => { + test('should create contact successfully', async () => { + const events = createBatchTestEvents(createContactList) + + // Mock: Read Contact Using Email + nock(HUBSPOT_BASE_URL) + .post(`/crm/v3/objects/contacts/batch/read`) + .reply(207, generateBatchReadResponse(createContactList)) + + // Mock: Create Contact + nock(HUBSPOT_BASE_URL) + .post(`/crm/v3/objects/contacts/batch/create`) + .reply(201, generateBatchCreateResponse(createContactList)) + + const mapping = { + properties: { + graduation_date: { + '@path': '$.traits.graduation_date' + } + } + } + + const testBatchResponses = await testDestination.testBatchAction('upsertContact', { + mapping, + useDefaultMappings: true, + events + }) + + expect(testBatchResponses[0].options).toMatchSnapshot() + expect(testBatchResponses[0].data).toMatchSnapshot() + expect(testBatchResponses[1].data).toMatchSnapshot() + }) + + test('should update contact successfully', async () => { + const events = createBatchTestEvents(updateContactList) + + // Mock: Read Contact Using Email + nock(HUBSPOT_BASE_URL) + .post(`/crm/v3/objects/contacts/batch/read`) + .reply(200, generateBatchReadResponse(updateContactList)) + + // Mock: Update Contact + nock(HUBSPOT_BASE_URL) + .post(`/crm/v3/objects/contacts/batch/update`) + .reply(200, generateBatchCreateResponse(updateContactList)) + + const mapping = { + properties: { + graduation_date: { + '@path': '$.traits.graduation_date' + } + } + } + + const testBatchResponses = await testDestination.testBatchAction('upsertContact', { + mapping, + useDefaultMappings: true, + events + }) + + expect(testBatchResponses[0].options).toMatchSnapshot() + expect(testBatchResponses[0].data).toMatchSnapshot() + expect(testBatchResponses[1].data).toMatchSnapshot() + }) + + test('should create and update contact successfully', async () => { + const events = createBatchTestEvents([...createContactList, ...updateContactList]) + + // Mock: Read Contact Using Email + nock(HUBSPOT_BASE_URL) + .post(`/crm/v3/objects/contacts/batch/read`) + .reply(200, generateBatchReadResponse([...createContactList, ...updateContactList])) + + // Mock: Update Contact + nock(HUBSPOT_BASE_URL) + .post(`/crm/v3/objects/contacts/batch/update`) + .reply(200, generateBatchCreateResponse(updateContactList)) + + // Mock: Create Contact + nock(HUBSPOT_BASE_URL) + .post(`/crm/v3/objects/contacts/batch/create`) + .reply(201, generateBatchCreateResponse(createContactList)) + + const mapping = { + properties: { + graduation_date: { + '@path': '$.traits.graduation_date' + } + } + } + + const testBatchResponses = await testDestination.testBatchAction('upsertContact', { + mapping, + useDefaultMappings: true, + events + }) + + expect(testBatchResponses[0].options).toMatchSnapshot() + expect(testBatchResponses[0].data).toMatchSnapshot() + expect(testBatchResponses[1].data).toMatchSnapshot() + expect(testBatchResponses[2].data).toMatchSnapshot() + }) + + test('should reset lifecyclestage and update if lifecyclestage is to be moved backwards', async () => { + const events = createBatchTestEvents([ + { + email: 'userone@somecompany.com', + firstname: 'User', + lastname: 'One' + } + ]) + + // Mock: Read Contact Using Email + nock(HUBSPOT_BASE_URL) + .post(`/crm/v3/objects/contacts/batch/read`) + .reply( + 200, + generateBatchReadResponse([ + { + id: '103', + email: 'userone@somecompany.com', + firstname: 'User', + lastname: 'One' + } + ]) + ) + + // Mock: Update Contact + nock(HUBSPOT_BASE_URL) + .post( + `/crm/v3/objects/contacts/batch/update`, + '{"inputs":[{"id":"103","properties":{"company":"Some Company","phone":"+13134561129","address":"Vancover st","city":"San Francisco","state":"California","country":"USA","zip":"600001","email":"userone@somecompany.com","website":"somecompany.com","lifecyclestage":"subscriber","graduation_date":1664533942262}}]}' + ) + .reply( + 200, + generateBatchCreateResponse([ + { + id: '103', + email: 'userone@somecompany.com', + firstname: 'User', + lastname: 'One', + lifecyclestage: 'lead' + } + ]) + ) + + nock(HUBSPOT_BASE_URL) + .post(`/crm/v3/objects/contacts/batch/update`, '{"inputs":[{"id":"103","properties":{"lifecyclestage":""}}]}') + .reply( + 200, + generateBatchCreateResponse([ + { + id: '103', + email: 'userone@somecompany.com', + firstname: 'User', + lastname: 'One', + lifecyclestage: '' + } + ]) + ) + + nock(HUBSPOT_BASE_URL) + .post( + `/crm/v3/objects/contacts/batch/update`, + '{"inputs":[{"id":"103","properties":{"lifecyclestage":"subscriber"}}]}' + ) + .reply( + 200, + generateBatchCreateResponse([ + { + id: '103', + email: 'userone@somecompany.com', + firstname: 'User', + lastname: 'One', + lifecyclestage: 'subscriber' + } + ]) + ) + + const testBatchResponses = await testDestination.testBatchAction('upsertContact', { + mapping, + useDefaultMappings: true, + events + }) + + expect(testBatchResponses[0].options).toMatchSnapshot() + expect(testBatchResponses[0].data).toMatchSnapshot() + expect(testBatchResponses[1].data).toMatchSnapshot() + expect(testBatchResponses[2].data).toMatchSnapshot() + expect(testBatchResponses[3].data).toMatchSnapshot() + }) + test('should fail if any of error comes while reading batch of contacts', async () => { + const events = createBatchTestEvents(createContactList) + + // Mock: Read Contact Using Email + nock(HUBSPOT_BASE_URL) + .post(`/crm/v3/objects/contacts/batch/read`) + .reply(207, { + status: 'COMPLETE', + results: [], + numErrors: 1, + errors: [ + { + status: 'error', + category: 'VALIDATION_ERROR', + message: "'lastname' Property does not exist" + } + ] + }) + + const mapping = { + properties: { + graduation_date: { + '@path': '$.traits.graduation_date' + } + } + } + + await expect( + testDestination.testBatchAction('upsertContact', { + mapping, + useDefaultMappings: true, + events + }) + ).rejects.toThrowError("'lastname' Property does not exist") + }) +}) diff --git a/packages/destination-actions/src/destinations/hubspot/upsertContact/generated-types.ts b/packages/destination-actions/src/destinations/hubspot/upsertContact/generated-types.ts index e7697c3d2c..e18d8db54c 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertContact/generated-types.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertContact/generated-types.ts @@ -55,4 +55,8 @@ export interface Payload { properties?: { [k: string]: unknown } + /** + * If true, Segment will batch events before sending to HubSpot’s API endpoint. HubSpot accepts batches of up to 100 events. Note: Contacts created with batch endpoint can’t be associated to a Company from the UpsertCompany Action. + */ + enable_batching?: boolean } diff --git a/packages/destination-actions/src/destinations/hubspot/upsertContact/index.ts b/packages/destination-actions/src/destinations/hubspot/upsertContact/index.ts index 48a880b509..7d95f689f7 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertContact/index.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertContact/index.ts @@ -1,15 +1,68 @@ import { HTTPError } from '@segment/actions-core' -import { ActionDefinition, RequestClient } from '@segment/actions-core' +import { ActionDefinition, RequestClient, IntegrationError } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { HUBSPOT_BASE_URL } from '../properties' import { flattenObject } from '../utils' -interface ContactResponse { +interface ContactProperties { + company?: string | undefined + firstname?: string | undefined + lastname?: string | undefined + phone?: string | undefined + address?: string | undefined + city?: string | undefined + state?: string | undefined + country?: string | undefined + zip?: string | undefined + email?: string | undefined + website?: string | undefined + lifecyclestage?: string | undefined + [key: string]: string | undefined +} + +interface ContactCreateRequestPayload { + properties: ContactProperties +} + +interface ContactUpdateRequestPayload { + id: string + properties: ContactProperties +} + +interface ContactSuccessResponse { id: string properties: Record } +interface ContactErrorResponse { + status: string + category: string + message: string + context: { + ids: string[] + [key: string]: unknown + } +} + +export interface ContactBatchResponse { + status: string + results: ContactSuccessResponse[] + numErrors?: number + errors?: ContactErrorResponse[] +} +export interface BatchContactResponse { + data: ContactBatchResponse +} + +interface ContactsUpsertMapItem { + action: 'create' | 'update' | 'undefined' + payload: { + id?: string + properties: ContactProperties + } +} + const action: ActionDefinition = { title: 'Upsert Contact', description: 'Create or update a contact in HubSpot.', @@ -130,6 +183,13 @@ const action: ActionDefinition = { description: 'Any other default or custom contact properties. On the left-hand side, input the internal name of the property as seen in your HubSpot account. On the right-hand side, map the Segment field that contains the value. Custom properties must be predefined in HubSpot. See more information in [HubSpot’s documentation](https://knowledge.hubspot.com/crm-setup/manage-your-properties#create-custom-properties).', defaultObjectUI: 'keyvalue:only' + }, + enable_batching: { + type: 'boolean', + label: 'Send Batch Data to HubSpot', + description: + 'If true, Segment will batch events before sending to HubSpot’s API endpoint. HubSpot accepts batches of up to 100 events. Note: Contacts created with batch endpoint can’t be associated to a Company from the UpsertCompany Action.', + default: false } }, perform: async (request, { payload, transactionContext }) => { @@ -185,11 +245,48 @@ const action: ActionDefinition = { } throw ex } + }, + + performBatch: async (request, { payload }) => { + // Create a map of email & id to contact upsert payloads + // Record + let contactsUpsertMap = mapUpsertContactPayload(payload) + + // Fetch the list of contacts from HubSpot + const readResponse = await readContactsBatch(request, Object.keys(contactsUpsertMap)) + contactsUpsertMap = updateActionsForBatchedContacts(readResponse, contactsUpsertMap) + + // Divide Contacts into two maps - one for insert and one for update + const createList: ContactCreateRequestPayload[] = [] + const updateList: ContactUpdateRequestPayload[] = [] + + for (const [_, { action, payload }] of Object.entries(contactsUpsertMap)) { + if (action === 'create') { + createList.push(payload) + } else if (action === 'update') { + updateList.push({ + id: payload.id as string, + properties: payload.properties + }) + } + } + + // Create contacts that don't exist in HubSpot + if (createList.length > 0) { + await createContactsBatch(request, createList) + } + + if (updateList.length > 0) { + // Update contacts that already exist in HubSpot + const updateContactResponse = await updateContactsBatch(request, updateList) + // Check if Life Cycle Stage update was successful, and pick the ones that didn't succeed + await checkAndRetryUpdatingLifecycleStage(request, updateContactResponse, contactsUpsertMap) + } } } -async function createContact(request: RequestClient, contactProperties: { [key: string]: unknown }) { - return request(`${HUBSPOT_BASE_URL}/crm/v3/objects/contacts`, { +async function createContact(request: RequestClient, contactProperties: ContactProperties) { + return request(`${HUBSPOT_BASE_URL}/crm/v3/objects/contacts`, { method: 'POST', json: { properties: contactProperties @@ -197,8 +294,8 @@ async function createContact(request: RequestClient, contactProperties: { [key: }) } -async function updateContact(request: RequestClient, email: string, properties: { [key: string]: unknown }) { - return request(`${HUBSPOT_BASE_URL}/crm/v3/objects/contacts/${email}?idProperty=email`, { +async function updateContact(request: RequestClient, email: string, properties: ContactProperties) { + return request(`${HUBSPOT_BASE_URL}/crm/v3/objects/contacts/${email}?idProperty=email`, { method: 'PATCH', json: { properties: properties @@ -206,4 +303,145 @@ async function updateContact(request: RequestClient, email: string, properties: }) } +async function readContactsBatch(request: RequestClient, emails: string[]) { + const requestPayload = { + properties: ['email', 'lifecyclestage'], + idProperty: 'email', + inputs: emails.map((email) => ({ + id: email + })) + } + + return request(`${HUBSPOT_BASE_URL}/crm/v3/objects/contacts/batch/read`, { + method: 'POST', + json: requestPayload + }) +} + +async function createContactsBatch(request: RequestClient, contactCreatePayload: ContactCreateRequestPayload[]) { + return request(`${HUBSPOT_BASE_URL}/crm/v3/objects/contacts/batch/create`, { + method: 'POST', + json: { + inputs: contactCreatePayload + } + }) +} + +async function updateContactsBatch(request: RequestClient, contactUpdatePayload: ContactUpdateRequestPayload[]) { + return request(`${HUBSPOT_BASE_URL}/crm/v3/objects/contacts/batch/update`, { + method: 'POST', + json: { + inputs: contactUpdatePayload + } + }) +} + +function mapUpsertContactPayload(payload: Payload[]) { + // Create a map of email & id to contact upsert payloads + // Record + const contactsUpsertMap: Record = {} + for (const contact of payload) { + contactsUpsertMap[contact.email.toLowerCase()] = { + // Setting initial state to undefined as we don't know if the contact exists in HubSpot + action: 'undefined', + + payload: { + // Skip setting the id as we don't know if the contact exists in HubSpot + properties: { + company: contact.company, + firstname: contact.firstname, + lastname: contact.lastname, + phone: contact.phone, + address: contact.address, + city: contact.city, + state: contact.state, + country: contact.country, + zip: contact.zip, + email: contact.email.toLowerCase(), + website: contact.website, + lifecyclestage: contact.lifecyclestage?.toLowerCase(), + ...flattenObject(contact.properties) + } + } + } + } + + return contactsUpsertMap +} + +function updateActionsForBatchedContacts( + readResponse: BatchContactResponse, + contactsUpsertMap: Record +) { + // Throw any other error responses + // Case 1: Loop over results if there are any + if (readResponse.data?.results && readResponse.data.results.length > 0) { + for (const result of readResponse.data.results) { + // Set the action to update for contacts that exist in HubSpot + contactsUpsertMap[result.properties.email].action = 'update' + + // Set the id for contacts that exist in HubSpot + contactsUpsertMap[result.properties.email].payload.id = result.id + + // Re-index the payload with ID + contactsUpsertMap[result.id] = { ...contactsUpsertMap[result.properties.email] } + delete contactsUpsertMap[result.properties.email] + } + } + + // Case 2: Loop over errors if there are any + if (readResponse.data?.numErrors && readResponse.data.errors) { + for (const error of readResponse.data.errors) { + if (error.status === 'error' && error.category === 'OBJECT_NOT_FOUND') { + // Set the action to create for contacts that don't exist in HubSpot + for (const id of error.context.ids) { + //Set Action to create + contactsUpsertMap[id].action = 'create' + } + } else { + // Throw any other error responses + throw new IntegrationError(error.message, error.category, 400) + } + } + } + return contactsUpsertMap +} +async function checkAndRetryUpdatingLifecycleStage( + request: RequestClient, + updateContactResponse: BatchContactResponse, + contactsUpsertMap: Record +) { + // Check if Life Cycle Stage update was successful, and pick the ones that didn't succeed + const resetLifeCycleStagePayload: ContactUpdateRequestPayload[] = [] + const retryLifeCycleStagePayload: ContactUpdateRequestPayload[] = [] + + for (const result of updateContactResponse.data.results) { + const desiredLifeCycleStage = contactsUpsertMap[result.id].payload.properties.lifecyclestage + const currentLifeCycleStage = result.properties.lifecyclestage + + if (desiredLifeCycleStage && desiredLifeCycleStage !== currentLifeCycleStage) { + resetLifeCycleStagePayload.push({ + id: result.id, + properties: { + lifecyclestage: '' + } + }) + + retryLifeCycleStagePayload.push({ + id: result.id, + properties: { + lifecyclestage: desiredLifeCycleStage + } + }) + } + } + // Retry Life Cycle Stage Updates + if (retryLifeCycleStagePayload.length > 0) { + // Reset Life Cycle Stage + await updateContactsBatch(request, resetLifeCycleStagePayload) + + // Set the new Life Cycle Stage + await updateContactsBatch(request, retryLifeCycleStagePayload) + } +} export default action