From 3d7db7366e30df31c37cc473e344da82b49ed885 Mon Sep 17 00:00:00 2001 From: Manish Kumar <144022547+manish339k@users.noreply.github.com> Date: Fri, 15 Nov 2024 16:59:49 +0530 Subject: [PATCH] feat: onboarding intercom v2 retl support (#3843) * feat: onboarding intercom v2 retl support * fix: fixing export error * fix: searching contact for insert record * fix: added more tests * fix: addressing comment * fix: minor change --- src/v0/destinations/intercom_v2/config.js | 7 + src/v0/destinations/intercom_v2/transform.js | 51 ++++- src/v0/destinations/intercom_v2/utils.js | 25 ++- .../destinations/intercom_v2/network.ts | 102 +++++++++ .../destinations/intercom_v2/router/data.ts | 199 ++++++++++++++++++ .../destinations/intercom_v2/router/rETL.ts | 182 ++++++++++++++++ 6 files changed, 558 insertions(+), 8 deletions(-) create mode 100644 test/integrations/destinations/intercom_v2/router/rETL.ts diff --git a/src/v0/destinations/intercom_v2/config.js b/src/v0/destinations/intercom_v2/config.js index c7cb43b093..5ff5566d2d 100644 --- a/src/v0/destinations/intercom_v2/config.js +++ b/src/v0/destinations/intercom_v2/config.js @@ -6,6 +6,12 @@ const ApiVersions = { v2: '2.10', }; +const RecordAction = { + INSERT: 'insert', + UPDATE: 'update', + DELETE: 'delete', +}; + const ConfigCategory = { IDENTIFY: { name: 'IntercomIdentifyConfig', @@ -25,4 +31,5 @@ module.exports = { ConfigCategory, MappingConfig, ApiVersions, + RecordAction, }; diff --git a/src/v0/destinations/intercom_v2/transform.js b/src/v0/destinations/intercom_v2/transform.js index 8d97e20bde..3f9457410f 100644 --- a/src/v0/destinations/intercom_v2/transform.js +++ b/src/v0/destinations/intercom_v2/transform.js @@ -1,4 +1,4 @@ -const { InstrumentationError } = require('@rudderstack/integrations-lib'); +const { InstrumentationError, ConfigurationError } = require('@rudderstack/integrations-lib'); const { handleRtTfSingleEventError, getSuccessRespEvents, @@ -17,13 +17,14 @@ const { addOrUpdateTagsToCompany, getStatusCode, getBaseEndpoint, + getRecordAction, } = require('./utils'); const { getName, filterCustomAttributes, addMetadataToPayload, } = require('../../../cdk/v2/destinations/intercom/utils'); -const { MappingConfig, ConfigCategory } = require('./config'); +const { MappingConfig, ConfigCategory, RecordAction } = require('./config'); const transformIdentifyPayload = (event) => { const { message, destination } = event; @@ -38,7 +39,7 @@ const transformIdentifyPayload = (event) => { } payload.name = getName(message); payload.custom_attributes = message.traits || message.context.traits || {}; - payload.custom_attributes = filterCustomAttributes(payload, 'user', destination); + payload.custom_attributes = filterCustomAttributes(payload, 'user', destination, message); return payload; }; @@ -66,7 +67,7 @@ const transformGroupPayload = (event) => { const category = ConfigCategory.GROUP; const payload = constructPayload(message, MappingConfig[category.name]); payload.custom_attributes = message.traits || message.context.traits || {}; - payload.custom_attributes = filterCustomAttributes(payload, 'company', destination); + payload.custom_attributes = filterCustomAttributes(payload, 'company', destination, message); return payload; }; @@ -131,6 +132,45 @@ const constructGroupResponse = async (event) => { return getResponse(method, endpoint, headers, finalPayload); }; +const constructRecordResponse = async (event) => { + const { message, destination, metadata } = event; + const { identifiers, fields } = message; + + let method = 'POST'; + let endpoint = `${getBaseEndpoint(destination)}/contacts`; + let payload = {}; + + const action = getRecordAction(message); + const contactId = await searchContact(event); + + if ((action === RecordAction.UPDATE || action === RecordAction.DELETE) && !contactId) { + throw new ConfigurationError('Contact is not present. Aborting.'); + } + + switch (action) { + case RecordAction.INSERT: + payload = { ...identifiers, ...fields }; + if (contactId) { + endpoint += `/${contactId}`; + payload = { ...fields }; + method = 'PUT'; + } + break; + case RecordAction.UPDATE: + endpoint += `/${contactId}`; + payload = { ...fields }; + method = 'PUT'; + break; + case RecordAction.DELETE: + endpoint += `/${contactId}`; + method = 'DELETE'; + break; + default: + throw new InstrumentationError(`action ${action} is not supported.`); + } + return getResponse(method, endpoint, getHeaders(metadata), payload); +}; + const processEvent = async (event) => { const { message } = event; const messageType = getEventType(message); @@ -145,6 +185,9 @@ const processEvent = async (event) => { case EventType.GROUP: response = await constructGroupResponse(event); break; + case EventType.RECORD: + response = constructRecordResponse(event); + break; default: throw new InstrumentationError(`message type ${messageType} is not supported.`); } diff --git a/src/v0/destinations/intercom_v2/utils.js b/src/v0/destinations/intercom_v2/utils.js index 69ea1385d9..df44b92e24 100644 --- a/src/v0/destinations/intercom_v2/utils.js +++ b/src/v0/destinations/intercom_v2/utils.js @@ -28,6 +28,8 @@ const { getAccessToken } = require('../../util'); const { ApiVersions, destType } = require('./config'); const { getDynamicErrorType } = require('../../../adapters/utils/networkUtils'); +const getRecordAction = (message) => message?.action?.toLowerCase(); + /** * method to handle error during api call * ref docs: https://developers.intercom.com/docs/references/rest-api/errors/http-responses/ @@ -99,11 +101,25 @@ const getResponse = (method, endpoint, headers, payload) => { const searchContact = async (event) => { const { message, destination, metadata } = event; - const lookupField = getLookUpField(message); - let lookupFieldValue = getFieldValueFromMessage(message, lookupField); - if (!lookupFieldValue) { - lookupFieldValue = message?.context?.traits?.[lookupField]; + + const extractLookupFieldAndValue = () => { + const messageType = getEventType(message); + if (messageType === EventType.RECORD) { + const { identifiers } = message; + return Object.entries(identifiers || {})[0] || [null, null]; + } + const lookupField = getLookUpField(message); + const lookupFieldValue = + getFieldValueFromMessage(message, lookupField) || message?.context?.traits?.[lookupField]; + return [lookupField, lookupFieldValue]; + }; + + const [lookupField, lookupFieldValue] = extractLookupFieldAndValue(); + + if (!lookupField || !lookupFieldValue) { + throw new InstrumentationError('Missing lookup field or lookup field value for searchContact'); } + const data = JSON.stringify({ query: { operator: 'AND', @@ -329,4 +345,5 @@ module.exports = { attachContactToCompany, addOrUpdateTagsToCompany, getBaseEndpoint, + getRecordAction, }; diff --git a/test/integrations/destinations/intercom_v2/network.ts b/test/integrations/destinations/intercom_v2/network.ts index 26ff3c38ee..e4cae04d07 100644 --- a/test/integrations/destinations/intercom_v2/network.ts +++ b/test/integrations/destinations/intercom_v2/network.ts @@ -746,6 +746,108 @@ const deliveryCallsData = [ }, }, }, + { + httpReq: { + method: 'post', + url: 'https://api.intercom.io/contacts/search', + data: { + query: { + operator: 'AND', + value: [{ field: 'email', operator: '=', value: 'test-rETL-available@gmail.com' }], + }, + }, + headers, + }, + httpRes: { + status: 200, + statusText: 'ok', + data: { + type: 'list', + total_count: 0, + pages: { + type: 'pages', + page: 1, + per_page: 50, + total_pages: 0, + }, + data: [ + { + type: 'contact', + id: 'retl-available-contact-id', + workspace_id: 'rudderWorkspace', + external_id: 'detach-company-user-id', + role: 'user', + email: 'test-rETL-available@gmail.com', + }, + ], + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.intercom.io/contacts/search', + data: { + query: { + operator: 'AND', + value: [{ field: 'email', operator: '=', value: 'test-rETL-unavailable@gmail.com' }], + }, + }, + headers, + }, + httpRes: { + status: 200, + statusText: 'ok', + data: { + type: 'list', + total_count: 0, + pages: { + type: 'pages', + page: 1, + per_page: 50, + total_pages: 0, + }, + data: [], + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.au.intercom.io/contacts/search', + data: { + query: { + operator: 'AND', + value: [{ field: 'external_id', operator: '=', value: 'known-user-id-1' }], + }, + }, + headers, + }, + httpRes: { + status: 200, + statusText: 'ok', + data: { + type: 'list', + total_count: 0, + pages: { + type: 'pages', + page: 1, + per_page: 50, + total_pages: 0, + }, + data: [ + { + type: 'contact', + id: 'contact-id-by-intercom-known-user-id-1', + workspace_id: 'rudderWorkspace', + external_id: 'user-id-1', + role: 'user', + email: 'test@rudderlabs.com', + }, + ], + }, + }, + }, ]; export const networkCallsData = [...deliveryCallsData]; diff --git a/test/integrations/destinations/intercom_v2/router/data.ts b/test/integrations/destinations/intercom_v2/router/data.ts index 7656914059..75f5ba6ae7 100644 --- a/test/integrations/destinations/intercom_v2/router/data.ts +++ b/test/integrations/destinations/intercom_v2/router/data.ts @@ -17,6 +17,7 @@ import { userTraits, } from '../common'; import { RouterTestData } from '../../../testTypes'; +import { rETLRecordV2RouterRequest } from './rETL'; const routerRequest1: RouterTransformationRequest = { input: [ @@ -222,6 +223,26 @@ const routerRequest3: RouterTransformationRequest = { }, metadata: generateMetadata(3), }, + { + destination: destinationApiServerAU, + message: { + userId: 'known-user-id-1', + channel, + context: { + traits: { ...userTraits, external_id: 'known-user-id-1' }, + }, + type: 'identify', + integrations: { + All: true, + Intercom: { + lookup: 'external_id', + }, + }, + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(4), + }, ], destType: 'intercom_v2', }; @@ -735,6 +756,38 @@ export const data: RouterTestData[] = [ metadata: [generateMetadata(3)], statusCode: 400, }, + { + batched: false, + batchedRequest: { + body: { + JSON: { + email: 'test@rudderlabs.com', + external_id: 'known-user-id-1', + name: 'John Snow', + owner_id: 13, + phone: '+91 9999999999', + custom_attributes: { + address: 'california usa', + age: 23, + }, + }, + XML: {}, + FORM: {}, + JSON_ARRAY: {}, + }, + endpoint: + 'https://api.au.intercom.io/contacts/contact-id-by-intercom-known-user-id-1', + files: {}, + headers, + method: 'PUT', + params: {}, + type: 'REST', + version: '1', + }, + destination: destinationApiServerAU, + metadata: [generateMetadata(4)], + statusCode: 200, + }, ], }, }, @@ -880,4 +933,150 @@ export const data: RouterTestData[] = [ }, }, }, + { + id: 'INTERCOM-V2-router-test-6', + scenario: 'Framework', + successCriteria: 'Some events should be transformed successfully and some should fail for rETL', + name: 'intercom_v2', + description: 'INTERCOM V2 rETL tests', + feature: 'router', + module: 'destination', + version: 'v0', + input: { + request: { + body: rETLRecordV2RouterRequest, + }, + }, + output: { + response: { + status: 200, + body: { + output: [ + { + batched: false, + batchedRequest: { + body: { + JSON: { + email: 'test-rETL-unavailable@gmail.com', + external_id: 'rEtl_external_id', + }, + XML: {}, + FORM: {}, + JSON_ARRAY: {}, + }, + endpoint: 'https://api.intercom.io/contacts', + files: {}, + headers, + method: 'POST', + params: {}, + type: 'REST', + version: '1', + }, + destination: destination, + metadata: [generateMetadata(1)], + statusCode: 200, + }, + { + batched: false, + batchedRequest: { + body: { + JSON: { + external_id: 'rEtl_external_id', + }, + XML: {}, + FORM: {}, + JSON_ARRAY: {}, + }, + endpoint: 'https://api.intercom.io/contacts/retl-available-contact-id', + files: {}, + headers, + method: 'PUT', + params: {}, + type: 'REST', + version: '1', + }, + destination: destination, + metadata: [generateMetadata(2)], + statusCode: 200, + }, + { + batched: false, + batchedRequest: { + body: { + JSON: {}, + XML: {}, + FORM: {}, + JSON_ARRAY: {}, + }, + endpoint: 'https://api.intercom.io/contacts/retl-available-contact-id', + files: {}, + headers, + method: 'DELETE', + params: {}, + type: 'REST', + version: '1', + }, + destination: destination, + metadata: [generateMetadata(3)], + statusCode: 200, + }, + { + batched: false, + error: 'Contact is not present. Aborting.', + statTags: { + ...RouterInstrumentationErrorStatTags, + errorType: 'configuration', + }, + destination, + metadata: [generateMetadata(4)], + statusCode: 400, + }, + { + batched: false, + batchedRequest: { + body: { + JSON: { + external_id: 'rEtl_external_id', + }, + XML: {}, + FORM: {}, + JSON_ARRAY: {}, + }, + endpoint: 'https://api.intercom.io/contacts/retl-available-contact-id', + files: {}, + headers, + method: 'PUT', + params: {}, + type: 'REST', + version: '1', + }, + destination: destination, + metadata: [generateMetadata(5)], + statusCode: 200, + }, + { + batched: false, + error: 'action dummyaction is not supported.', + statTags: { + ...RouterInstrumentationErrorStatTags, + }, + destination, + metadata: [generateMetadata(6)], + statusCode: 400, + }, + { + batched: false, + error: 'Missing lookup field or lookup field value for searchContact', + statTags: { + ...RouterInstrumentationErrorStatTags, + }, + destination, + metadata: [generateMetadata(7)], + statusCode: 400, + }, + ], + }, + }, + }, + }, ]; diff --git a/test/integrations/destinations/intercom_v2/router/rETL.ts b/test/integrations/destinations/intercom_v2/router/rETL.ts new file mode 100644 index 0000000000..0a36b8cfa6 --- /dev/null +++ b/test/integrations/destinations/intercom_v2/router/rETL.ts @@ -0,0 +1,182 @@ +import { RouterTransformationRequest } from '../../../../../src/types'; +import { destination } from '../common'; +import { generateMetadata } from '../../../testUtils'; + +export const rETLRecordV2RouterRequest: RouterTransformationRequest = { + input: [ + { + destination, + message: { + type: 'record', + action: 'insert', + fields: { + external_id: 'rEtl_external_id', + }, + channel: 'sources', + context: { + sources: { + job_id: 'job-id', + version: 'local', + job_run_id: 'job_run_id', + task_run_id: 'job_run_id', + }, + }, + recordId: '1', + rudderId: '1', + identifiers: { + email: 'test-rETL-unavailable@gmail.com', + }, + }, + metadata: generateMetadata(1), + }, + { + destination, + message: { + type: 'record', + action: 'update', + fields: { + external_id: 'rEtl_external_id', + }, + channel: 'sources', + context: { + sources: { + job_id: 'job-id', + version: 'local', + job_run_id: 'job_run_id', + task_run_id: 'job_run_id', + }, + }, + recordId: '2', + rudderId: '2', + identifiers: { + email: 'test-rETL-available@gmail.com', + }, + }, + metadata: generateMetadata(2), + }, + { + destination, + message: { + type: 'record', + action: 'delete', + fields: { + external_id: 'rEtl_external_id', + }, + channel: 'sources', + context: { + sources: { + job_id: 'job-id', + version: 'local', + job_run_id: 'job_run_id', + task_run_id: 'job_run_id', + }, + }, + recordId: '3', + rudderId: '3', + identifiers: { + email: 'test-rETL-available@gmail.com', + }, + }, + metadata: generateMetadata(3), + }, + { + destination, + message: { + type: 'record', + action: 'update', + fields: { + external_id: 'rEtl_external_id', + }, + channel: 'sources', + context: { + sources: { + job_id: 'job-id', + version: 'local', + job_run_id: 'job_run_id', + task_run_id: 'job_run_id', + }, + }, + recordId: '1', + rudderId: '1', + identifiers: { + email: 'test-rETL-unavailable@gmail.com', + }, + }, + metadata: generateMetadata(4), + }, + { + destination, + message: { + type: 'record', + action: 'insert', + fields: { + external_id: 'rEtl_external_id', + }, + channel: 'sources', + context: { + sources: { + job_id: 'job-id', + version: 'local', + job_run_id: 'job_run_id', + task_run_id: 'job_run_id', + }, + }, + recordId: '1', + rudderId: '1', + identifiers: { + email: 'test-rETL-available@gmail.com', + }, + }, + metadata: generateMetadata(5), + }, + { + destination, + message: { + type: 'record', + action: 'dummyAction', + fields: { + external_id: 'rEtl_external_id', + }, + channel: 'sources', + context: { + sources: { + job_id: 'job-id', + version: 'local', + job_run_id: 'job_run_id', + task_run_id: 'job_run_id', + }, + }, + recordId: '1', + rudderId: '1', + identifiers: { + email: 'test-rETL-available@gmail.com', + }, + }, + metadata: generateMetadata(6), + }, + { + destination, + message: { + type: 'record', + action: 'insert', + fields: { + external_id: 'rEtl_external_id', + }, + channel: 'sources', + context: { + sources: { + job_id: 'job-id', + version: 'local', + job_run_id: 'job_run_id', + task_run_id: 'job_run_id', + }, + }, + recordId: '1', + rudderId: '1', + identifiers: {}, + }, + metadata: generateMetadata(7), + }, + ], + destType: 'intercom_v2', +};