From de8e503524c1e8e3320f7458c66b8581f121b9bb Mon Sep 17 00:00:00 2001 From: Dilip Kola <33080863+koladilip@users.noreply.github.com> Date: Thu, 3 Oct 2024 13:30:05 +0530 Subject: [PATCH 1/9] fix: webhook proc workflow object assign bug (#3775) * fix: webhook proc workflow object assign bug * chore: add test for webhook headers --- .../v2/destinations/webhook/procWorkflow.yaml | 2 +- .../destinations/webhook/processor/data.ts | 254 ++++++++++++++++++ 2 files changed, 255 insertions(+), 1 deletion(-) diff --git a/src/cdk/v2/destinations/webhook/procWorkflow.yaml b/src/cdk/v2/destinations/webhook/procWorkflow.yaml index 950af46b96..bfe7bd4c13 100644 --- a/src/cdk/v2/destinations/webhook/procWorkflow.yaml +++ b/src/cdk/v2/destinations/webhook/procWorkflow.yaml @@ -27,7 +27,7 @@ steps: template: | let defaultHeaders = $.context.method in ['POST', 'PUT', 'PATCH'] ? {"content-type": "application/json"} : {} let configHeaders = $.getHashFromArray(.destination.Config.headers) - let messageHeader = typeof .message.header === "object" ? Object.assign(...Object.entries(.message.header).({[.[0]]:typeof .[1] === 'string' ? .[1] : JSON.stringify(.[1])})[]) : {} + let messageHeader = typeof .message.header === "object" ? Object.assign({}, ...Object.entries(.message.header).({[.[0]]:typeof .[1] === 'string' ? .[1] : JSON.stringify(.[1])})[]) : {} $.context.finalHeaders = { ...defaultHeaders, ...configHeaders, diff --git a/test/integrations/destinations/webhook/processor/data.ts b/test/integrations/destinations/webhook/processor/data.ts index 3add4629bf..92fe8f50d9 100644 --- a/test/integrations/destinations/webhook/processor/data.ts +++ b/test/integrations/destinations/webhook/processor/data.ts @@ -1,3 +1,5 @@ +import { head } from 'lodash'; + export const data = [ { name: 'webhook', @@ -250,6 +252,258 @@ export const data = [ }, }, }, + { + name: 'webhook', + description: 'Empty headers', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + message: { + anonymousId: 'c82cbdff-e5be-4009-ac78-cdeea09ab4b1', + context: { + device: { + id: 'df16bffa-5c3d-4fbb-9bce-3bab098129a7R', + manufacturer: 'Xiaomi', + model: 'Redmi 6', + name: 'xiaomi', + }, + network: { + carrier: 'Banglalink', + }, + os: { + name: 'android', + version: '8.1.0', + }, + traits: { + address: { + city: 'Dhaka', + country: 'Bangladesh', + }, + anonymousId: 'c82cbdff-e5be-4009-ac78-cdeea09ab4b1', + }, + }, + header: {}, + event: 'spin_result', + integrations: { + All: true, + }, + message_id: 'a80f82be-9bdc-4a9f-b2a5-15621ee41df8', + properties: { + additional_bet_index: 0, + battle_id: 'N/A', + bet_amount: 9, + bet_level: 1, + bet_multiplier: 1, + coin_balance: 9466052, + current_module_name: 'CasinoGameModule', + days_in_game: 0, + extra_param: 'N/A', + fb_profile: '0', + featureGameType: 'N/A', + game_fps: 30, + game_id: 'fireEagleBase', + game_name: 'FireEagleSlots', + gem_balance: 0, + graphicsQuality: 'HD', + idfa: '2bf99787-33d2-4ae2-a76a-c49672f97252', + internetReachability: 'ReachableViaLocalAreaNetwork', + isLowEndDevice: 'False', + is_auto_spin: 'False', + is_turbo: 'False', + isf: 'False', + ishighroller: 'False', + jackpot_win_amount: 90, + jackpot_win_type: 'Silver', + level: 6, + lifetime_gem_balance: 0, + no_of_spin: 1, + player_total_battles: 0, + player_total_shields: 0, + start_date: '2019-08-01', + total_payments: 0, + tournament_id: 'T1561970819', + userId: 'c82cbdff-e5be-4009-ac78-cdeea09ab4b1', + versionSessionCount: 2, + win_amount: 0, + }, + timestamp: '2019-09-01T15:46:51.693229+05:30', + type: 'track', + user_properties: { + coin_balance: 9466052, + current_module_name: 'CasinoGameModule', + fb_profile: '0', + game_fps: 30, + game_name: 'FireEagleSlots', + gem_balance: 0, + graphicsQuality: 'HD', + idfa: '2bf99787-33d2-4ae2-a76a-c49672f97252', + internetReachability: 'ReachableViaLocalAreaNetwork', + isLowEndDevice: false, + level: 6, + lifetime_gem_balance: 0, + player_total_battles: 0, + player_total_shields: 0, + start_date: '2019-08-01', + total_payments: 0, + userId: 'c82cbdff-e5be-4009-ac78-cdeea09ab4b1', + versionSessionCount: 2, + }, + }, + destination: { + Config: { + webhookUrl: 'http://6b0e6a60.ngrok.io', + headers: [ + { + from: '', + to: '', + }, + { + from: 'test2', + to: 'value2', + }, + ], + }, + DestinationDefinition: { + Config: { + cdkV2Enabled: true, + }, + }, + }, + metadata: { + destinationId: 'destId', + workspaceId: 'wspId', + }, + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + output: { + body: { + JSON: { + anonymousId: 'c82cbdff-e5be-4009-ac78-cdeea09ab4b1', + context: { + device: { + id: 'df16bffa-5c3d-4fbb-9bce-3bab098129a7R', + manufacturer: 'Xiaomi', + model: 'Redmi 6', + name: 'xiaomi', + }, + network: { + carrier: 'Banglalink', + }, + os: { + name: 'android', + version: '8.1.0', + }, + traits: { + address: { + city: 'Dhaka', + country: 'Bangladesh', + }, + anonymousId: 'c82cbdff-e5be-4009-ac78-cdeea09ab4b1', + }, + }, + event: 'spin_result', + integrations: { + All: true, + }, + message_id: 'a80f82be-9bdc-4a9f-b2a5-15621ee41df8', + properties: { + additional_bet_index: 0, + battle_id: 'N/A', + bet_amount: 9, + bet_level: 1, + bet_multiplier: 1, + coin_balance: 9466052, + current_module_name: 'CasinoGameModule', + days_in_game: 0, + extra_param: 'N/A', + fb_profile: '0', + featureGameType: 'N/A', + game_fps: 30, + game_id: 'fireEagleBase', + game_name: 'FireEagleSlots', + gem_balance: 0, + graphicsQuality: 'HD', + idfa: '2bf99787-33d2-4ae2-a76a-c49672f97252', + internetReachability: 'ReachableViaLocalAreaNetwork', + isLowEndDevice: 'False', + is_auto_spin: 'False', + is_turbo: 'False', + isf: 'False', + ishighroller: 'False', + jackpot_win_amount: 90, + jackpot_win_type: 'Silver', + level: 6, + lifetime_gem_balance: 0, + no_of_spin: 1, + player_total_battles: 0, + player_total_shields: 0, + start_date: '2019-08-01', + total_payments: 0, + tournament_id: 'T1561970819', + userId: 'c82cbdff-e5be-4009-ac78-cdeea09ab4b1', + versionSessionCount: 2, + win_amount: 0, + }, + timestamp: '2019-09-01T15:46:51.693229+05:30', + type: 'track', + user_properties: { + coin_balance: 9466052, + current_module_name: 'CasinoGameModule', + fb_profile: '0', + game_fps: 30, + game_name: 'FireEagleSlots', + gem_balance: 0, + graphicsQuality: 'HD', + idfa: '2bf99787-33d2-4ae2-a76a-c49672f97252', + internetReachability: 'ReachableViaLocalAreaNetwork', + isLowEndDevice: false, + level: 6, + lifetime_gem_balance: 0, + player_total_battles: 0, + player_total_shields: 0, + start_date: '2019-08-01', + total_payments: 0, + userId: 'c82cbdff-e5be-4009-ac78-cdeea09ab4b1', + versionSessionCount: 2, + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + version: '1', + userId: 'c82cbdff-e5be-4009-ac78-cdeea09ab4b1', + type: 'REST', + method: 'POST', + endpoint: 'http://6b0e6a60.ngrok.io', + headers: { + 'content-type': 'application/json', + test2: 'value2', + }, + params: {}, + files: {}, + }, + metadata: { + destinationId: 'destId', + workspaceId: 'wspId', + }, + statusCode: 200, + }, + ], + }, + }, + }, { name: 'webhook', description: 'Test 1', From a84dd4a6d9d94e5875030168697472f0d26faf23 Mon Sep 17 00:00:00 2001 From: Yashasvi Bajpai <33063622+yashasvibajpai@users.noreply.github.com> Date: Mon, 7 Oct 2024 10:49:33 +0530 Subject: [PATCH 2/9] chore: add missed track entries for fulfillment discount events (#3772) * chore: add missed track entries for fulfillment discount events * chore: update config --- src/v0/sources/shopify/config.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/v0/sources/shopify/config.js b/src/v0/sources/shopify/config.js index 7f5d41ec96..f0a844830b 100644 --- a/src/v0/sources/shopify/config.js +++ b/src/v0/sources/shopify/config.js @@ -52,10 +52,12 @@ const SHOPIFY_TRACK_MAP = { discounts_create: 'Discount Created', discounts_delete: 'Discount Deleted', discounts_update: 'Discount Updated', + discounts_redeemcode_added: 'Discount Redeemcode Added', + discounts_redeemcode_removed: 'Discount Redeemcode Removed', draft_orders_create: 'Draft Order Created', draft_orders_delete: 'Draft Order Deleted', draft_orders_update: 'Draft Order Updated', - fulfillment_order_split: 'Fulfillment Order Split', + fulfillment_orders_split: 'Fulfillment Orders Split', inventory_items_create: 'Inventory Items Created', inventory_items_delete: 'Inventory Items Deleted', inventory_items_update: 'Inventory Items Updated', @@ -141,11 +143,13 @@ const SUPPORTED_TRACK_EVENTS = [ 'collection_publications_update', 'discounts_create', 'discounts_delete', + 'discounts_redeemcode_added', + 'discounts_redeemcode_removed', 'discounts_update', 'draft_orders_create', 'draft_orders_delete', 'draft_orders_update', - 'fulfillment_order_split', + 'fulfillment_orders_split', 'inventory_items_create', 'inventory_items_delete', 'inventory_items_update', From f8cde8c072eb9415368fb97f53a3070027a3943b Mon Sep 17 00:00:00 2001 From: Manish Kumar <144022547+manish339k@users.noreply.github.com> Date: Tue, 8 Oct 2024 16:34:11 +0530 Subject: [PATCH 3/9] feat: onboard intercom v2 destination (#3721) * feat: onboard intercom v2 destination (initial commit) * feat: intercom v2 native integration * fix: lint error * fix: addressing comments * fix: addressing comments * fix: addressing comment * fix: address comment --- src/features.json | 3 +- src/v0/destinations/intercom_v2/config.js | 28 + .../intercom_v2/data/IntercomGroupConfig.json | 47 + .../data/IntercomIdentifyConfig.json | 51 + .../intercom_v2/data/IntercomTrackConfig.json | 33 + .../intercom_v2/networkHandler.js | 67 ++ src/v0/destinations/intercom_v2/transform.js | 187 ++++ src/v0/destinations/intercom_v2/utils.js | 332 +++++++ .../destinations/intercom_v2/common.ts | 150 +++ .../intercom_v2/dataDelivery/business.ts | 516 ++++++++++ .../intercom_v2/dataDelivery/data.ts | 9 + .../intercom_v2/dataDelivery/oauth.ts | 196 ++++ .../destinations/intercom_v2/network.ts | 751 +++++++++++++++ .../destinations/intercom_v2/router/data.ts | 883 ++++++++++++++++++ 14 files changed, 3252 insertions(+), 1 deletion(-) create mode 100644 src/v0/destinations/intercom_v2/config.js create mode 100644 src/v0/destinations/intercom_v2/data/IntercomGroupConfig.json create mode 100644 src/v0/destinations/intercom_v2/data/IntercomIdentifyConfig.json create mode 100644 src/v0/destinations/intercom_v2/data/IntercomTrackConfig.json create mode 100644 src/v0/destinations/intercom_v2/networkHandler.js create mode 100644 src/v0/destinations/intercom_v2/transform.js create mode 100644 src/v0/destinations/intercom_v2/utils.js create mode 100644 test/integrations/destinations/intercom_v2/common.ts create mode 100644 test/integrations/destinations/intercom_v2/dataDelivery/business.ts create mode 100644 test/integrations/destinations/intercom_v2/dataDelivery/data.ts create mode 100644 test/integrations/destinations/intercom_v2/dataDelivery/oauth.ts create mode 100644 test/integrations/destinations/intercom_v2/network.ts create mode 100644 test/integrations/destinations/intercom_v2/router/data.ts diff --git a/src/features.json b/src/features.json index 097e4a8aa0..4c7fc7b231 100644 --- a/src/features.json +++ b/src/features.json @@ -80,7 +80,8 @@ "X_AUDIENCE": true, "BLOOMREACH_CATALOG": true, "SMARTLY": true, - "HTTP": true + "HTTP": true, + "INTERCOM_V2": true }, "regulations": [ "BRAZE", diff --git a/src/v0/destinations/intercom_v2/config.js b/src/v0/destinations/intercom_v2/config.js new file mode 100644 index 0000000000..c7cb43b093 --- /dev/null +++ b/src/v0/destinations/intercom_v2/config.js @@ -0,0 +1,28 @@ +const { getMappingConfig } = require('../../util'); + +const destType = 'INTERCOM_V2'; + +const ApiVersions = { + v2: '2.10', +}; + +const ConfigCategory = { + IDENTIFY: { + name: 'IntercomIdentifyConfig', + }, + TRACK: { + name: 'IntercomTrackConfig', + }, + GROUP: { + name: 'IntercomGroupConfig', + }, +}; + +const MappingConfig = getMappingConfig(ConfigCategory, __dirname); + +module.exports = { + destType, + ConfigCategory, + MappingConfig, + ApiVersions, +}; diff --git a/src/v0/destinations/intercom_v2/data/IntercomGroupConfig.json b/src/v0/destinations/intercom_v2/data/IntercomGroupConfig.json new file mode 100644 index 0000000000..d357d2bb5d --- /dev/null +++ b/src/v0/destinations/intercom_v2/data/IntercomGroupConfig.json @@ -0,0 +1,47 @@ +[ + { + "destKey": "company_id", + "sourceKeys": "groupId", + "sourceFromGenericMap": true, + "required": true + }, + { + "destKey": "name", + "sourceKeys": "name", + "sourceFromGenericMap": true + }, + { + "destKey": "website", + "sourceKeys": "website", + "sourceFromGenericMap": true + }, + { + "destKey": "plan", + "sourceKeys": ["traits.plan", "context.traits.plan"] + }, + { + "destKey": "size", + "sourceKeys": ["traits.size", "context.traits.size"], + "metadata": { + "type": "toNumber" + } + }, + { + "destKey": "industry", + "sourceKeys": ["traits.industry", "context.traits.industry"] + }, + { + "destKey": "monthly_spend", + "sourceKeys": ["traits.monthlySpend", "context.traits.monthlySpend"], + "metadata": { + "type": "toNumber" + } + }, + { + "destKey": "remote_created_at", + "sourceKeys": ["traits.remoteCreatedAt", "context.traits.remoteCreatedAt"], + "metadata": { + "type": "secondTimestamp" + } + } +] diff --git a/src/v0/destinations/intercom_v2/data/IntercomIdentifyConfig.json b/src/v0/destinations/intercom_v2/data/IntercomIdentifyConfig.json new file mode 100644 index 0000000000..7ace2e030d --- /dev/null +++ b/src/v0/destinations/intercom_v2/data/IntercomIdentifyConfig.json @@ -0,0 +1,51 @@ +[ + { + "destKey": "external_id", + "sourceKeys": "userIdOnly", + "sourceFromGenericMap": true + }, + { + "destKey": "email", + "sourceKeys": "email", + "sourceFromGenericMap": true + }, + { + "destKey": "phone", + "sourceKeys": "phone", + "sourceFromGenericMap": true + }, + { + "destKey": "avatar", + "sourceKeys": "avatar", + "sourceFromGenericMap": true + }, + { + "destKey": "last_seen_at", + "sourceKeys": ["context.traits.lastSeenAt", "last_seen_at"], + "metadata": { + "type": "secondTimestamp" + } + }, + { + "destKey": "role", + "sourceKeys": ["traits.role", "context.traits.role"] + }, + { + "destKey": "signed_up_at", + "sourceKeys": ["traits.createdAt", "context.traits.createdAt"], + "metadata": { + "type": "secondTimestamp" + } + }, + { + "destKey": "owner_id", + "sourceKeys": ["traits.ownerId", "context.traits.ownerId"], + "metadata": { + "type": "toNumber" + } + }, + { + "destKey": "unsubscribed_from_emails", + "sourceKeys": ["traits.unsubscribedFromEmails", "context.traits.unsubscribedFromEmails"] + } +] diff --git a/src/v0/destinations/intercom_v2/data/IntercomTrackConfig.json b/src/v0/destinations/intercom_v2/data/IntercomTrackConfig.json new file mode 100644 index 0000000000..f5ab226cce --- /dev/null +++ b/src/v0/destinations/intercom_v2/data/IntercomTrackConfig.json @@ -0,0 +1,33 @@ +[ + { + "destKey": "user_id", + "sourceKeys": "userIdOnly", + "sourceFromGenericMap": true + }, + { + "destKey": "email", + "sourceKeys": "email", + "sourceFromGenericMap": true + }, + { + "destKey": "event_name", + "sourceKeys": "event", + "required": true + }, + { + "destKey": "created_at", + "sourceKeys": "timestamp", + "sourceFromGenericMap": true, + "metadata": { + "type": "secondTimestamp" + } + }, + { + "destKey": "id", + "sourceKeys": ["message.properties.id", "message.traits.id"] + }, + { + "destKey": "metadata", + "sourceKeys": "properties" + } +] diff --git a/src/v0/destinations/intercom_v2/networkHandler.js b/src/v0/destinations/intercom_v2/networkHandler.js new file mode 100644 index 0000000000..3f06460588 --- /dev/null +++ b/src/v0/destinations/intercom_v2/networkHandler.js @@ -0,0 +1,67 @@ +const { RetryableError, NetworkError } = require('@rudderstack/integrations-lib'); +const { + processAxiosResponse, + getDynamicErrorType, +} = require('../../../adapters/utils/networkUtils'); +const { AUTH_STATUS_INACTIVE } = require('../../../adapters/networkhandler/authConstants'); +const { prepareProxyRequest, proxyRequest } = require('../../../adapters/network'); +const { TransformerProxyError } = require('../../util/errorTypes'); +const tags = require('../../util/tags'); +const { isHttpStatusSuccess } = require('../../util'); + +// ref: https://github.com/intercom/oauth2-intercom +// Intercom's OAuth implementation does not use refresh tokens. Access tokens are valid until a user revokes access manually, or until an app deauthorizes itself. +const getAuthErrCategory = (status) => { + if (status === 401) { + return AUTH_STATUS_INACTIVE; + } + return ''; +}; + +const errorResponseHandler = (destinationResponse, dest) => { + const { response, status } = destinationResponse; + const message = `[Intercom V2 Response Handler] Request failed for destination ${dest} with status: ${status}`; + if (status === 401) { + throw new TransformerProxyError( + `${message}. ${JSON.stringify(response)}`, + 400, + { + [tags.TAG_NAMES.ERROR_TYPE]: getDynamicErrorType(status), + }, + destinationResponse, + getAuthErrCategory(status), + ); + } + if (status === 408) { + throw new RetryableError(message, 500, destinationResponse, getAuthErrCategory(status)); + } + if (!isHttpStatusSuccess(status)) { + throw new NetworkError( + `${message}. ${JSON.stringify(response)}`, + status, + { + [tags.TAG_NAMES.ERROR_TYPE]: getDynamicErrorType(status), + }, + destinationResponse, + ); + } +}; + +const responseHandler = (responseParams) => { + const { destinationResponse, destType } = responseParams; + errorResponseHandler(destinationResponse, destType); + return { + destinationResponse: destinationResponse.response, + message: 'Request Processed Successfully', + status: destinationResponse.status, + }; +}; + +function networkHandler() { + this.prepareProxy = prepareProxyRequest; + this.proxy = proxyRequest; + this.processAxiosResponse = processAxiosResponse; + this.responseHandler = responseHandler; +} + +module.exports = { networkHandler }; diff --git a/src/v0/destinations/intercom_v2/transform.js b/src/v0/destinations/intercom_v2/transform.js new file mode 100644 index 0000000000..8d97e20bde --- /dev/null +++ b/src/v0/destinations/intercom_v2/transform.js @@ -0,0 +1,187 @@ +const { InstrumentationError } = require('@rudderstack/integrations-lib'); +const { + handleRtTfSingleEventError, + getSuccessRespEvents, + getEventType, + constructPayload, + getIntegrationsObj, +} = require('../../util'); +const { EventType } = require('../../../constants'); +const { + getHeaders, + searchContact, + handleDetachUserAndCompany, + getResponse, + createOrUpdateCompany, + attachContactToCompany, + addOrUpdateTagsToCompany, + getStatusCode, + getBaseEndpoint, +} = require('./utils'); +const { + getName, + filterCustomAttributes, + addMetadataToPayload, +} = require('../../../cdk/v2/destinations/intercom/utils'); +const { MappingConfig, ConfigCategory } = require('./config'); + +const transformIdentifyPayload = (event) => { + const { message, destination } = event; + const category = ConfigCategory.IDENTIFY; + const payload = constructPayload(message, MappingConfig[category.name]); + const shouldSendAnonymousId = destination.Config.sendAnonymousId; + if (!payload.external_id && shouldSendAnonymousId) { + payload.external_id = message.anonymousId; + } + if (!(payload.external_id || payload.email)) { + throw new InstrumentationError('Either email or userId is required for Identify call'); + } + payload.name = getName(message); + payload.custom_attributes = message.traits || message.context.traits || {}; + payload.custom_attributes = filterCustomAttributes(payload, 'user', destination); + return payload; +}; + +const transformTrackPayload = (event) => { + const { message, destination } = event; + const category = ConfigCategory.TRACK; + let payload = constructPayload(message, MappingConfig[category.name]); + if (!payload.id) { + const integrationsObj = getIntegrationsObj(message, 'INTERCOM'); + payload.id = integrationsObj?.id; + } + const shouldSendAnonymousId = destination.Config.sendAnonymousId; + if (!payload.user_id && shouldSendAnonymousId) { + payload.user_id = message.anonymousId; + } + if (!(payload.user_id || payload.email || payload.id)) { + throw new InstrumentationError('Either email or userId or id is required for Track call'); + } + payload = addMetadataToPayload(payload); + return payload; +}; + +const transformGroupPayload = (event) => { + const { message, destination } = 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); + return payload; +}; + +const constructIdentifyResponse = async (event) => { + const { destination, metadata } = event; + + const payload = transformIdentifyPayload(event); + + let method = 'POST'; + let endpoint = `${getBaseEndpoint(destination)}/contacts`; + const headers = getHeaders(metadata); + + // when contact is found in intercom + const contactId = await searchContact(event); + if (contactId) { + method = 'PUT'; + endpoint += `/${contactId}`; + + // detach user and company if required + await handleDetachUserAndCompany(contactId, event); + } + + return getResponse(method, endpoint, headers, payload); +}; + +const constructTrackResponse = (event) => { + const { destination, metadata } = event; + const payload = transformTrackPayload(event); + const method = 'POST'; + const endpoint = `${getBaseEndpoint(destination)}/events`; + const headers = getHeaders(metadata); + + return getResponse(method, endpoint, headers, payload); +}; + +const constructGroupResponse = async (event) => { + const { destination, metadata } = event; + const payload = transformGroupPayload(event); + + const method = 'POST'; + let endpoint = `${getBaseEndpoint(destination)}/companies`; + const headers = getHeaders(metadata); + let finalPayload = payload; + + // create or update company + const companyId = await createOrUpdateCompany(payload, destination, metadata); + + // when contact is found in intercom + const contactId = await searchContact(event); + if (contactId) { + // attach user and company + finalPayload = { + id: companyId, + }; + endpoint = `${getBaseEndpoint(destination)}/contacts/${contactId}/companies`; + await attachContactToCompany(finalPayload, endpoint, destination, metadata); + } + + // add tags to company + await addOrUpdateTagsToCompany(companyId, event); + + return getResponse(method, endpoint, headers, finalPayload); +}; + +const processEvent = async (event) => { + const { message } = event; + const messageType = getEventType(message); + let response; + switch (messageType) { + case EventType.IDENTIFY: + response = await constructIdentifyResponse(event); + break; + case EventType.TRACK: + response = constructTrackResponse(event); + break; + case EventType.GROUP: + response = await constructGroupResponse(event); + break; + default: + throw new InstrumentationError(`message type ${messageType} is not supported.`); + } + return response; +}; + +const process = async (event) => { + const response = await processEvent(event); + return response; +}; + +const processRouter = async (inputs, reqMetadata) => { + const results = await Promise.all( + inputs.map(async (event) => { + try { + const response = await process(event); + return getSuccessRespEvents( + response, + [event.metadata], + event.destination, + false, + getStatusCode(event), + ); + } catch (error) { + return handleRtTfSingleEventError(event, error, reqMetadata); + } + }), + ); + return results; +}; + +const processRouterDest = async (inputs, reqMetadata) => { + if (!inputs || inputs.length === 0) { + return []; + } + const response = await processRouter(inputs, reqMetadata); + return response; +}; + +module.exports = { processRouterDest }; diff --git a/src/v0/destinations/intercom_v2/utils.js b/src/v0/destinations/intercom_v2/utils.js new file mode 100644 index 0000000000..69ea1385d9 --- /dev/null +++ b/src/v0/destinations/intercom_v2/utils.js @@ -0,0 +1,332 @@ +const { + removeUndefinedAndNullValues, + InstrumentationError, + NetworkError, + InvalidAuthTokenError, +} = require('@rudderstack/integrations-lib'); +const { EventType } = require('../../../constants'); +const { JSON_MIME_TYPE } = require('../../util/constant'); +const tags = require('../../util/tags'); +const { + getFieldValueFromMessage, + isHttpStatusSuccess, + defaultRequestConfig, + getEventType, +} = require('../../util'); +const { HTTP_STATUS_CODES } = require('../../util/constant'); +const { + SEARCH_CONTACT_ENDPOINT, + CREATE_OR_UPDATE_COMPANY_ENDPOINT, + TAGS_ENDPOINT, + BASE_ENDPOINT, + BASE_EU_ENDPOINT, + BASE_AU_ENDPOINT, +} = require('../../../cdk/v2/destinations/intercom/config'); +const { getLookUpField } = require('../../../cdk/v2/destinations/intercom/utils'); +const { handleHttpRequest } = require('../../../adapters/network'); +const { getAccessToken } = require('../../util'); +const { ApiVersions, destType } = require('./config'); +const { getDynamicErrorType } = require('../../../adapters/utils/networkUtils'); + +/** + * method to handle error during api call + * ref docs: https://developers.intercom.com/docs/references/rest-api/errors/http-responses/ + * e.g. + * 400 - code: parameter_not_found (or parameter_invalid), message: company not specified + * 401 - code: unauthorized, message: Access Token Invalid + * 404 - code: company_not_found, message: Company Not Found + * @param {*} message + * @param {*} processedResponse + */ +const intercomErrorHandler = (message, processedResponse) => { + const errorMessages = JSON.stringify(processedResponse.response); + if (processedResponse.status === 400) { + throw new InstrumentationError(`${message} : ${errorMessages}`); + } + if (processedResponse.status === 401) { + throw new InvalidAuthTokenError(message, 400, errorMessages); + } + if (processedResponse.status === 404) { + throw new InstrumentationError(`${message} : ${errorMessages}`); + } + throw new NetworkError( + `${message} : ${errorMessages}`, + processedResponse.status, + { + [tags]: getDynamicErrorType(processedResponse.status), + }, + processedResponse, + ); +}; + +const getHeaders = (metadata) => ({ + Authorization: `Bearer ${getAccessToken(metadata, 'accessToken')}`, + Accept: JSON_MIME_TYPE, + 'Content-Type': JSON_MIME_TYPE, + 'Intercom-Version': ApiVersions.v2, +}); + +const getBaseEndpoint = (destination) => { + const { apiServer } = destination.Config; + switch (apiServer) { + case 'Europe': + return BASE_EU_ENDPOINT; + case 'Australia': + return BASE_AU_ENDPOINT; + default: + return BASE_ENDPOINT; + } +}; + +const getStatusCode = (event) => { + const { message } = event; + let statusCode = HTTP_STATUS_CODES.OK; + const messageType = getEventType(message); + if (messageType === EventType.GROUP) { + statusCode = HTTP_STATUS_CODES.SUPPRESS_EVENTS; + } + return statusCode; +}; + +const getResponse = (method, endpoint, headers, payload) => { + const response = defaultRequestConfig(); + response.method = method; + response.endpoint = endpoint; + response.headers = headers; + response.body.JSON = removeUndefinedAndNullValues(payload); + return response; +}; + +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 data = JSON.stringify({ + query: { + operator: 'AND', + value: [ + { + field: lookupField, + operator: '=', + value: lookupFieldValue, + }, + ], + }, + }); + + const headers = getHeaders(metadata); + const endpoint = `${getBaseEndpoint(destination)}/${SEARCH_CONTACT_ENDPOINT}`; + const statTags = { + destType, + feature: 'transformation', + endpointPath: '/contacts/search', + requestMethod: 'POST', + module: 'router', + metadata, + }; + + const { processedResponse: response } = await handleHttpRequest( + 'POST', + endpoint, + data, + { + headers, + }, + statTags, + ); + + if (!isHttpStatusSuccess(response.status)) { + intercomErrorHandler('Unable to search contact due to', response); + } + return response.response?.data.length > 0 ? response.response?.data[0]?.id : null; +}; + +const getCompanyId = async (company, destination, metadata) => { + if (!company.id && !company.name) return undefined; + const headers = getHeaders(metadata); + + const queryParam = company.id ? `company_id=${company.id}` : `name=${company.name}`; + const endpoint = `${getBaseEndpoint(destination)}/companies?${queryParam}`; + + const statTags = { + metadata, + destType, + feature: 'transformation', + endpointPath: '/companies', + requestMethod: 'GET', + module: 'router', + }; + + const { processedResponse: response } = await handleHttpRequest( + 'GET', + endpoint, + { + headers, + }, + statTags, + ); + + if (!isHttpStatusSuccess(response.status)) { + intercomErrorHandler('Unable to get company id due to', response); + } + + return response?.response?.id; +}; + +const detachContactAndCompany = async (contactId, company, destination, metadata) => { + const companyId = await getCompanyId(company, destination, metadata); + if (!companyId) return; + + const headers = getHeaders(metadata); + const endpoint = `${getBaseEndpoint(destination)}/contacts/${contactId}/companies/${companyId}`; + + const statTags = { + metadata, + destType, + feature: 'transformation', + endpointPath: 'contacts/companies', + requestMethod: 'DELETE', + module: 'router', + }; + + const { processedResponse: response } = await handleHttpRequest( + 'DELETE', + endpoint, + { + headers, + }, + statTags, + ); + + if (!isHttpStatusSuccess(response.status)) { + intercomErrorHandler('Unable to detach contact and company due to', response); + } +}; + +const handleDetachUserAndCompany = async (contactId, event) => { + const { message, destination, metadata } = event; + const company = message?.traits?.company || message?.context?.traits?.company; + const shouldDetachUserAndCompany = company?.remove; + if (shouldDetachUserAndCompany) { + await detachContactAndCompany(contactId, company, destination, metadata); + } +}; + +const createOrUpdateCompany = async (payload, destination, metadata) => { + const headers = getHeaders(metadata); + const endpoint = `${getBaseEndpoint(destination)}/${CREATE_OR_UPDATE_COMPANY_ENDPOINT}`; + + const finalPayload = JSON.stringify(removeUndefinedAndNullValues(payload)); + + const statTags = { + metadata, + destType, + feature: 'transformation', + endpointPath: '/companies', + requestMethod: 'POST', + module: 'router', + }; + + const { processedResponse: response } = await handleHttpRequest( + 'POST', + endpoint, + finalPayload, + { + headers, + }, + statTags, + ); + + if (!isHttpStatusSuccess(response.status)) { + intercomErrorHandler('Unable to Create or Update Company due to', response); + } + + return response.response?.id; +}; + +const attachContactToCompany = async (payload, endpoint, destination, metadata) => { + const headers = getHeaders(metadata); + const finalPayload = JSON.stringify(removeUndefinedAndNullValues(payload)); + + const statTags = { + metadata, + destType, + feature: 'transformation', + endpointPath: '/contact/{id}/companies', + requestMethod: 'POST', + module: 'router', + }; + + const { processedResponse: response } = await handleHttpRequest( + 'POST', + endpoint, + finalPayload, + { + headers, + }, + statTags, + ); + + if (!isHttpStatusSuccess(response.status)) { + intercomErrorHandler('Unable to attach Contact or User to Company due to', response); + } +}; + +const addOrUpdateTagsToCompany = async (id, event) => { + const { message, destination, metadata } = event; + const companyTags = message?.traits?.tags || message?.context?.traits?.tags; + if (!companyTags) return; + + const headers = getHeaders(metadata); + const endpoint = `${getBaseEndpoint(destination)}/${TAGS_ENDPOINT}`; + + const statTags = { + destType, + feature: 'transformation', + endpointPath: '/tags', + requestMethod: 'POST', + module: 'router', + metadata, + }; + + await Promise.all( + companyTags.map(async (tag) => { + const finalPayload = { + name: tag, + companies: [ + { + id, + }, + ], + }; + const { processedResponse: response } = await handleHttpRequest( + 'POST', + endpoint, + finalPayload, + { + headers, + }, + statTags, + ); + + if (!isHttpStatusSuccess(response.status)) { + intercomErrorHandler('Unable to Add or Update the Tag to Company due to', response); + } + }), + ); +}; + +module.exports = { + getStatusCode, + getHeaders, + searchContact, + handleDetachUserAndCompany, + getResponse, + createOrUpdateCompany, + attachContactToCompany, + addOrUpdateTagsToCompany, + getBaseEndpoint, +}; diff --git a/test/integrations/destinations/intercom_v2/common.ts b/test/integrations/destinations/intercom_v2/common.ts new file mode 100644 index 0000000000..60c7e02b33 --- /dev/null +++ b/test/integrations/destinations/intercom_v2/common.ts @@ -0,0 +1,150 @@ +import { Destination } from '../../../../src/types'; + +const destTypeInUpperCase = 'INTERCOM_V2'; +const channel = 'web'; +const originalTimestamp = '2023-11-10T14:42:44.724Z'; +const timestamp = '2023-11-22T10:12:44.757+05:30'; +const anonymousId = 'test-anonymous-id'; + +const destination: Destination = { + ID: '123', + Name: destTypeInUpperCase, + DestinationDefinition: { + ID: '123', + Name: destTypeInUpperCase, + DisplayName: 'Intercom V2', + Config: {}, + }, + Config: { + apiServer: 'US', + sendAnonymousId: false, + }, + Enabled: true, + WorkspaceID: '123', + Transformations: [], +}; + +const destinationApiServerEU: Destination = { + ID: '123', + Name: destTypeInUpperCase, + DestinationDefinition: { + ID: '123', + Name: destTypeInUpperCase, + DisplayName: 'Intercom V2', + Config: {}, + }, + Config: { + apiServer: 'Europe', + sendAnonymousId: true, + }, + Enabled: true, + WorkspaceID: '123', + Transformations: [], +}; + +const destinationApiServerAU: Destination = { + ID: '123', + Name: destTypeInUpperCase, + DestinationDefinition: { + ID: '123', + Name: destTypeInUpperCase, + DisplayName: 'Intercom V2', + Config: {}, + }, + Config: { + apiServer: 'Australia', + sendAnonymousId: true, + }, + Enabled: true, + WorkspaceID: '123', + Transformations: [], +}; + +const userTraits = { + age: 23, + email: 'test@rudderlabs.com', + phone: '+91 9999999999', + firstName: 'John', + lastName: 'Snow', + address: 'california usa', + ownerId: '13', +}; + +const detachUserCompanyUserTraits = { + age: 23, + email: 'detach-user-company@rudderlabs.com', + phone: '+91 9999999999', + firstName: 'John', + lastName: 'Snow', + address: 'california usa', + ownerId: '13', +}; + +const companyTraits = { + email: 'known-email@rudderlabs.com', + name: 'RudderStack', + size: 500, + website: 'www.rudderstack.com', + industry: 'CDP', + plan: 'enterprise', + remoteCreatedAt: '2024-09-12T14:40:33.996+05:30', +}; + +const properties = { + revenue: { + amount: 1232, + currency: 'inr', + test: 123, + }, + price: { + amount: 3000, + currency: 'USD', + }, +}; + +const headers = { + Authorization: 'Bearer default-accessToken', + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Intercom-Version': '2.10', +}; + +const headersWithRevokedAccessToken = { + ...headers, + Authorization: 'Bearer revoked-accessToken', +}; + +const RouterInstrumentationErrorStatTags = { + destType: destTypeInUpperCase, + errorCategory: 'dataValidation', + errorType: 'instrumentation', + implementation: 'native', + module: 'destination', + destinationId: 'default-destinationId', + workspaceId: 'default-workspaceId', + feature: 'router', +}; + +const RouterNetworkErrorStatTags = { + ...RouterInstrumentationErrorStatTags, + errorCategory: 'network', + errorType: 'aborted', +}; + +export { + channel, + destination, + originalTimestamp, + timestamp, + destinationApiServerEU, + destinationApiServerAU, + userTraits, + companyTraits, + properties, + detachUserCompanyUserTraits, + anonymousId, + headers, + headersWithRevokedAccessToken, + RouterInstrumentationErrorStatTags, + RouterNetworkErrorStatTags, +}; diff --git a/test/integrations/destinations/intercom_v2/dataDelivery/business.ts b/test/integrations/destinations/intercom_v2/dataDelivery/business.ts new file mode 100644 index 0000000000..c75993bc7a --- /dev/null +++ b/test/integrations/destinations/intercom_v2/dataDelivery/business.ts @@ -0,0 +1,516 @@ +import { + generateMetadata, + generateProxyV0Payload, + generateProxyV1Payload, +} from '../../../testUtils'; +import { headers, RouterNetworkErrorStatTags } from '../common'; +import { ProxyV1TestData } from '../../../testTypes'; + +const createUserPayload = { + email: 'test-unsupported-media@rudderlabs.com', + external_id: 'user-id-1', + name: 'John Snow', +}; + +const conflictUserPayload = { + email: 'conflict@test.com', + user_id: 'conflict_test_user_id_1', +}; + +const statTags = { + ...RouterNetworkErrorStatTags, + errorType: 'retryable', + feature: 'dataDelivery', +}; + +export const testScenariosForV0API = [ + { + id: 'INTERCOM_V2_v0_other_scenario_1', + name: 'intercom_v2', + description: + '[Proxy v0 API] :: Scenario to test Malformed Payload Response Handling from Destination', + successCriteria: 'Should return 400 status code with error message', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v0', + input: { + request: { + body: generateProxyV0Payload({ + JSON: { + custom_attributes: { + isOpenSource: true, + }, + }, + headers, + endpoint: 'https://api.intercom.io/contacts/proxy-contact-id', + method: 'PUT', + }), + }, + }, + output: { + response: { + status: 400, + body: { + output: { + destinationResponse: { + response: { + errors: [ + { + code: 'parameter_invalid', + message: "Custom attribute 'isOpenSource' does not exist", + }, + ], + request_id: 'request_1', + type: 'error.list', + }, + status: 400, + }, + message: + '[Intercom V2 Response Handler] Request failed for destination intercom_v2 with status: 400. {"request_id":"request_1","type":"error.list","errors":[{"code":"parameter_invalid","message":"Custom attribute \'isOpenSource\' does not exist"}]}', + status: 400, + statTags: { + ...statTags, + errorType: 'aborted', + }, + }, + }, + }, + }, + }, + { + id: 'INTERCOM_V2_v0_other_scenario_2', + name: 'intercom_v2', + description: '[Proxy v0 API] :: Scenario to test Rate Limit Exceeded Handling from Destination', + successCriteria: 'Should return 429 status code with error message', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v0', + input: { + request: { + body: generateProxyV0Payload({ + JSON: { + email: 'new@test.com', + }, + endpoint: 'https://api.intercom.io/contacts/proxy-contact-id', + method: 'PUT', + headers, + }), + }, + }, + output: { + response: { + status: 429, + body: { + output: { + destinationResponse: { + response: { + errors: [ + { + code: 'rate_limit_exceeded', + message: 'The rate limit for the App has been exceeded', + }, + ], + request_id: 'request125', + type: 'error.list', + }, + status: 429, + }, + message: + '[Intercom V2 Response Handler] Request failed for destination intercom_v2 with status: 429. {"errors":[{"code":"rate_limit_exceeded","message":"The rate limit for the App has been exceeded"}],"request_id":"request125","type":"error.list"}', + status: 429, + statTags: { + ...statTags, + errorType: 'throttled', + }, + }, + }, + }, + }, + }, + { + id: 'INTERCOM_V2_v0_other_scenario_3', + name: 'intercom_v2', + description: '[Proxy v0 API] :: Scenario to test Conflict User Handling from Destination', + successCriteria: 'Should return 409 status code with error message', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v0', + input: { + request: { + body: generateProxyV0Payload({ + JSON: conflictUserPayload, + headers, + endpoint: 'https://api.intercom.io/contacts', + method: 'POST', + }), + }, + }, + output: { + response: { + status: 409, + body: { + output: { + destinationResponse: { + response: { + errors: [ + { + code: 'conflict', + message: 'A contact matching those details already exists with id=test', + }, + ], + request_id: 'request126', + type: 'error.list', + }, + status: 409, + }, + message: + '[Intercom V2 Response Handler] Request failed for destination intercom_v2 with status: 409. {"errors":[{"code":"conflict","message":"A contact matching those details already exists with id=test"}],"request_id":"request126","type":"error.list"}', + status: 409, + statTags: { + ...statTags, + errorType: 'aborted', + }, + }, + }, + }, + }, + }, + { + id: 'INTERCOM_V2_v0_other_scenario_4', + name: 'intercom_v2', + description: '[Proxy v0 API] :: Scenario to test Unsupported Media Handling from Destination', + successCriteria: 'Should return 406 status code with error message', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v0', + input: { + request: { + body: generateProxyV0Payload({ + JSON: createUserPayload, + headers: { + ...headers, + Accept: 'test', + 'Content-Type': 'test', + }, + endpoint: 'https://api.intercom.io/contacts', + method: 'POST', + }), + }, + }, + output: { + response: { + status: 406, + body: { + output: { + destinationResponse: { + response: { + errors: [ + { + code: 'media_type_not_acceptable', + message: 'The Accept header should send a media type of application/json', + }, + ], + type: 'error.list', + }, + status: 406, + }, + message: + '[Intercom V2 Response Handler] Request failed for destination intercom_v2 with status: 406. {"errors":[{"code":"media_type_not_acceptable","message":"The Accept header should send a media type of application/json"}],"type":"error.list"}', + status: 406, + statTags: { + ...statTags, + errorType: 'aborted', + }, + }, + }, + }, + }, + }, + { + id: 'INTERCOM_V2_v0_other_scenario_5', + name: 'intercom_v2', + description: '[Proxy v0 API] :: Request Timeout Error Handling from Destination', + successCriteria: 'Should return 500 status code with error message', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v0', + input: { + request: { + body: generateProxyV0Payload({ + JSON: { + email: 'time-out@gmail.com', + }, + endpoint: 'https://api.intercom.io/contacts', + headers, + method: 'POST', + }), + }, + }, + output: { + response: { + status: 500, + body: { + output: { + status: 500, + message: + '[Intercom V2 Response Handler] Request failed for destination intercom_v2 with status: 408', + destinationResponse: { + response: { + type: 'error.list', + request_id: 'req-123', + errors: [ + { + code: 'Request Timeout', + message: 'The server would not wait any longer for the client', + }, + ], + }, + status: 408, + }, + statTags, + }, + }, + }, + }, + }, +]; + +export const testScenariosForV1API: ProxyV1TestData[] = [ + { + id: 'INTERCOM_V2_v1_other_scenario_1', + name: 'intercom_v2', + description: + '[Proxy v1 API] :: Scenario to test Malformed Payload Response Handling from Destination', + successCriteria: 'Should return 400 status code with error message', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v1', + input: { + request: { + body: generateProxyV1Payload({ + JSON: { + custom_attributes: { + isOpenSource: true, + }, + }, + headers, + endpoint: 'https://api.intercom.io/contacts/proxy-contact-id', + method: 'PUT', + }), + method: 'POST', + }, + }, + output: { + response: { + status: 200, + body: { + output: { + response: [ + { + error: + '{"request_id":"request_1","type":"error.list","errors":[{"code":"parameter_invalid","message":"Custom attribute \'isOpenSource\' does not exist"}]}', + statusCode: 400, + metadata: generateMetadata(1), + }, + ], + message: + '[Intercom V2 Response Handler] Request failed for destination intercom_v2 with status: 400. {"request_id":"request_1","type":"error.list","errors":[{"code":"parameter_invalid","message":"Custom attribute \'isOpenSource\' does not exist"}]}', + status: 400, + statTags: { + ...statTags, + errorType: 'aborted', + }, + }, + }, + }, + }, + }, + { + id: 'INTERCOM_V2_v1_other_scenario_2', + name: 'intercom_v2', + description: '[Proxy v1 API] :: Scenario to test Rate Limit Exceeded Handling from Destination', + successCriteria: 'Should return 429 status code with error message', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v1', + input: { + request: { + body: generateProxyV1Payload({ + JSON: { + email: 'new@test.com', + }, + endpoint: 'https://api.intercom.io/contacts/proxy-contact-id', + headers, + method: 'PUT', + }), + method: 'POST', + }, + }, + output: { + response: { + status: 200, + body: { + output: { + response: [ + { + error: + '{"errors":[{"code":"rate_limit_exceeded","message":"The rate limit for the App has been exceeded"}],"request_id":"request125","type":"error.list"}', + statusCode: 429, + metadata: generateMetadata(1), + }, + ], + message: + '[Intercom V2 Response Handler] Request failed for destination intercom_v2 with status: 429. {"errors":[{"code":"rate_limit_exceeded","message":"The rate limit for the App has been exceeded"}],"request_id":"request125","type":"error.list"}', + status: 429, + statTags: { + ...statTags, + errorType: 'throttled', + }, + }, + }, + }, + }, + }, + { + id: 'INTERCOM_V2_v1_other_scenario_3', + name: 'intercom_v2', + description: '[Proxy v1 API] :: Scenario to test Conflict User Handling from Destination', + successCriteria: 'Should return 409 status code with error message', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v1', + input: { + request: { + body: generateProxyV1Payload({ + JSON: conflictUserPayload, + headers, + endpoint: 'https://api.intercom.io/contacts', + }), + method: 'POST', + }, + }, + output: { + response: { + status: 200, + body: { + output: { + response: [ + { + error: + '{"errors":[{"code":"conflict","message":"A contact matching those details already exists with id=test"}],"request_id":"request126","type":"error.list"}', + statusCode: 409, + metadata: generateMetadata(1), + }, + ], + message: + '[Intercom V2 Response Handler] Request failed for destination intercom_v2 with status: 409. {"errors":[{"code":"conflict","message":"A contact matching those details already exists with id=test"}],"request_id":"request126","type":"error.list"}', + status: 409, + statTags: { + ...statTags, + errorType: 'aborted', + }, + }, + }, + }, + }, + }, + { + id: 'INTERCOM_V2_v1_other_scenario_4', + name: 'intercom_v2', + description: '[Proxy v1 API] :: Scenario to test Unsupported Media Handling from Destination', + successCriteria: 'Should return 406 status code with error message', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v1', + input: { + request: { + body: generateProxyV1Payload({ + JSON: createUserPayload, + headers: { + ...headers, + Accept: 'test', + 'Content-Type': 'test', + }, + endpoint: 'https://api.intercom.io/contacts', + }), + method: 'POST', + }, + }, + output: { + response: { + status: 200, + body: { + output: { + response: [ + { + error: + '{"errors":[{"code":"media_type_not_acceptable","message":"The Accept header should send a media type of application/json"}],"type":"error.list"}', + statusCode: 406, + metadata: generateMetadata(1), + }, + ], + message: + '[Intercom V2 Response Handler] Request failed for destination intercom_v2 with status: 406. {"errors":[{"code":"media_type_not_acceptable","message":"The Accept header should send a media type of application/json"}],"type":"error.list"}', + status: 406, + statTags: { + ...statTags, + errorType: 'aborted', + }, + }, + }, + }, + }, + }, + { + id: 'INTERCOM_V2_v1_other_scenario_5', + name: 'intercom_v2', + description: '[Proxy v1 API] :: Request Timeout Error Handling from Destination', + successCriteria: 'Should return 500 status code with error message', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v1', + input: { + request: { + body: generateProxyV1Payload({ + JSON: { + email: 'time-out@gmail.com', + }, + endpoint: 'https://api.intercom.io/contacts', + headers, + }), + method: 'POST', + }, + }, + output: { + response: { + status: 200, + body: { + output: { + status: 500, + message: + '[Intercom V2 Response Handler] Request failed for destination intercom_v2 with status: 408', + response: [ + { + error: + '{"type":"error.list","request_id":"req-123","errors":[{"code":"Request Timeout","message":"The server would not wait any longer for the client"}]}', + metadata: generateMetadata(1), + statusCode: 500, + }, + ], + statTags, + }, + }, + }, + }, + }, +]; diff --git a/test/integrations/destinations/intercom_v2/dataDelivery/data.ts b/test/integrations/destinations/intercom_v2/dataDelivery/data.ts new file mode 100644 index 0000000000..1286b70f28 --- /dev/null +++ b/test/integrations/destinations/intercom_v2/dataDelivery/data.ts @@ -0,0 +1,9 @@ +import { oauthScenariosV0, oauthScenariosV1 } from './oauth'; +import { testScenariosForV0API, testScenariosForV1API } from './business'; + +export const data = [ + ...oauthScenariosV0, + ...oauthScenariosV1, + ...testScenariosForV0API, + ...testScenariosForV1API, +]; diff --git a/test/integrations/destinations/intercom_v2/dataDelivery/oauth.ts b/test/integrations/destinations/intercom_v2/dataDelivery/oauth.ts new file mode 100644 index 0000000000..8f36a4bd55 --- /dev/null +++ b/test/integrations/destinations/intercom_v2/dataDelivery/oauth.ts @@ -0,0 +1,196 @@ +import { ProxyV1TestData } from '../../../testTypes'; +import { generateMetadata, generateProxyV1Payload } from '../../../testUtils'; +import { headers, headersWithRevokedAccessToken, RouterNetworkErrorStatTags } from '../common'; + +const commonRequestParameters = { + endpoint: `https://api.intercom.io/events`, + JSON: { + created_at: 1700628164, + email: 'test@rudderlabs.com', + event_name: 'Product Viewed', + metadata: { + price: { + amount: 3000, + currency: 'USD', + }, + revenue: { + amount: 1232, + currency: 'inr', + test: 123, + }, + }, + user_id: 'user-id-1', + }, +}; + +export const oauthScenariosV0 = [ + { + id: 'INTERCOM_V2_v0_oauth_scenario_1', + name: 'intercom_v2', + description: '[Proxy v0 API] :: [oauth] app event fails due to revoked access token', + successCriteria: 'Should return 400 with revoked access token error', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v0', + input: { + request: { + body: generateProxyV1Payload({ + ...commonRequestParameters, + headers: headersWithRevokedAccessToken, + accessToken: 'revoked-accessToken', + }), + method: 'POST', + }, + }, + output: { + response: { + status: 400, + body: { + output: { + destinationResponse: { + response: { + errors: [ + { + code: 'unauthorized', + message: 'Access Token Invalid', + }, + ], + request_id: 'request_id-1', + type: 'error.list', + }, + status: 401, + }, + statTags: { + ...RouterNetworkErrorStatTags, + feature: 'dataDelivery', + }, + authErrorCategory: 'AUTH_STATUS_INACTIVE', + message: + '[Intercom V2 Response Handler] Request failed for destination intercom_v2 with status: 401. {"type":"error.list","request_id":"request_id-1","errors":[{"code":"unauthorized","message":"Access Token Invalid"}]}', + status: 400, + }, + }, + }, + }, + }, + { + id: 'INTERCOM_V2_v0_oauth_scenario_2', + name: 'intercom_v2', + description: '[Proxy v0 API] :: [oauth] success case', + successCriteria: 'Should return 200 response', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v0', + input: { + request: { + body: generateProxyV1Payload({ + ...commonRequestParameters, + headers, + }), + method: 'POST', + }, + }, + output: { + response: { + status: 200, + body: { + output: { + status: 202, + message: 'Request Processed Successfully', + destinationResponse: '', + }, + }, + }, + }, + }, +]; + +export const oauthScenariosV1: ProxyV1TestData[] = [ + { + id: 'INTERCOM_V2_v1_oauth_scenario_1', + name: 'intercom_v2', + description: '[Proxy v1 API] :: [oauth] app event fails due to revoked access token', + successCriteria: 'Should return 400 with revoked access token error', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v1', + input: { + request: { + body: generateProxyV1Payload({ + ...commonRequestParameters, + headers: headersWithRevokedAccessToken, + accessToken: 'revoked-accessToken', + }), + method: 'POST', + }, + }, + output: { + response: { + status: 400, + body: { + output: { + response: [ + { + error: + '{"type":"error.list","request_id":"request_id-1","errors":[{"code":"unauthorized","message":"Access Token Invalid"}]}', + statusCode: 400, + metadata: { + ...generateMetadata(1), + secret: { accessToken: 'revoked-accessToken' }, + }, + }, + ], + statTags: { + ...RouterNetworkErrorStatTags, + feature: 'dataDelivery', + }, + authErrorCategory: 'AUTH_STATUS_INACTIVE', + message: + '[Intercom V2 Response Handler] Request failed for destination intercom_v2 with status: 401. {"type":"error.list","request_id":"request_id-1","errors":[{"code":"unauthorized","message":"Access Token Invalid"}]}', + status: 400, + }, + }, + }, + }, + }, + { + id: 'INTERCOM_V2_v1_oauth_scenario_2', + name: 'intercom_v2', + description: '[Proxy v1 API] :: [oauth] success case', + successCriteria: 'Should return 200 response', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v1', + input: { + request: { + body: generateProxyV1Payload({ + ...commonRequestParameters, + headers, + }), + method: 'POST', + }, + }, + output: { + response: { + status: 200, + body: { + output: { + status: 202, + message: 'Request Processed Successfully', + response: [ + { + statusCode: 202, + metadata: generateMetadata(1), + error: '""', + }, + ], + }, + }, + }, + }, + }, +]; diff --git a/test/integrations/destinations/intercom_v2/network.ts b/test/integrations/destinations/intercom_v2/network.ts new file mode 100644 index 0000000000..26ff3c38ee --- /dev/null +++ b/test/integrations/destinations/intercom_v2/network.ts @@ -0,0 +1,751 @@ +import { headers, headersWithRevokedAccessToken } from './common'; + +const deliveryCallsData = [ + { + httpReq: { + method: 'post', + url: 'https://api.intercom.io/contacts/search', + data: { + query: { + operator: 'AND', + value: [{ field: 'email', operator: '=', value: 'test@rudderlabs.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.intercom.io/companies', + data: { + company_id: 'rudderlabs', + name: 'RudderStack', + website: 'www.rudderstack.com', + plan: 'enterprise', + size: 500, + industry: 'CDP', + remote_created_at: 1726132233, + }, + headers, + }, + httpRes: { + status: 200, + data: { + type: 'company', + company_id: 'rudderlabs', + id: 'company-id-by-intercom', + name: 'RudderStack', + website: 'www.rudderstack.com', + plan: 'enterprise', + size: 500, + industry: 'CDP', + created_at: 1701930212, + updated_at: 1701930212, + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.intercom.io/contacts/search', + data: { + query: { + operator: 'AND', + value: [{ field: 'email', operator: '=', value: 'known-email@rudderlabs.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.intercom.io/tags', + data: { + name: 'tag-1', + companies: [ + { + id: 'company-id-by-intercom', + }, + ], + }, + headers, + }, + httpRes: { + status: 200, + data: { + type: 'tag', + name: 'tag-1', + id: '123', + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.intercom.io/tags', + data: { + name: 'tag-2', + companies: [ + { + id: 'company-id-by-intercom', + }, + ], + }, + headers, + }, + httpRes: { + status: 200, + data: { + type: 'tag', + name: 'tag-2', + id: '123', + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.intercom.io/companies', + data: { + company_id: 'rudderlabs', + name: 'RudderStack', + website: 'www.rudderstack.com', + plan: 'enterprise', + size: 500, + industry: 'CDP', + remote_created_at: 1726132233, + custom_attributes: { + isOpenSource: true, + }, + }, + headers, + }, + httpRes: { + status: 400, + data: { + type: 'error.list', + request_id: 'request_id-1', + errors: [ + { + code: 'parameter_invalid', + message: "Custom attribute 'isOpenSource' does not exist", + }, + ], + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.eu.intercom.io/contacts/search', + data: { + query: { + operator: 'AND', + value: [{ field: 'email', operator: '=', value: 'test@rudderlabs.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: 'userId', 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', + }, + ], + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.au.intercom.io/companies', + data: { + company_id: 'rudderlabs', + name: 'RudderStack', + website: 'www.rudderstack.com', + plan: 'enterprise', + size: 500, + industry: 'CDP', + remote_created_at: 1726132233, + }, + headers, + }, + httpRes: { + status: 200, + data: { + type: 'company', + company_id: 'rudderlabs', + id: 'au-company-id-by-intercom', + name: 'RudderStack', + website: 'www.rudderstack.com', + plan: 'enterprise', + size: 500, + industry: 'CDP', + created_at: 1701930212, + updated_at: 1701930212, + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.au.intercom.io/contacts/search', + data: { + query: { + operator: 'AND', + value: [{ field: 'email', operator: '=', value: 'known-email@rudderlabs.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: 'au-contact-id-by-intercom-known-email', + workspace_id: 'rudderWorkspace', + external_id: 'known-user-id-1-au', + role: 'user', + email: 'known-email@rudderlabs.com', + }, + ], + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.au.intercom.io/contacts/au-contact-id-by-intercom-known-email/companies', + data: { + id: 'au-company-id-by-intercom', + }, + headers, + }, + httpRes: { + status: 200, + data: { + type: 'company', + company_id: 'rudderlabs', + id: 'company-id-by-intercom', + name: 'RudderStack', + website: 'www.rudderstack.com', + plan: 'enterprise', + size: 500, + industry: 'CDP', + user_count: 1, + remote_created_at: 1374138000, + created_at: 1701930212, + updated_at: 1701930212, + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.intercom.io/contacts/search', + data: { + query: { + operator: 'AND', + value: [{ field: 'email', operator: '=', value: 'detach-user-company@rudderlabs.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: 'contact-id-by-intercom-detached-from-company', + workspace_id: 'rudderWorkspace', + external_id: 'detach-company-user-id', + role: 'user', + email: 'detach-user-company@rudderlabs.com', + }, + ], + }, + }, + }, + { + httpReq: { + method: 'get', + url: 'https://api.intercom.io/companies?company_id=company id', + data: {}, + headers, + }, + httpRes: { + status: 200, + data: { + id: '123', + }, + }, + }, + { + httpReq: { + method: 'delete', + url: 'https://api.intercom.io/contacts/contact-id-by-intercom-detached-from-company/companies/123', + data: {}, + headers, + }, + httpRes: { + status: 200, + data: {}, + }, + }, + { + httpReq: { + method: 'get', + url: 'https://api.intercom.io/companies?company_id=unavailable company id', + data: {}, + headers, + }, + httpRes: { + status: 404, + data: { + type: 'error.list', + request_id: 'req123', + errors: [ + { + code: 'company_not_found', + message: 'Company Not Found', + }, + ], + }, + }, + }, + { + httpReq: { + method: 'get', + url: 'https://api.intercom.io/companies?company_id=other company id', + data: {}, + headers, + }, + httpRes: { + status: 200, + data: { + id: 'other123', + }, + }, + }, + { + httpReq: { + method: 'delete', + url: 'https://api.intercom.io/contacts/contact-id-by-intercom-detached-from-company/companies/other123', + data: {}, + headers, + }, + httpRes: { + status: 404, + data: { + type: 'error.list', + request_id: 'req123', + errors: [ + { + code: 'company_not_found', + message: 'Company Not Found', + }, + ], + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.intercom.io/events', + data: { + created_at: 1700628164, + email: 'test@rudderlabs.com', + event_name: 'Product Viewed', + metadata: { + price: { + amount: 3000, + currency: 'USD', + }, + revenue: { + amount: 1232, + currency: 'inr', + test: 123, + }, + }, + user_id: 'user-id-1', + }, + headers: headersWithRevokedAccessToken, + }, + httpRes: { + status: 401, + data: { + type: 'error.list', + request_id: 'request_id-1', + errors: [ + { + code: 'unauthorized', + message: 'Access Token Invalid', + }, + ], + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.intercom.io/events', + data: { + created_at: 1700628164, + email: 'test@rudderlabs.com', + event_name: 'Product Viewed', + metadata: { + price: { + amount: 3000, + currency: 'USD', + }, + revenue: { + amount: 1232, + currency: 'inr', + test: 123, + }, + }, + user_id: 'user-id-1', + }, + headers, + }, + httpRes: { + status: 202, + }, + }, + { + httpReq: { + method: 'put', + url: 'https://api.intercom.io/contacts/proxy-contact-id', + data: { + custom_attributes: { + isOpenSource: true, + }, + }, + headers, + }, + httpRes: { + status: 400, + data: { + request_id: 'request_1', + type: 'error.list', + errors: [ + { + code: 'parameter_invalid', + message: "Custom attribute 'isOpenSource' does not exist", + }, + ], + }, + }, + }, + { + httpReq: { + method: 'put', + url: 'https://api.intercom.io/contacts/proxy-contact-id', + data: { + email: 'new@test.com', + }, + headers, + }, + httpRes: { + status: 429, + data: { + errors: [ + { + code: 'rate_limit_exceeded', + message: 'The rate limit for the App has been exceeded', + }, + ], + request_id: 'request125', + type: 'error.list', + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.intercom.io/contacts', + data: { + email: 'conflict@test.com', + user_id: 'conflict_test_user_id_1', + }, + headers, + }, + httpRes: { + status: 409, + data: { + errors: [ + { + code: 'conflict', + message: 'A contact matching those details already exists with id=test', + }, + ], + request_id: 'request126', + type: 'error.list', + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.intercom.io/contacts', + data: { + email: 'test-unsupported-media@rudderlabs.com', + external_id: 'user-id-1', + name: 'John Snow', + }, + headers: { + ...headers, + Accept: 'test', + 'Content-Type': 'test', + }, + }, + httpRes: { + status: 406, + data: { + errors: [ + { + code: 'media_type_not_acceptable', + message: 'The Accept header should send a media type of application/json', + }, + ], + type: 'error.list', + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.intercom.io/contacts', + data: { + email: 'time-out@gmail.com', + }, + headers, + }, + httpRes: { + status: 408, + data: { + type: 'error.list', + request_id: 'req-123', + errors: [ + { + code: 'Request Timeout', + message: 'The server would not wait any longer for the client', + }, + ], + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.intercom.io/contacts/search', + data: { + query: { + operator: 'AND', + value: [{ field: 'email', operator: '=', value: 'test@rudderlabs.com' }], + }, + }, + headers: headersWithRevokedAccessToken, + }, + httpRes: { + status: 401, + data: { + type: 'error.list', + request_id: 'request_id-1', + errors: [ + { + code: 'unauthorized', + message: 'Access Token Invalid', + }, + ], + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.au.intercom.io/contacts/search', + data: { + query: { + operator: 'AND', + value: [{ field: 'email', operator: '=', value: 'known-user-2-company@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: 'au-contact-id-by-intercom-known-user-2-company', + workspace_id: 'rudderWorkspace', + external_id: 'known-user-id-2-au', + role: 'user', + email: 'known-user-2-company@gmail.com', + }, + ], + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.au.intercom.io/contacts/au-contact-id-by-intercom-known-user-2-company/companies', + data: { + id: 'au-company-id-by-intercom', + }, + headers, + }, + httpRes: { + status: 404, + data: { + type: 'error.list', + request_id: 'req-1234', + errors: [ + { + code: 'company_not_found', + message: 'Company Not Found', + }, + ], + }, + }, + }, + { + httpReq: { + method: 'post', + url: 'https://api.intercom.io/tags', + data: { + name: 'tag-3', + companies: [ + { + id: 'company-id-by-intercom', + }, + ], + }, + headers, + }, + httpRes: { + status: 404, + data: { + type: 'error.list', + request_id: 'req-1234', + errors: [ + { + code: 'company_not_found', + message: 'Company Not Found', + }, + ], + }, + }, + }, +]; + +export const networkCallsData = [...deliveryCallsData]; diff --git a/test/integrations/destinations/intercom_v2/router/data.ts b/test/integrations/destinations/intercom_v2/router/data.ts new file mode 100644 index 0000000000..7656914059 --- /dev/null +++ b/test/integrations/destinations/intercom_v2/router/data.ts @@ -0,0 +1,883 @@ +import { RouterTransformationRequest } from '../../../../../src/types'; +import { generateMetadata } from '../../../testUtils'; +import { + anonymousId, + channel, + companyTraits, + destination, + destinationApiServerAU, + destinationApiServerEU, + detachUserCompanyUserTraits, + headers, + originalTimestamp, + properties, + RouterInstrumentationErrorStatTags, + RouterNetworkErrorStatTags, + timestamp, + userTraits, +} from '../common'; +import { RouterTestData } from '../../../testTypes'; + +const routerRequest1: RouterTransformationRequest = { + input: [ + { + destination, + message: { + userId: 'user-id-1', + channel, + context: { + traits: { + ...userTraits, + company: { + id: 'company id', + name: 'Test Company', + }, + }, + }, + type: 'identify', + integrations: { All: true }, + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(1), + }, + { + destination, + message: { + userId: 'user-id-1', + channel, + context: { + traits: userTraits, + }, + properties: properties, + event: 'Product Viewed', + type: 'track', + originalTimestamp, + timestamp, + integrations: { + All: true, + intercom: { + id: 'id-by-intercom', + }, + }, + }, + metadata: generateMetadata(2), + }, + { + destination, + message: { + groupId: 'rudderlabs', + channel, + traits: { + ...companyTraits, + tags: ['tag-1', 'tag-2'], + }, + type: 'group', + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(3), + }, + { + destination, + message: { + groupId: 'rudderlabs', + channel, + traits: { + ...companyTraits, + isOpenSource: true, + }, + type: 'group', + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(4), + }, + { + destination, + message: { + userId: 'user-id-1', + channel, + context: { + traits: { + ...userTraits, + }, + }, + type: 'identify', + integrations: { All: true }, + originalTimestamp, + timestamp, + }, + metadata: { + ...generateMetadata(5), + secret: { + accessToken: 'revoked-accessToken', + }, + }, + }, + { + destination, + message: { + groupId: 'rudderlabs', + channel, + traits: { + ...companyTraits, + tags: ['tag-3'], + }, + type: 'group', + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(6), + }, + ], + destType: 'intercom_v2', +}; + +// eu server and send anonymous id true +const routerRequest2: RouterTransformationRequest = { + input: [ + { + destination: destinationApiServerEU, + message: { + anonymousId, + channel, + context: { + traits: userTraits, + }, + type: 'identify', + integrations: { All: true }, + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(1), + }, + { + destination: destinationApiServerEU, + message: { + anonymousId, + channel, + context: { + traits: userTraits, + }, + properties: properties, + event: 'Product Viewed', + type: 'track', + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(2), + }, + ], + destType: 'intercom_v2', +}; + +// au server and when contact found in intercom +const routerRequest3: RouterTransformationRequest = { + input: [ + { + destination: destinationApiServerAU, + message: { + userId: 'known-user-id-1', + channel, + context: { + traits: userTraits, + }, + type: 'identify', + integrations: { + All: true, + Intercom: { + lookup: 'userId', + }, + }, + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(1), + }, + { + destination: destinationApiServerAU, + message: { + groupId: 'rudderlabs', + channel, + traits: companyTraits, + type: 'group', + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(2), + }, + { + destination: destinationApiServerAU, + message: { + groupId: 'rudderlabs', + channel, + traits: { + ...companyTraits, + email: 'known-user-2-company@gmail.com', + }, + type: 'group', + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(3), + }, + ], + destType: 'intercom_v2', +}; + +// detach user and company +const routerRequest4: RouterTransformationRequest = { + input: [ + { + destination, + message: { + userId: 'detach-company-user-id', + channel, + context: { + traits: { + ...detachUserCompanyUserTraits, + company: { + id: 'company id', + name: 'Test Company', + remove: true, + }, + }, + }, + type: 'identify', + integrations: { All: true }, + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(1), + }, + { + destination: destination, + message: { + userId: 'detach-company-user-id', + channel, + context: { + traits: { + ...detachUserCompanyUserTraits, + company: { + id: 'unavailable company id', + name: 'Test Company', + remove: true, + }, + }, + }, + type: 'identify', + integrations: { All: true }, + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(2), + }, + { + destination: destination, + message: { + userId: 'detach-company-user-id', + channel, + context: { + traits: { + ...detachUserCompanyUserTraits, + company: { + id: 'other company id', + name: 'Test Company', + remove: true, + }, + }, + }, + type: 'identify', + integrations: { All: true }, + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(3), + }, + ], + destType: 'intercom_v2', +}; + +// validation +const routerRequest5: RouterTransformationRequest = { + input: [ + { + destination, + message: { + channel, + context: { + traits: { + ...userTraits, + email: null, + }, + }, + type: 'identify', + integrations: { All: true }, + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(1), + }, + { + destination, + message: { + channel, + context: { + traits: { + ...userTraits, + email: null, + }, + }, + event: 'Product Viewed', + type: 'track', + integrations: { All: true }, + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(2), + }, + { + destination, + message: { + channel, + context: { + traits: { + ...userTraits, + }, + }, + type: 'track', + integrations: { All: true }, + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(3), + }, + { + destination, + message: { + channel, + context: { + traits: { + ...companyTraits, + }, + }, + type: 'group', + integrations: { All: true }, + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(4), + }, + { + destination, + message: { + channel, + context: { + traits: { + ...companyTraits, + }, + }, + type: 'dummyGroupType', + integrations: { All: true }, + originalTimestamp, + timestamp, + }, + metadata: generateMetadata(5), + }, + ], + destType: 'intercom_v2', +}; + +export const data: RouterTestData[] = [ + { + id: 'INTERCOM-V2-router-test-1', + scenario: 'Framework', + successCriteria: + 'Some events should be transformed successfully and some should fail for apiVersion v2', + name: 'intercom_v2', + description: 'INTERCOM V2 router tests', + feature: 'router', + module: 'destination', + version: 'v0', + input: { + request: { + body: routerRequest1, + }, + }, + output: { + response: { + status: 200, + body: { + output: [ + { + batched: false, + batchedRequest: { + body: { + JSON: { + email: 'test@rudderlabs.com', + external_id: '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.intercom.io/contacts', + files: {}, + headers, + method: 'POST', + params: {}, + type: 'REST', + version: '1', + }, + destination: destination, + metadata: [generateMetadata(1)], + statusCode: 200, + }, + { + batched: false, + batchedRequest: { + body: { + FORM: {}, + JSON: { + created_at: 1700628164, + email: 'test@rudderlabs.com', + event_name: 'Product Viewed', + metadata: { + price: { + amount: 3000, + currency: 'USD', + }, + revenue: { + amount: 1232, + currency: 'inr', + test: 123, + }, + }, + user_id: 'user-id-1', + id: 'id-by-intercom', + }, + JSON_ARRAY: {}, + XML: {}, + }, + endpoint: 'https://api.intercom.io/events', + files: {}, + headers, + method: 'POST', + params: {}, + type: 'REST', + version: '1', + }, + destination: destination, + metadata: [generateMetadata(2)], + statusCode: 200, + }, + { + batched: false, + batchedRequest: { + body: { + JSON: { + company_id: 'rudderlabs', + name: 'RudderStack', + size: 500, + website: 'www.rudderstack.com', + industry: 'CDP', + plan: 'enterprise', + remote_created_at: 1726132233, + }, + XML: {}, + FORM: {}, + JSON_ARRAY: {}, + }, + endpoint: 'https://api.intercom.io/companies', + files: {}, + headers, + method: 'POST', + params: {}, + type: 'REST', + version: '1', + }, + destination: destination, + metadata: [generateMetadata(3)], + statusCode: 299, + }, + { + batched: false, + error: + 'Unable to Create or Update Company due to : {"type":"error.list","request_id":"request_id-1","errors":[{"code":"parameter_invalid","message":"Custom attribute \'isOpenSource\' does not exist"}]}', + statTags: { + ...RouterInstrumentationErrorStatTags, + }, + destination, + metadata: [generateMetadata(4)], + statusCode: 400, + }, + { + batched: false, + error: + '{"message":"Unable to search contact due to","destinationResponse":"{\\"type\\":\\"error.list\\",\\"request_id\\":\\"request_id-1\\",\\"errors\\":[{\\"code\\":\\"unauthorized\\",\\"message\\":\\"Access Token Invalid\\"}]}"}', + statTags: { + ...RouterNetworkErrorStatTags, + errorType: 'retryable', + meta: 'invalidAuthToken', + }, + destination, + metadata: [ + { + ...generateMetadata(5), + secret: { + accessToken: 'revoked-accessToken', + }, + }, + ], + statusCode: 400, + }, + { + batched: false, + error: + 'Unable to Add or Update the Tag to Company due to : {"type":"error.list","request_id":"req-1234","errors":[{"code":"company_not_found","message":"Company Not Found"}]}', + statTags: { + ...RouterInstrumentationErrorStatTags, + }, + destination, + metadata: [generateMetadata(6)], + statusCode: 400, + }, + ], + }, + }, + }, + }, + { + id: 'INTERCOM-V2-router-test-2', + scenario: 'Framework', + successCriteria: 'Events should be transformed successfully for apiVersion v2', + name: 'intercom_v2', + description: + 'INTERCOM V2 router tests with sendAnonymousId true for apiVersion v2 and eu apiServer', + feature: 'router', + module: 'destination', + version: 'v0', + input: { + request: { + body: routerRequest2, + }, + }, + output: { + response: { + status: 200, + body: { + output: [ + { + batchedRequest: { + version: '1', + type: 'REST', + method: 'POST', + endpoint: 'https://api.eu.intercom.io/contacts', + headers, + params: {}, + body: { + JSON: { + email: 'test@rudderlabs.com', + external_id: 'test-anonymous-id', + name: 'John Snow', + owner_id: 13, + phone: '+91 9999999999', + custom_attributes: { + address: 'california usa', + age: 23, + }, + }, + XML: {}, + JSON_ARRAY: {}, + FORM: {}, + }, + files: {}, + }, + metadata: [generateMetadata(1)], + batched: false, + statusCode: 200, + destination: destinationApiServerEU, + }, + { + batchedRequest: { + version: '1', + type: 'REST', + method: 'POST', + endpoint: 'https://api.eu.intercom.io/events', + headers, + params: {}, + body: { + JSON: { + created_at: 1700628164, + email: 'test@rudderlabs.com', + event_name: 'Product Viewed', + metadata: { + price: { + amount: 3000, + currency: 'USD', + }, + revenue: { + amount: 1232, + currency: 'inr', + test: 123, + }, + }, + user_id: 'test-anonymous-id', + }, + XML: {}, + JSON_ARRAY: {}, + FORM: {}, + }, + files: {}, + }, + metadata: [generateMetadata(2)], + batched: false, + statusCode: 200, + destination: destinationApiServerEU, + }, + ], + }, + }, + }, + }, + { + id: 'INTERCOM-V2-router-test-3', + scenario: 'Framework', + successCriteria: 'Events should be transformed successfully for apiVersion v2', + name: 'intercom_v2', + description: + 'INTERCOM V2 router tests when contact is found in intercom for au apiServer and apiVersion v2', + feature: 'router', + module: 'destination', + version: 'v0', + input: { + request: { + body: routerRequest3, + }, + }, + output: { + response: { + status: 200, + body: { + output: [ + { + 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(1)], + statusCode: 200, + }, + { + batched: false, + batchedRequest: { + body: { + JSON: { + id: 'au-company-id-by-intercom', + }, + XML: {}, + FORM: {}, + JSON_ARRAY: {}, + }, + endpoint: + 'https://api.au.intercom.io/contacts/au-contact-id-by-intercom-known-email/companies', + files: {}, + headers, + method: 'POST', + params: {}, + type: 'REST', + version: '1', + }, + destination: destinationApiServerAU, + metadata: [generateMetadata(2)], + statusCode: 299, + }, + { + batched: false, + error: + 'Unable to attach Contact or User to Company due to : {"type":"error.list","request_id":"req-1234","errors":[{"code":"company_not_found","message":"Company Not Found"}]}', + statTags: { + ...RouterInstrumentationErrorStatTags, + }, + destination: destinationApiServerAU, + metadata: [generateMetadata(3)], + statusCode: 400, + }, + ], + }, + }, + }, + }, + { + id: 'INTERCOM-V2-router-test-4', + scenario: 'Framework', + successCriteria: + 'Some identify events should be transformed successfully and some should fail for apiVersion v2', + name: 'intercom', + description: + 'INTERCOM V2 router tests for detaching contact from company in intercom for apiVersion v2', + feature: 'router', + module: 'destination', + version: 'v0', + input: { + request: { + body: routerRequest4, + }, + }, + output: { + response: { + status: 200, + body: { + output: [ + { + batched: false, + batchedRequest: { + body: { + JSON: { + email: 'detach-user-company@rudderlabs.com', + external_id: 'detach-company-user-id', + name: 'John Snow', + owner_id: 13, + phone: '+91 9999999999', + custom_attributes: { + address: 'california usa', + age: 23, + }, + }, + XML: {}, + FORM: {}, + JSON_ARRAY: {}, + }, + endpoint: + 'https://api.intercom.io/contacts/contact-id-by-intercom-detached-from-company', + files: {}, + headers, + method: 'PUT', + params: {}, + type: 'REST', + version: '1', + }, + destination: destination, + metadata: [generateMetadata(1)], + statusCode: 200, + }, + { + batched: false, + error: + 'Unable to get company id due to : {"type":"error.list","request_id":"req123","errors":[{"code":"company_not_found","message":"Company Not Found"}]}', + statTags: RouterInstrumentationErrorStatTags, + destination: destination, + metadata: [generateMetadata(2)], + statusCode: 400, + }, + { + batched: false, + error: + 'Unable to detach contact and company due to : {"type":"error.list","request_id":"req123","errors":[{"code":"company_not_found","message":"Company Not Found"}]}', + statTags: RouterInstrumentationErrorStatTags, + destination: destination, + metadata: [generateMetadata(3)], + statusCode: 400, + }, + ], + }, + }, + }, + }, + { + id: 'INTERCOM-V2-router-test-5', + scenario: 'Framework', + successCriteria: 'validation should pass for apiVersion v2', + name: 'intercom_v2', + description: 'INTERCOM V2 router validation tests', + feature: 'router', + module: 'destination', + version: 'v0', + input: { + request: { + body: routerRequest5, + }, + }, + output: { + response: { + status: 200, + body: { + output: [ + { + batched: false, + error: 'Either email or userId is required for Identify call', + statTags: RouterInstrumentationErrorStatTags, + destination, + metadata: [generateMetadata(1)], + statusCode: 400, + }, + { + batched: false, + error: 'Either email or userId or id is required for Track call', + statTags: RouterInstrumentationErrorStatTags, + destination, + metadata: [generateMetadata(2)], + statusCode: 400, + }, + { + batched: false, + error: 'Missing required value from "event"', + statTags: RouterInstrumentationErrorStatTags, + destination, + metadata: [generateMetadata(3)], + statusCode: 400, + }, + { + batched: false, + error: 'Missing required value from "groupId"', + statTags: RouterInstrumentationErrorStatTags, + destination, + metadata: [generateMetadata(4)], + statusCode: 400, + }, + { + batched: false, + error: 'message type dummygrouptype is not supported.', + statTags: RouterInstrumentationErrorStatTags, + destination, + metadata: [generateMetadata(5)], + statusCode: 400, + }, + ], + }, + }, + }, + }, +]; From f3046f0ae37c113c1239d988d056fc204f2776a0 Mon Sep 17 00:00:00 2001 From: shrouti1507 <60211312+shrouti1507@users.noreply.github.com> Date: Tue, 8 Oct 2024 17:41:12 +0530 Subject: [PATCH 4/9] fix: rakuten amount value rounded up to nearest integer (#3784) * fix: rakuten amount value rounded up to nearest integer * fix: review comments addressed --- src/cdk/v2/destinations/rakuten/utils.js | 22 ++++--- src/cdk/v2/destinations/rakuten/utils.test.js | 62 ++++++++++++++++++- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/src/cdk/v2/destinations/rakuten/utils.js b/src/cdk/v2/destinations/rakuten/utils.js index 2dd628a250..b4897c46ed 100644 --- a/src/cdk/v2/destinations/rakuten/utils.js +++ b/src/cdk/v2/destinations/rakuten/utils.js @@ -17,6 +17,18 @@ const constructProperties = (message) => { return payload; }; +/** + * Calculates the amount for a single product + * @param {Object} product + * @returns {number} + */ +const calculateProductAmount = (product) => { + if (!product?.amount && !product?.price) { + throw new InstrumentationError('Either amount or price is required for every product'); + } + return Math.round(product.amount * 100 || (product.quantity || 1) * 100 * product.price); +}; + /** * This fucntion build the item level list * @param {*} properties @@ -52,14 +64,8 @@ const constructLineItems = (properties) => { }); // Map 'amountList' by evaluating 'amount' or deriving it from 'price' and 'quantity' - const amountList = products.map((product) => { - if (!product?.amount && !product?.price) { - throw new InstrumentationError('Either amount or price is required for every product'); - } - return product.amount * 100 || (product.quantity || 1) * 100 * product.price; - }); - productList.amtlist = amountList.join('|'); + productList.amtlist = products.map(calculateProductAmount).join('|'); return productList; }; -module.exports = { constructProperties, constructLineItems }; +module.exports = { constructProperties, constructLineItems, calculateProductAmount }; diff --git a/src/cdk/v2/destinations/rakuten/utils.test.js b/src/cdk/v2/destinations/rakuten/utils.test.js index 9cc7f5fd4c..2d82037b1c 100644 --- a/src/cdk/v2/destinations/rakuten/utils.test.js +++ b/src/cdk/v2/destinations/rakuten/utils.test.js @@ -1,4 +1,5 @@ -const { constructLineItems } = require('./utils'); +const { InstrumentationError } = require('@rudderstack/integrations-lib'); +const { constructLineItems, calculateProductAmount } = require('./utils'); describe('constructLineItems', () => { it('should return a non-empty object when given a valid properties object with at least one product', () => { const properties = { @@ -115,3 +116,62 @@ describe('constructLineItems', () => { ); }); }); + +describe('calculateProductAmount', () => { + // Calculates product amount correctly when amount is defined + it('should return the correct product amount when amount is defined', () => { + const product = { amount: 5 }; + const result = calculateProductAmount(product); + expect(result).toBe(500); + }); + + // Throws error when both amount and price are undefined or null + it('should throw an error when both amount and price are undefined or null', () => { + const product = {}; + expect(() => calculateProductAmount(product)).toThrow(InstrumentationError); + }); + + // Calculates product amount correctly when price and quantity are defined + it('should calculate product amount correctly when price and quantity are defined', () => { + const product = { amount: 10, price: 5, quantity: 2 }; + const result = calculateProductAmount(product); + expect(result).toEqual(1000); + }); + + // Returns correct value when only price is defined and quantity defaults to 1 + it('should return correct value when only price is defined and quantity defaults to 1', () => { + const product = { price: 20 }; + const result = calculateProductAmount(product); + expect(result).toEqual(2000); + }); + + // Handles cases where amount is a floating-point number + it('should handle cases where amount is a floating-point number', () => { + const product = { amount: 5.5, price: 10, quantity: 2 }; + const result = calculateProductAmount(product); + expect(result).toEqual(550); + }); + + it('should handle cases where amount is a floating-point number', () => { + const product = { amount: 5.1, price: 10, quantity: 2 }; + const result = calculateProductAmount(product); + expect(result).toEqual(510); + }); + + it('should handle cases where amount is a floating-point number', () => { + const product = { amount: 5.19, price: 10, quantity: 2 }; + const result = calculateProductAmount(product); + expect(result).toEqual(519); + }); + + it('should handle cases where amount is a floating-point number', () => { + const product = { amount: 5.199, price: 10, quantity: 2 }; + const result = calculateProductAmount(product); + expect(result).toEqual(520); + }); + it('should handle cases where amount is a floating-point number', () => { + const product = { amount: 5.479, price: 10, quantity: 2 }; + const result = calculateProductAmount(product); + expect(result).toEqual(548); + }); +}); From 6d9976c640b98d74492fde587058fb1fcbdb8314 Mon Sep 17 00:00:00 2001 From: shrouti1507 <60211312+shrouti1507@users.noreply.github.com> Date: Tue, 8 Oct 2024 19:00:46 +0530 Subject: [PATCH 5/9] chore: adding salesforce oauth sandbox support for closed testing (#3778) * chore: adding salesforce oauth sandbox support for closed testing * chore: adding test log * fix: throw error for empty secret * fix: processor last test-case * chore: add logical changes for passing test-cases * fix: adding dynamic destination name for rETL connection * fix: adding test case * fix: remove log --------- Co-authored-by: Sai Sankeerth --- src/constants/destinationCanonicalNames.js | 1 + src/features.json | 1 + src/v0/destinations/salesforce/config.js | 2 + src/v0/destinations/salesforce/transform.js | 3 +- src/v0/destinations/salesforce/utils.js | 25 ++- .../networkHandler.js | 34 ++++ .../destinations/salesforce/processor/data.ts | 116 +++++++++++- .../dataDelivery/data.ts | 3 + .../dataDelivery/oauth.ts | 174 ++++++++++++++++++ .../salesforce_oauth_sandbox/network.ts | 51 +++++ 10 files changed, 399 insertions(+), 11 deletions(-) create mode 100644 src/v0/destinations/salesforce_oauth_sandbox/networkHandler.js create mode 100644 test/integrations/destinations/salesforce_oauth_sandbox/dataDelivery/data.ts create mode 100644 test/integrations/destinations/salesforce_oauth_sandbox/dataDelivery/oauth.ts create mode 100644 test/integrations/destinations/salesforce_oauth_sandbox/network.ts diff --git a/src/constants/destinationCanonicalNames.js b/src/constants/destinationCanonicalNames.js index f99c735e45..e9b7dc136b 100644 --- a/src/constants/destinationCanonicalNames.js +++ b/src/constants/destinationCanonicalNames.js @@ -1,6 +1,7 @@ const DestHandlerMap = { ga360: 'ga', salesforce_oauth: 'salesforce', + salesforce_oauth_sandbox: 'salesforce', }; const DestCanonicalNames = { diff --git a/src/features.json b/src/features.json index 4c7fc7b231..f95bd09d7e 100644 --- a/src/features.json +++ b/src/features.json @@ -26,6 +26,7 @@ "PROFITWELL": true, "SALESFORCE": true, "SALESFORCE_OAUTH": true, + "SALESFORCE_OAUTH_SANDBOX": true, "SFMC": true, "SNAPCHAT_CONVERSION": true, "TIKTOK_ADS": true, diff --git a/src/v0/destinations/salesforce/config.js b/src/v0/destinations/salesforce/config.js index 1425bad51b..f2e8072755 100644 --- a/src/v0/destinations/salesforce/config.js +++ b/src/v0/destinations/salesforce/config.js @@ -24,6 +24,7 @@ const SF_TOKEN_REQUEST_URL = 'https://login.salesforce.com/services/oauth2/token const SF_TOKEN_REQUEST_URL_SANDBOX = 'https://test.salesforce.com/services/oauth2/token'; const DESTINATION = 'Salesforce'; +const SALESFORCE_OAUTH_SANDBOX = 'salesforce_oauth_sandbox'; const OAUTH = 'oauth'; const LEGACY = 'legacy'; @@ -41,4 +42,5 @@ module.exports = { DESTINATION, OAUTH, LEGACY, + SALESFORCE_OAUTH_SANDBOX, }; diff --git a/src/v0/destinations/salesforce/transform.js b/src/v0/destinations/salesforce/transform.js index 1dde1ec65a..7e66dd8810 100644 --- a/src/v0/destinations/salesforce/transform.js +++ b/src/v0/destinations/salesforce/transform.js @@ -293,6 +293,7 @@ async function processIdentify( authorizationData, authorizationFlow, ) { + const { Name } = destination.DestinationDefinition; const mapProperty = destination.Config.mapProperty === undefined ? true : destination.Config.mapProperty; // check the traits before hand @@ -304,7 +305,7 @@ async function processIdentify( // Append external ID to traits if event is mapped to destination and only if identifier type is not id // If identifier type is id, then it should not be added to traits, else saleforce will throw an error const mappedToDestination = get(message, MappedToDestinationKey); - const externalId = getDestinationExternalIDObjectForRetl(message, 'SALESFORCE'); + const externalId = getDestinationExternalIDObjectForRetl(message, Name); if (mappedToDestination && externalId?.identifierType?.toLowerCase() !== 'id') { addExternalIdToTraits(message); } diff --git a/src/v0/destinations/salesforce/utils.js b/src/v0/destinations/salesforce/utils.js index 9a4effc502..a7731f07de 100644 --- a/src/v0/destinations/salesforce/utils.js +++ b/src/v0/destinations/salesforce/utils.js @@ -1,4 +1,9 @@ -const { RetryableError, ThrottledError, AbortedError } = require('@rudderstack/integrations-lib'); +const { + RetryableError, + ThrottledError, + AbortedError, + OAuthSecretError, +} = require('@rudderstack/integrations-lib'); const { handleHttpRequest } = require('../../../adapters/network'); const { isHttpStatusSuccess, @@ -13,6 +18,7 @@ const { DESTINATION, LEGACY, OAUTH, + SALESFORCE_OAUTH_SANDBOX, } = require('./config'); const ACCESS_TOKEN_CACHE = new Cache(ACCESS_TOKEN_CACHE_TTL); @@ -104,10 +110,15 @@ const salesforceResponseHandler = (destResponse, sourceMessage, authKey, authori * @param {destination: Record, metadata: Record} * @returns */ -const getAccessTokenOauth = (metadata) => ({ - token: metadata.secret?.access_token, - instanceUrl: metadata.secret?.instance_url, -}); +const getAccessTokenOauth = (metadata) => { + if (!isDefinedAndNotNull(metadata?.secret)) { + throw new OAuthSecretError('secret is undefined/null'); + } + return { + token: metadata.secret?.access_token, + instanceUrl: metadata.secret?.instance_url, + }; +}; const getAccessToken = async ({ destination, metadata }) => { const accessTokenKey = destination.ID; @@ -169,7 +180,9 @@ const getAccessToken = async ({ destination, metadata }) => { const collectAuthorizationInfo = async (event) => { let authorizationFlow; let authorizationData; - if (isDefinedAndNotNull(event.metadata?.secret)) { + const { Name } = event.destination.DestinationDefinition; + const lowerCaseName = Name?.toLowerCase?.(); + if (isDefinedAndNotNull(event?.metadata?.secret) || lowerCaseName === SALESFORCE_OAUTH_SANDBOX) { authorizationFlow = OAUTH; authorizationData = getAccessTokenOauth(event.metadata); } else { diff --git a/src/v0/destinations/salesforce_oauth_sandbox/networkHandler.js b/src/v0/destinations/salesforce_oauth_sandbox/networkHandler.js new file mode 100644 index 0000000000..b6cbed77f9 --- /dev/null +++ b/src/v0/destinations/salesforce_oauth_sandbox/networkHandler.js @@ -0,0 +1,34 @@ +const { proxyRequest, prepareProxyRequest } = require('../../../adapters/network'); +const { processAxiosResponse } = require('../../../adapters/utils/networkUtils'); +const { OAUTH } = require('../salesforce/config'); +const { salesforceResponseHandler } = require('../salesforce/utils'); + +const responseHandler = (responseParams) => { + const { destinationResponse, destType, rudderJobMetadata } = responseParams; + const message = `Request for destination: ${destType} Processed Successfully`; + + salesforceResponseHandler( + destinationResponse, + 'during Salesforce Response Handling', + rudderJobMetadata?.destInfo?.authKey, + OAUTH, + ); + + // else successfully return status as 200, message and original destination response + return { + status: 200, + message, + destinationResponse, + }; +}; + +function networkHandler() { + this.responseHandler = responseHandler; + this.proxy = proxyRequest; + this.prepareProxy = prepareProxyRequest; + this.processAxiosResponse = processAxiosResponse; +} + +module.exports = { + networkHandler, +}; diff --git a/test/integrations/destinations/salesforce/processor/data.ts b/test/integrations/destinations/salesforce/processor/data.ts index 7a44b6b8ed..b33b75b55b 100644 --- a/test/integrations/destinations/salesforce/processor/data.ts +++ b/test/integrations/destinations/salesforce/processor/data.ts @@ -1373,7 +1373,7 @@ export const data = [ }, { name: 'salesforce', - description: 'Test 10', + description: 'Test 11', feature: 'processor', module: 'destination', version: 'v0', @@ -1389,9 +1389,9 @@ export const data = [ sandbox: true, }, DestinationDefinition: { - DisplayName: 'Salesforce', + DisplayName: 'Salesforce Sandbox', ID: '1T96GHZ0YZ1qQSLULHCoJkow9KC', - Name: 'SALESFORCE', + Name: 'SALESFORCE_OAUTH_SANDBOX', }, Enabled: true, ID: '1ut7LcVW1QC56y2EoTNo7ZwBWSY', @@ -1412,7 +1412,7 @@ export const data = [ externalId: [ { id: 'a005g0000383kmUAAQ', - type: 'SALESFORCE-custom_object__c', + type: 'SALESFORCE_OAUTH_SANDBOX-custom_object__c', identifierType: 'Id', }, ], @@ -1499,4 +1499,112 @@ export const data = [ }, }, }, + { + name: 'salesforce', + description: 'Test 12 : Retry happens when no secret information is found', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + destination: { + Config: { + initialAccessToken: '7fiy1FKcO9sohsxq1v6J88sg', + password: 'dummyPassword2', + userName: 'test.c97-qvpd@force.com.test', + sandbox: true, + }, + DestinationDefinition: { + DisplayName: 'Salesforce Sandbox', + ID: '1T96GHZ0YZ1qQSLULHCoJkow9KC', + Name: 'SALESFORCE_OAUTH_SANDBOX', + }, + Enabled: true, + ID: '1ut7LcVW1QC56y2EoTNo7ZwBWSY', + Name: 'Test SF', + Transformations: [], + }, + metadata: { + jobId: 1, + }, + message: { + anonymousId: '1e7673da-9473-49c6-97f7-da848ecafa76', + channel: 'web', + context: { + mappedToDestination: true, + externalId: [ + { + id: 'a005g0000383kmUAAQ', + type: 'SALESFORCE_OAUTH_SANDBOX-custom_object__c', + identifierType: 'Id', + }, + ], + app: { + build: '1.0.0', + name: 'RudderLabs JavaScript SDK', + namespace: 'com.rudderlabs.javascript', + version: '1.0.0', + }, + ip: '0.0.0.0', + library: { + name: 'RudderLabs JavaScript SDK', + version: '1.0.0', + }, + locale: 'en-US', + os: { + name: '', + version: '', + }, + screen: { + density: 2, + }, + traits: { + email: 'john@rs.com', + firstname: 'john doe', + Id: 'some-id', + }, + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36', + }, + integrations: { + All: true, + }, + messageId: 'f19c35da-e9de-4c6e-b6e5-9e60cccc12c8', + originalTimestamp: '2020-01-27T12:20:55.301Z', + receivedAt: '2020-01-27T17:50:58.657+05:30', + request_ip: '14.98.244.60', + sentAt: '2020-01-27T12:20:56.849Z', + timestamp: '2020-01-27T17:50:57.109+05:30', + type: 'identify', + userId: '1e7673da-9473-49c6-97f7-da848ecafa76', + }, + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + statusCode: 500, + error: 'secret is undefined/null', + metadata: { + jobId: 1, + }, + statTags: { + errorCategory: 'platform', + errorType: 'oAuthSecret', + destType: 'SALESFORCE', + module: 'destination', + implementation: 'native', + feature: 'processor', + }, + }, + ], + }, + }, + }, ]; diff --git a/test/integrations/destinations/salesforce_oauth_sandbox/dataDelivery/data.ts b/test/integrations/destinations/salesforce_oauth_sandbox/dataDelivery/data.ts new file mode 100644 index 0000000000..bed8eec8db --- /dev/null +++ b/test/integrations/destinations/salesforce_oauth_sandbox/dataDelivery/data.ts @@ -0,0 +1,3 @@ +import { testScenariosForV1API } from './oauth'; + +export const data = [...testScenariosForV1API]; diff --git a/test/integrations/destinations/salesforce_oauth_sandbox/dataDelivery/oauth.ts b/test/integrations/destinations/salesforce_oauth_sandbox/dataDelivery/oauth.ts new file mode 100644 index 0000000000..30ee516e72 --- /dev/null +++ b/test/integrations/destinations/salesforce_oauth_sandbox/dataDelivery/oauth.ts @@ -0,0 +1,174 @@ +import { ProxyMetdata } from '../../../../../src/types'; +import { ProxyV1TestData } from '../../../testTypes'; +import { generateProxyV1Payload } from '../../../testUtils'; + +const commonHeadersForWrongToken = { + Authorization: 'Bearer expiredAccessToken', + 'Content-Type': 'application/json', +}; + +const commonHeadersForRightToken = { + Authorization: 'Bearer correctAccessToken', + 'Content-Type': 'application/json', +}; +const params = { destination: 'salesforce_oauth_sandbox' }; + +const users = [ + { + Email: 'danis.archurav@sbermarket.ru', + Company: 'itus.ru', + LastName: 'Danis', + FirstName: 'Archurav', + LeadSource: 'App Signup', + account_type__c: 'free_trial', + }, +]; + +const statTags = { + retryable: { + destType: 'SALESFORCE_OAUTH_SANDBOX', + destinationId: 'dummyDestinationId', + errorCategory: 'network', + errorType: 'retryable', + feature: 'dataDelivery', + implementation: 'native', + module: 'destination', + workspaceId: 'dummyWorkspaceId', + }, +}; + +const commonRequestParametersWithWrongToken = { + headers: commonHeadersForWrongToken, + JSON: users[0], + params, +}; + +const commonRequestParametersWithRightToken = { + headers: commonHeadersForRightToken, + JSON: users[0], + params, +}; + +export const proxyMetdataWithSecretWithWrongAccessToken: ProxyMetdata = { + jobId: 1, + attemptNum: 1, + userId: 'dummyUserId', + sourceId: 'dummySourceId', + destinationId: 'dummyDestinationId', + workspaceId: 'dummyWorkspaceId', + secret: { + access_token: 'expiredAccessToken', + instanceUrl: 'https://rudderstack.my.salesforce_oauth_sandbox.com', + }, + destInfo: { authKey: 'dummyDestinationId' }, + dontBatch: false, +}; + +export const proxyMetdataWithSecretWithRightAccessToken: ProxyMetdata = { + jobId: 1, + attemptNum: 1, + userId: 'dummyUserId', + sourceId: 'dummySourceId', + destinationId: 'dummyDestinationId', + workspaceId: 'dummyWorkspaceId', + secret: { + access_token: 'expiredRightToken', + instanceUrl: 'https://rudderstack.my.salesforce_oauth_sandbox.com', + }, + destInfo: { authKey: 'dummyDestinationId' }, + dontBatch: false, +}; + +export const reqMetadataArrayWithWrongSecret = [proxyMetdataWithSecretWithWrongAccessToken]; +export const reqMetadataArray = [proxyMetdataWithSecretWithRightAccessToken]; + +export const testScenariosForV1API: ProxyV1TestData[] = [ + { + id: 'salesforce_v1_scenario_1', + name: 'salesforce_oauth_sandbox', + description: '[Proxy v1 API] :: Test with expired access token scenario', + successCriteria: + 'Should return 5XX with error category REFRESH_TOKEN and Session expired or invalid, INVALID_SESSION_ID', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v1', + input: { + request: { + body: generateProxyV1Payload( + { + ...commonRequestParametersWithWrongToken, + endpoint: + 'https://rudderstack.my.salesforce_oauth_sandbox.com/services/data/v50.0/sobjects/Lead/20', + }, + reqMetadataArrayWithWrongSecret, + ), + method: 'POST', + }, + }, + output: { + response: { + status: 500, + body: { + output: { + status: 500, + authErrorCategory: 'REFRESH_TOKEN', + message: + 'Salesforce Request Failed - due to "INVALID_SESSION_ID", (Retryable) during Salesforce Response Handling', + response: [ + { + error: + '[{"message":"Session expired or invalid","errorCode":"INVALID_SESSION_ID"}]', + metadata: proxyMetdataWithSecretWithWrongAccessToken, + statusCode: 500, + }, + ], + statTags: statTags.retryable, + }, + }, + }, + }, + }, + { + id: 'salesforce_v1_scenario_2', + name: 'salesforce', + description: + '[Proxy v1 API] :: Test for a valid request - Lead creation with existing unchanged leadId and unchanged data', + successCriteria: 'Should return 200 with no error with destination response', + scenario: 'Business', + feature: 'dataDelivery', + module: 'destination', + version: 'v1', + input: { + request: { + body: generateProxyV1Payload( + { + ...commonRequestParametersWithRightToken, + endpoint: + 'https://rudderstack.my.salesforce.com/services/data/v50.0/sobjects/Lead/existing_unchanged_leadId', + }, + reqMetadataArray, + ), + method: 'POST', + }, + }, + output: { + response: { + status: 200, + body: { + output: { + status: 200, + message: 'Request for destination: salesforce Processed Successfully', + response: [ + { + error: '{"statusText":"No Content"}', + metadata: proxyMetdataWithSecretWithRightAccessToken, + statusCode: 200, + }, + ], + }, + }, + }, + }, + }, +]; diff --git a/test/integrations/destinations/salesforce_oauth_sandbox/network.ts b/test/integrations/destinations/salesforce_oauth_sandbox/network.ts new file mode 100644 index 0000000000..09d2c759d2 --- /dev/null +++ b/test/integrations/destinations/salesforce_oauth_sandbox/network.ts @@ -0,0 +1,51 @@ +const headerWithWrongAccessToken = { + Authorization: 'Bearer expiredAccessToken', + 'Content-Type': 'application/json', +}; + +const headerWithRightAccessToken = { + Authorization: 'Bearer correctAccessToken', + 'Content-Type': 'application/json', +}; + +const dataValue = { + Email: 'danis.archurav@sbermarket.ru', + Company: 'itus.ru', + LastName: 'Danis', + FirstName: 'Archurav', + LeadSource: 'App Signup', + account_type__c: 'free_trial', +}; + +const businessMockData = [ + { + description: 'Mock response from destination depicting an expired access token', + httpReq: { + method: 'post', + url: 'https://rudderstack.my.salesforce_oauth_sandbox.com/services/data/v50.0/sobjects/Lead/20', + headers: headerWithWrongAccessToken, + data: dataValue, + params: { destination: 'salesforce_oauth_sandbox' }, + }, + httpRes: { + data: [{ message: 'Session expired or invalid', errorCode: 'INVALID_SESSION_ID' }], + status: 401, + }, + }, + { + description: + 'Mock response from destination depicting a valid lead request, with no changed data', + httpReq: { + method: 'post', + url: 'https://rudderstack.my.salesforce.com/services/data/v50.0/sobjects/Lead/existing_unchanged_leadId', + data: dataValue, + headers: headerWithRightAccessToken, + }, + httpRes: { + data: { statusText: 'No Content' }, + status: 204, + }, + }, +]; + +export const networkCallsData = [...businessMockData]; From 5ac81860c51f9971343df8c61bfd0b2de8161735 Mon Sep 17 00:00:00 2001 From: Anant Jain <62471433+anantjain45823@users.noreply.github.com> Date: Tue, 8 Oct 2024 20:20:06 +0530 Subject: [PATCH 6/9] feat: onboard Amazon Audience (#3727) --- package-lock.json | 6 + package.json | 1 + src/features.json | 1 + src/v0/destinations/amazon_audience/config.js | 5 + .../amazon_audience/networkHandler.js | 139 ++++++++++ .../destinations/amazon_audience/transform.js | 58 +++++ src/v0/destinations/amazon_audience/utils.js | 175 +++++++++++++ .../destinations/amazon_audience/common.ts | 30 +++ .../amazon_audience/dataDelivery/data.ts | 197 ++++++++++++++ .../destinations/amazon_audience/network.ts | 123 +++++++++ .../amazon_audience/processor/data.ts | 246 ++++++++++++++++++ .../amazon_audience/router/data.ts | 226 ++++++++++++++++ 12 files changed, 1207 insertions(+) create mode 100644 src/v0/destinations/amazon_audience/config.js create mode 100644 src/v0/destinations/amazon_audience/networkHandler.js create mode 100644 src/v0/destinations/amazon_audience/transform.js create mode 100644 src/v0/destinations/amazon_audience/utils.js create mode 100644 test/integrations/destinations/amazon_audience/common.ts create mode 100644 test/integrations/destinations/amazon_audience/dataDelivery/data.ts create mode 100644 test/integrations/destinations/amazon_audience/network.ts create mode 100644 test/integrations/destinations/amazon_audience/processor/data.ts create mode 100644 test/integrations/destinations/amazon_audience/router/data.ts diff --git a/package-lock.json b/package-lock.json index 243b989a6d..b8f677520f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "ajv": "^8.12.0", "ajv-draft-04": "^1.0.0", "ajv-formats": "^2.1.1", + "amazon-dsp-formatter": "^1.0.2", "axios": "^1.7.3", "btoa": "^1.2.1", "component-each": "^0.2.6", @@ -8197,6 +8198,11 @@ } } }, + "node_modules/amazon-dsp-formatter": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/amazon-dsp-formatter/-/amazon-dsp-formatter-1.0.2.tgz", + "integrity": "sha512-CfsssMzLFh0IK6oz3j38ENGgp5LZ/q21YX4yXSavfI50CU2cJbupKOk+Bgg0sY67V0lWsYsmYrpkEI2aFG/duA==" + }, "node_modules/ansi-align": { "version": "3.0.1", "dev": true, diff --git a/package.json b/package.json index 8a104f3081..7aedc782ba 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "ajv": "^8.12.0", "ajv-draft-04": "^1.0.0", "ajv-formats": "^2.1.1", + "amazon-dsp-formatter": "^1.0.2", "axios": "^1.7.3", "btoa": "^1.2.1", "component-each": "^0.2.6", diff --git a/src/features.json b/src/features.json index f95bd09d7e..63862eefed 100644 --- a/src/features.json +++ b/src/features.json @@ -82,6 +82,7 @@ "BLOOMREACH_CATALOG": true, "SMARTLY": true, "HTTP": true, + "AMAZON_AUDIENCE": true, "INTERCOM_V2": true }, "regulations": [ diff --git a/src/v0/destinations/amazon_audience/config.js b/src/v0/destinations/amazon_audience/config.js new file mode 100644 index 0000000000..f377bceaae --- /dev/null +++ b/src/v0/destinations/amazon_audience/config.js @@ -0,0 +1,5 @@ +const CREATE_USERS_URL = 'https://advertising-api.amazon.com/dp/records/hashed/'; +const ASSOCIATE_USERS_URL = 'https://advertising-api.amazon.com/v2/dp/audience'; +const MAX_PAYLOAD_SIZE_IN_BYTES = 4000000; +const DESTINATION = 'amazon_audience'; +module.exports = { CREATE_USERS_URL, MAX_PAYLOAD_SIZE_IN_BYTES, ASSOCIATE_USERS_URL, DESTINATION }; diff --git a/src/v0/destinations/amazon_audience/networkHandler.js b/src/v0/destinations/amazon_audience/networkHandler.js new file mode 100644 index 0000000000..c1fbeeccea --- /dev/null +++ b/src/v0/destinations/amazon_audience/networkHandler.js @@ -0,0 +1,139 @@ +const { + NetworkError, + ThrottledError, + AbortedError, + RetryableError, +} = require('@rudderstack/integrations-lib'); +const { prepareProxyRequest, handleHttpRequest } = require('../../../adapters/network'); +const { isHttpStatusSuccess } = require('../../util/index'); +const { + processAxiosResponse, + getDynamicErrorType, +} = require('../../../adapters/utils/networkUtils'); +const { REFRESH_TOKEN } = require('../../../adapters/networkhandler/authConstants'); +const { DESTINATION, CREATE_USERS_URL, ASSOCIATE_USERS_URL } = require('./config'); +const { TAG_NAMES } = require('../../util/tags'); + +const amazonAudienceRespHandler = (destResponse, stageMsg) => { + const { status, response } = destResponse; + + // to handle the case when authorization-token is invalid + // docs for error codes: https://advertising.amazon.com/API/docs/en-us/reference/concepts/errors#tag/Audiences/operation/dspCreateAudiencesPost + if (status === 401 && response.message === 'Unauthorized') { + // 401 takes place in case of authorization isue meaning token is epxired or access is not enough. + // Since acces is configured from dashboard only refresh token makes sense + throw new NetworkError( + `${response?.message} ${stageMsg}`, + status, + { + [TAG_NAMES.ERROR_TYPE]: getDynamicErrorType(status), + }, + response, + REFRESH_TOKEN, + ); + } else if (status === 429) { + throw new ThrottledError( + `Request Failed: ${stageMsg} - due to Request Limit exceeded, (Throttled)`, + destResponse, + ); + } else if (status === 504 || status === 502 || status === 500) { + // see if its 5xx internal error + throw new RetryableError(`Request Failed: ${stageMsg} (Retryable)`, 500, destResponse); + } + // else throw the error + throw new AbortedError( + `Request Failed: ${stageMsg} with status "${status}" due to "${JSON.stringify( + response, + )}", (Aborted) `, + 400, + destResponse, + ); +}; + +const responseHandler = (responseParams) => { + const { destinationResponse } = responseParams; + const message = `[${DESTINATION} Response Handler] - Request Processed Successfully`; + const { status } = destinationResponse; + + if (!isHttpStatusSuccess(status)) { + // if error, successfully return status, message and original destination response + amazonAudienceRespHandler( + destinationResponse, + 'during amazon_audience response transformation', + ); + } + return { + status, + message, + destinationResponse, + }; +}; + +const makeRequest = async (url, data, headers, metadata, method, args) => { + const { httpResponse } = await handleHttpRequest(method, url, data, { headers }, args); + return httpResponse; +}; + +const amazonAudienceProxyRequest = async (request) => { + const { body, metadata } = request; + const { headers } = request; + const { createUsers, associateUsers } = body.JSON; + + // step 1: Create users + const firstResponse = await makeRequest( + CREATE_USERS_URL, + createUsers, + headers, + metadata, + 'post', + { + destType: 'amazon_audience', + feature: 'proxy', + requestMethod: 'POST', + module: 'dataDelivery', + endpointPath: '/records/hashed', + metadata, + }, + ); + // validate response success + if (!firstResponse.success && !isHttpStatusSuccess(firstResponse?.response?.status)) { + amazonAudienceRespHandler( + { + response: firstResponse.response?.response?.data || firstResponse, + status: firstResponse.response?.response?.status || firstResponse, + }, + 'during creating users', + ); + } + // we are returning above in case of failure because if first step is not executed then there is no sense of executing second step + // step2: Associate Users to Audience Id + const secondResponse = await makeRequest( + ASSOCIATE_USERS_URL, + associateUsers, + headers, + metadata, + 'patch', + { + destType: 'amazon_audience', + feature: 'proxy', + requestMethod: 'PATCH', + module: 'dataDelivery', + endpointPath: '/v2/dp/audience', + metadata, + }, + ); + return secondResponse; +}; +// eslint-disable-next-line @typescript-eslint/naming-convention +class networkHandler { + constructor() { + this.responseHandler = responseHandler; + this.proxy = amazonAudienceProxyRequest; + this.prepareProxy = prepareProxyRequest; + this.processAxiosResponse = processAxiosResponse; + } +} + +module.exports = { + networkHandler, +}; diff --git a/src/v0/destinations/amazon_audience/transform.js b/src/v0/destinations/amazon_audience/transform.js new file mode 100644 index 0000000000..834810de54 --- /dev/null +++ b/src/v0/destinations/amazon_audience/transform.js @@ -0,0 +1,58 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +const { InstrumentationError } = require('@rudderstack/integrations-lib'); +const { handleRtTfSingleEventError } = require('../../util'); +const { batchEvents, buildResponseWithUsers, getUserDetails } = require('./utils'); +/** + * This function returns the user traits list required in request for + * making a call to create a group of users in amazon_audience + * @param {*} record + * @param {*} destination + * @param {*} metadata + */ +const processRecord = (record, config) => { + const { fields, action, type } = record; + if (type !== 'record') { + throw new InstrumentationError(`[AMAZON AUDIENCE]: ${type} is not supported`); + } + return { user: getUserDetails(fields, config), action: action !== 'delete' ? 'add' : 'remove' }; +}; + +// This function is used to process a single record +const process = (event) => { + const { message, destination, metadata } = event; + const { Config } = destination; + const { user, action } = processRecord(message, Config); + return buildResponseWithUsers([user], action, Config, [metadata.jobId], metadata.secret); +}; +// This function is used to process multiple records +const processRouterDest = async (inputs, reqMetadata) => { + const responseList = []; // list containing all successful responses + const errorRespList = []; // list of error + const { destination } = inputs[0]; + const { Config } = destination; + inputs.map(async (event) => { + try { + if (event.message.statusCode) { + // already transformed event + responseList.push(event); + } else { + // if not transformed + responseList.push({ + message: processRecord(event.message, Config), + metadata: event.metadata, + destination, + }); + } + } catch (error) { + const errRespEvent = handleRtTfSingleEventError(event, error, reqMetadata); + errorRespList.push(errRespEvent); + } + }); + let batchedResponseList = []; + if (responseList.length > 0) { + batchedResponseList = batchEvents(responseList, destination); + } + return [...batchedResponseList, ...errorRespList]; +}; + +module.exports = { process, processRouterDest }; diff --git a/src/v0/destinations/amazon_audience/utils.js b/src/v0/destinations/amazon_audience/utils.js new file mode 100644 index 0000000000..c25f301378 --- /dev/null +++ b/src/v0/destinations/amazon_audience/utils.js @@ -0,0 +1,175 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +const sha256 = require('sha256'); +const AmazonAdsFormatter = require('amazon-dsp-formatter'); +const lodash = require('lodash'); +const { ConfigurationError, OAuthSecretError } = require('@rudderstack/integrations-lib'); +const { + defaultRequestConfig, + defaultPostRequestConfig, + getSuccessRespEvents, + removeUndefinedAndNullAndEmptyValues, +} = require('../../util'); + +const buildResponseWithUsers = (users, action, config, jobIdList, secret) => { + const { audienceId } = config; + if (!audienceId) { + throw new ConfigurationError('[AMAZON AUDIENCE]: Audience Id not found'); + } + if (!secret?.accessToken) { + throw new OAuthSecretError('OAuth - access token not found'); + } + if (!secret?.clientId) { + throw new OAuthSecretError('OAuth - Client Id not found'); + } + const externalId = `Rudderstack_${sha256(`${jobIdList}`)}`; + const response = defaultRequestConfig(); + response.endpoint = ''; + response.method = defaultPostRequestConfig.requestMethod; + response.headers = { + 'Amazon-Advertising-API-ClientId': `${secret.clientId}`, + 'Content-Type': 'application/json', + Authorization: `Bearer ${secret.accessToken}`, + }; + response.body.JSON = { + createUsers: { + records: [ + { + hashedRecords: users, + externalId, + }, + ], + }, + associateUsers: { + patches: [ + { + op: action, + path: `/EXTERNAL_USER_ID-${externalId}/audiences`, + value: [audienceId], + }, + ], + }, + }; + return response; +}; + +/** + * This function groups the response list based upon `operation` + * @param {*} respList + * @returns object + */ +const groupResponsesUsingOperation = (respList) => { + const eventGroups = lodash.groupBy(respList, (item) => item.message.action); + return eventGroups; +}; + +/** + * Input: [{ + message: { + users: {} + action + }, + }, + metadata, + destination, +}] + * @param {*} responseList + */ +const batchEvents = (responseList, destination) => { + const { secret } = responseList[0].metadata; + const eventGroups = groupResponsesUsingOperation(responseList); + const respList = []; + const opList = ['remove', 'add']; + opList.forEach((op) => { + if (eventGroups?.[op]) { + const { userList, jobIdList, metadataList } = eventGroups[op].reduce( + (acc, event) => ({ + userList: acc.userList.concat(event.message.user), + jobIdList: acc.jobIdList.concat(event.metadata.jobId), + metadataList: acc.metadataList.concat(event.metadata), + }), + { userList: [], metadataList: [], jobIdList: [] }, + ); + respList.push( + getSuccessRespEvents( + buildResponseWithUsers( + userList, + op, + destination.config || destination.Config, + jobIdList, + secret, + ), + metadataList, + destination, + true, + ), + ); + } + }); + return respList; +}; + +/** + * This function fetches the user details and + * hash them after normalizing if enable hash is turned on in config + * @param {*} fields + * @param {*} config + * @returns + */ +const getUserDetails = (fields, config) => { + const { enableHash } = config; + const { + email, + phone: phone_number, + firstName, + lastName, + address, + country, + city, + state, + postalCode, + } = fields; + if (!enableHash) { + return removeUndefinedAndNullAndEmptyValues({ + email, + phone: phone_number, + firstName, + lastName, + address, + country, + city, + state, + postalCode, + }); + } + // Since all fields are optional hence notusing formatRecord function from formatter but doing it for every parameter + const formatter = new AmazonAdsFormatter(); + const user = { + email: sha256(formatter.formatEmail(email)), + firstName: sha256(formatter.formatName(firstName)), + lastName: sha256(formatter.formatName(lastName)), + city: sha256(formatter.formatCity(city)), + state: sha256(formatter.formatState(state, country)), + postalCode: sha256(formatter.formatPostal(postalCode)), + }; + // formating guidelines https://advertising.amazon.com/help/GCCXMZYCK4RXWS6C + if (country) { + const country_code = formatter.formatCountry(country); + user.country = sha256(country_code); + if (phone_number) { + user.phone = sha256(formatter.formatPhone(phone_number, country_code)); + } + } + if (address) { + const formatted_address = + address + ?.normalize('NFD') + ?.replace(/[\u0300-\u036f]/g, '') + ?.trim() + ?.toLowerCase() + .replace(/[^\dA-Za-z]/g, '') || undefined; + user.address = sha256(formatter.formatAddress(formatted_address, country)); + } + return removeUndefinedAndNullAndEmptyValues(user); +}; + +module.exports = { batchEvents, getUserDetails, buildResponseWithUsers }; diff --git a/test/integrations/destinations/amazon_audience/common.ts b/test/integrations/destinations/amazon_audience/common.ts new file mode 100644 index 0000000000..728bdf1d25 --- /dev/null +++ b/test/integrations/destinations/amazon_audience/common.ts @@ -0,0 +1,30 @@ +export const destination = { + DestinationDefinition: { + Config: { + excludeKeys: [], + includeKeys: [], + }, + }, + Config: { + advertiserId: '{"Dummy Name":"1234"}', + audienceId: 'dummyId', + }, + ID: 'amazonAud-1234', +}; + +export const generateMetadata = (jobId: number, userId?: string): any => { + return { + jobId, + attemptNum: 1, + userId: userId || 'default-userId', + sourceId: 'default-sourceId', + destinationId: 'default-destinationId', + workspaceId: 'default-workspaceId', + dontBatch: false, + secret: { + accessToken: 'dummyAccessToken', + refreshToken: 'dummyRefreshToken', + clientId: 'dummyClientId', + }, + }; +}; diff --git a/test/integrations/destinations/amazon_audience/dataDelivery/data.ts b/test/integrations/destinations/amazon_audience/dataDelivery/data.ts new file mode 100644 index 0000000000..c78a53c899 --- /dev/null +++ b/test/integrations/destinations/amazon_audience/dataDelivery/data.ts @@ -0,0 +1,197 @@ +import { generateMetadata, generateProxyV0Payload } from '../../../testUtils'; + +const commonStatTags = { + destType: 'AMAZON_AUDIENCE', + errorCategory: 'network', + destinationId: 'default-destinationId', + workspaceId: 'default-workspaceId', + errorType: 'aborted', + feature: 'dataDelivery', + implementation: 'native', + module: 'destination', +}; +export const data = [ + { + name: 'amazon_audience', + id: 'Test 0', + description: 'Successfull Delivery case', + successCriteria: 'It should be passed with 200 Ok with no errors', + feature: 'dataDelivery', + module: 'destination', + version: 'v0', + input: { + request: { + body: generateProxyV0Payload({ + headers: { + Authorization: 'Bearer success_access_token', + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + endpoint: '', + JSON: { + associateUsers: { + patches: [ + { + op: 'remove', + path: '/EXTERNAL_USER_ID-Rudderstack_c73bcaadd94985269eeafd457c9f395135874dad5536cf1f6d75c132f602a14c/audiences', + value: ['dummyId'], + }, + ], + }, + createUsers: { + records: [ + { + externalId: + 'Rudderstack_c73bcaadd94985269eeafd457c9f395135874dad5536cf1f6d75c132f602a14c', + hashedRecords: [ + { + email: 'email4@abc.com', + }, + ], + }, + ], + }, + }, + }), + }, + }, + output: { + response: { + status: 200, + body: { + output: { + status: 200, + message: '[amazon_audience Response Handler] - Request Processed Successfully', + destinationResponse: { + response: { + requestId: 'dummy request id', + jobId: 'dummy job id', + }, + status: 200, + }, + }, + }, + }, + }, + }, + { + name: 'amazon_audience', + id: 'Test 1', + description: 'Unsuccessfull Delivery case for step 2', + successCriteria: 'It should be passed with 500 Internal with error with invalid payload', + feature: 'dataDelivery', + module: 'destination', + version: 'v0', + input: { + request: { + body: generateProxyV0Payload({ + headers: { + Authorization: 'Bearer success_access_token', + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + endpoint: '', + JSON: { + associateUsers: { + patches: [ + { + op: 'add', + path: '/EXTERNAL_USER_ID-Fail_Case/audiences', + value: ['dummyId'], + }, + ], + }, + createUsers: { + records: [ + { + externalId: + 'Rudderstack_c73bcaadd94985269eeafd457c9f395135874dad5536cf1f6d75c132f602a14c', + hashedRecords: [ + { + email: 'email4@abc.com', + }, + ], + }, + ], + }, + }, + }), + }, + }, + output: { + response: { + status: 500, + body: { + output: { + status: 500, + destinationResponse: { + response: { + code: 'Internal Error', + }, + status: 500, + }, + message: 'Request Failed: during amazon_audience response transformation (Retryable)', + statTags: { + destType: 'AMAZON_AUDIENCE', + errorCategory: 'network', + destinationId: 'default-destinationId', + workspaceId: 'default-workspaceId', + errorType: 'retryable', + feature: 'dataDelivery', + implementation: 'native', + module: 'destination', + }, + }, + }, + }, + }, + }, + { + name: 'amazon_audience', + id: 'Test 2 - Oauth Refresh Token', + description: 'Unsuccessfull Access Error for step 1', + successCriteria: 'It should be passed with 401 Unauthorized error', + feature: 'dataDelivery', + module: 'destination', + version: 'v0', + input: { + request: { + body: generateProxyV0Payload({ + headers: { + Authorization: 'Bearer fail_token', + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + endpoint: '', + JSON: { + associateUsers: [], + createUsers: { + records: [ + { + externalId: 'access token expired fail case', + hashedRecords: [], + }, + ], + }, + }, + }), + }, + }, + output: { + response: { + status: 401, + body: { + output: { + status: 401, + destinationResponse: { + message: 'Unauthorized', + }, + authErrorCategory: 'REFRESH_TOKEN', + message: 'Unauthorized during creating users', + statTags: commonStatTags, + }, + }, + }, + }, + }, +]; diff --git a/test/integrations/destinations/amazon_audience/network.ts b/test/integrations/destinations/amazon_audience/network.ts new file mode 100644 index 0000000000..b0941712c6 --- /dev/null +++ b/test/integrations/destinations/amazon_audience/network.ts @@ -0,0 +1,123 @@ +const headers = { + 'Amazon-Advertising-API-ClientId': 'dummyClientId', + 'Content-Type': 'application/json', + Authorization: 'Bearer success_access_token', +}; +export const networkCallsData = [ + { + description: 'successful step 1', + httpReq: { + url: 'https://advertising-api.amazon.com/dp/records/hashed/', + data: { + records: [ + { + externalId: + 'Rudderstack_c73bcaadd94985269eeafd457c9f395135874dad5536cf1f6d75c132f602a14c', + hashedRecords: [ + { + email: 'email4@abc.com', + }, + ], + }, + ], + }, + params: {}, + headers: { + Authorization: 'Bearer success_access_token', + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + method: 'post', + }, + httpRes: { + data: { + requestId: 'dummy request id', + }, + status: 200, + }, + }, + { + description: 'successful step 2', + httpReq: { + url: 'https://advertising-api.amazon.com/v2/dp/audience', + data: { + patches: [ + { + op: 'remove', + path: '/EXTERNAL_USER_ID-Rudderstack_c73bcaadd94985269eeafd457c9f395135874dad5536cf1f6d75c132f602a14c/audiences', + value: ['dummyId'], + }, + ], + }, + params: {}, + headers: { + Authorization: 'Bearer success_access_token', + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + method: 'patch', + }, + httpRes: { + data: { + requestId: 'dummy request id', + jobId: 'dummy job id', + }, + status: 200, + }, + }, + { + description: 'unsuccessful step 2', + httpReq: { + url: 'https://advertising-api.amazon.com/v2/dp/audience', + data: { + patches: [ + { + op: 'add', + path: '/EXTERNAL_USER_ID-Fail_Case/audiences', + value: ['dummyId'], + }, + ], + }, + params: {}, + headers: { + Authorization: 'Bearer success_access_token', + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + method: 'patch', + }, + httpRes: { + data: { + code: 'Internal Error', + }, + status: 500, + }, + }, + { + description: 'unsuccessful step 1', + httpReq: { + url: 'https://advertising-api.amazon.com/dp/records/hashed/', + data: { + records: [ + { + externalId: 'access token expired fail case', + hashedRecords: [], + }, + ], + }, + params: {}, + headers: { + Authorization: 'Bearer fail_token', + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + method: 'post', + }, + httpRes: { + data: { + message: 'Unauthorized', + }, + status: 401, + }, + }, +]; diff --git a/test/integrations/destinations/amazon_audience/processor/data.ts b/test/integrations/destinations/amazon_audience/processor/data.ts new file mode 100644 index 0000000000..49931ed92b --- /dev/null +++ b/test/integrations/destinations/amazon_audience/processor/data.ts @@ -0,0 +1,246 @@ +import { destination, generateMetadata } from '../common'; +const sha256 = require('sha256'); + +const fields = { + email: 'abc@xyz.com', + phone: '9876543323', + firstName: 'test', + lastName: 'user', + address: ' Été très chaud! ', +}; + +export const data = [ + { + name: 'amazon_audience', + id: 'Test 1', + description: 'All traits are present with hash enbaled for the audience with insert operation', + successCriteria: 'It should be passed with 200 Ok with all traits mapped after hashing', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + message: { + type: 'record', + action: 'insert', + fields: { + ...fields, + city: 'Edmonton', + state: 'alberta', + country: 'Canada', + postalCode: '12345', + }, + context: {}, + recordId: '1', + }, + destination: { ...destination, Config: { ...destination.Config, enableHash: true } }, + metadata: generateMetadata(1), + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + output: { + body: { + FORM: {}, + JSON_ARRAY: {}, + JSON: { + createUsers: { + records: [ + { + hashedRecords: [ + { + country: + '6959097001d10501ac7d54c0bdb8db61420f658f2922cc26e46d536119a31126', + address: + '7e68f87b9675dca9a6cbd0b3b715af6cd9e0b75b72b96feec98dd334d665a76c', + city: '5ae1b46bce91b626720727f9d8d1eb8998e5b6586b339b97c2288595fe25116a', + firstName: + '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + email: + 'ee278943de84e5d6243578ee1a1057bcce0e50daad9755f45dfa64b60b13bc5d', + lastName: + '04f8996da763b7a969b1028ee3007569eaf3a635486ddab211d512c85b9df8fb', + phone: + '3daf505bba309a952bb4bbd010d1d39e413e40c679ac3bbcee1ea9b009023ffa', + postalCode: + '6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b', + state: + 'fb8e20fc2e4c3f248c60c39bd652f3c1347298bb977b8b4d5903b85055620603', + }, + ], + externalId: + 'Rudderstack_6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b', + }, + ], + }, + associateUsers: { + patches: [ + { + op: 'add', + path: `/EXTERNAL_USER_ID-Rudderstack_6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b/audiences`, + value: ['dummyId'], + }, + ], + }, + }, + XML: {}, + }, + endpoint: '', + files: {}, + headers: { + 'Amazon-Advertising-API-ClientId': 'dummyClientId', + 'Content-Type': 'application/json', + Authorization: 'Bearer dummyAccessToken', + }, + method: 'POST', + params: {}, + type: 'REST', + userId: '', + version: '1', + }, + statusCode: 200, + metadata: generateMetadata(1), + }, + ], + }, + }, + }, + { + name: 'amazon_audience', + id: 'Test 2', + description: 'All traits are present with hash disabled for the audience with delete operation', + successCriteria: 'It should be passed with 200 Ok with all traits mapped without hashing', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + destination, + message: { + type: 'record', + action: 'delete', + fields, + channel: 'sources', + context: {}, + recordId: '1', + }, + metadata: generateMetadata(1), + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + output: { + version: '1', + type: 'REST', + method: 'POST', + endpoint: '', + headers: { + 'Amazon-Advertising-API-ClientId': 'dummyClientId', + 'Content-Type': 'application/json', + Authorization: 'Bearer dummyAccessToken', + }, + params: {}, + body: { + JSON: { + createUsers: { + records: [ + { + hashedRecords: [ + { + email: 'abc@xyz.com', + phone: '9876543323', + firstName: 'test', + lastName: 'user', + address: ' Été très chaud! ', + }, + ], + externalId: + 'Rudderstack_6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b', + }, + ], + }, + associateUsers: { + patches: [ + { + op: 'remove', + path: `/EXTERNAL_USER_ID-Rudderstack_6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b/audiences`, + value: ['dummyId'], + }, + ], + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + files: {}, + userId: '', + }, + metadata: generateMetadata(1), + statusCode: 200, + }, + ], + }, + }, + }, + { + name: 'amazon_audience', + id: 'Test 3', + description: 'Type Validation case', + successCriteria: 'It should be passed with 200 Ok giving validation error', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + destination, + message: { + type: 'identify', + context: {}, + recordId: '1', + }, + metadata: generateMetadata(1), + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + metadata: generateMetadata(1), + statusCode: 400, + error: '[AMAZON AUDIENCE]: identify is not supported', + statTags: { + errorCategory: 'dataValidation', + destinationId: 'default-destinationId', + errorType: 'instrumentation', + destType: 'AMAZON_AUDIENCE', + module: 'destination', + implementation: 'native', + workspaceId: 'default-workspaceId', + feature: 'processor', + }, + }, + ], + }, + }, + }, +]; diff --git a/test/integrations/destinations/amazon_audience/router/data.ts b/test/integrations/destinations/amazon_audience/router/data.ts new file mode 100644 index 0000000000..6787e03160 --- /dev/null +++ b/test/integrations/destinations/amazon_audience/router/data.ts @@ -0,0 +1,226 @@ +import { destination, generateMetadata } from '../common'; + +export const data = [ + { + name: 'amazon_audience', + id: 'router-test-1', + description: 'batching based upon action', + feature: 'router', + module: 'destination', + version: 'v0', + input: { + request: { + body: { + input: [ + { + destination, + message: { + type: 'record', + action: 'delete', + fields: { email: 'email4@abc.com' }, + channel: 'sources', + context: {}, + recordId: '4', + }, + metadata: generateMetadata(1), + }, + { + destination, + message: { + type: 'record', + action: 'delete', + fields: { + email: 'email5@abc.com', + }, + channel: 'sources', + context: {}, + recordId: '5', + }, + metadata: generateMetadata(2), + }, + { + destination, + message: { + type: 'record', + action: 'insert', + fields: { email: 'email3@abc.com' }, + channel: 'sources', + context: {}, + recordId: '3', + }, + metadata: generateMetadata(3), + }, + { + destination, + message: { + type: 'record', + action: 'update', + fields: { + email: 'email1@abc.com', + }, + channel: 'sources', + context: {}, + recordId: '1', + }, + metadata: generateMetadata(4), + }, + { + destination, + message: { + type: 'record', + action: 'insert', + fields: { email: 'email2@abc.com' }, + channel: 'sources', + context: {}, + recordId: '2', + }, + metadata: generateMetadata(5), + }, + { + destination, + message: { + type: 'identify', + context: {}, + recordId: '1', + }, + metadata: generateMetadata(6), + }, + ], + destType: 'amazon_audience', + }, + method: 'POST', + }, + }, + output: { + response: { + status: 200, + body: { + output: [ + { + batched: true, + batchedRequest: { + version: '1', + type: 'REST', + method: 'POST', + endpoint: '', + headers: { + 'Amazon-Advertising-API-ClientId': 'dummyClientId', + 'Content-Type': 'application/json', + Authorization: 'Bearer dummyAccessToken', + }, + params: {}, + body: { + JSON: { + associateUsers: { + patches: [ + { + op: 'remove', + path: '/EXTERNAL_USER_ID-Rudderstack_17f8af97ad4a7f7639a4c9171d5185cbafb85462877a4746c21bdb0a4f940ca0/audiences', + value: ['dummyId'], + }, + ], + }, + createUsers: { + records: [ + { + externalId: + 'Rudderstack_17f8af97ad4a7f7639a4c9171d5185cbafb85462877a4746c21bdb0a4f940ca0', + hashedRecords: [ + { + email: 'email4@abc.com', + }, + { + email: 'email5@abc.com', + }, + ], + }, + ], + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + files: {}, + }, + destination, + metadata: [generateMetadata(1), generateMetadata(2)], + statusCode: 200, + }, + { + batched: true, + batchedRequest: { + version: '1', + type: 'REST', + method: 'POST', + endpoint: '', + headers: { + 'Amazon-Advertising-API-ClientId': 'dummyClientId', + 'Content-Type': 'application/json', + Authorization: 'Bearer dummyAccessToken', + }, + params: {}, + body: { + JSON: { + associateUsers: { + patches: [ + { + op: 'add', + path: '/EXTERNAL_USER_ID-Rudderstack_a752d8ffaabe4c4d8a7a10cbdb2ee1525130a56a8290eef5d8a695434c49928f/audiences', + value: ['dummyId'], + }, + ], + }, + createUsers: { + records: [ + { + externalId: + 'Rudderstack_a752d8ffaabe4c4d8a7a10cbdb2ee1525130a56a8290eef5d8a695434c49928f', + hashedRecords: [ + { + email: 'email3@abc.com', + }, + { + email: 'email1@abc.com', + }, + { + email: 'email2@abc.com', + }, + ], + }, + ], + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + files: {}, + }, + destination, + metadata: [generateMetadata(3), generateMetadata(4), generateMetadata(5)], + statusCode: 200, + }, + { + metadata: [generateMetadata(6)], + destination, + batched: false, + statusCode: 400, + error: '[AMAZON AUDIENCE]: identify is not supported', + statTags: { + errorCategory: 'dataValidation', + destinationId: 'default-destinationId', + errorType: 'instrumentation', + destType: 'AMAZON_AUDIENCE', + module: 'destination', + implementation: 'native', + workspaceId: 'default-workspaceId', + feature: 'router', + }, + }, + ], + }, + }, + }, + }, +]; From f2231cb87f48767889be781f93ee8da5d4ef1f29 Mon Sep 17 00:00:00 2001 From: Utsab Chowdhury Date: Wed, 9 Oct 2024 12:17:11 +0530 Subject: [PATCH 7/9] chore: fix delete endpoint for braze (#3791) * chore: fix delete endpoint for braze * chore: add example inline --- src/v0/destinations/braze/deleteUsers.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/v0/destinations/braze/deleteUsers.js b/src/v0/destinations/braze/deleteUsers.js index 33c0f2ef7f..a83763e90d 100644 --- a/src/v0/destinations/braze/deleteUsers.js +++ b/src/v0/destinations/braze/deleteUsers.js @@ -21,11 +21,12 @@ const userDeletionHandler = async (userAttributes, config) => { } // Endpoints different for different data centers. // DOC: https://www.braze.com/docs/user_guide/administrative/access_braze/braze_instances/ + // Example Data Center: "EU-01", "US-01" let endPoint; const endpointPath = '/users/delete'; const dataCenterArr = dataCenter.trim().split('-'); if (dataCenterArr[0].toLowerCase() === 'eu') { - endPoint = 'https://rest.fra-01.braze.eu/users/delete'; + endPoint = `https://rest.fra-${dataCenterArr[1]}.braze.eu/users/delete`; } else { endPoint = `https://rest.iad-${dataCenterArr[1]}.braze.com/users/delete`; } From b357dd4e8a49ed66576f731a6aac84da55397475 Mon Sep 17 00:00:00 2001 From: Sudip Paul <67197965+ItsSudip@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:21:44 +0530 Subject: [PATCH 8/9] fix: add list of the props, which need to be placed at the root (#3777) * fix: add list of the props, which need to be placed at the root * chore: fix lint issue * chore: fix lint issue * chore: address comment * chore: address comment again --- src/v0/destinations/af/transform.js | 27 +++--- .../destinations/af/processor/data.ts | 92 +++++++++++++++++++ 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/src/v0/destinations/af/transform.js b/src/v0/destinations/af/transform.js index a611dcc249..064cf003a8 100644 --- a/src/v0/destinations/af/transform.js +++ b/src/v0/destinations/af/transform.js @@ -1,6 +1,6 @@ const get = require('get-value'); const set = require('set-value'); - +const lodash = require('lodash'); const { InstrumentationError, ConfigurationError } = require('@rudderstack/integrations-lib'); const { EventType } = require('../../../constants'); const { @@ -130,28 +130,29 @@ function getEventValueForUnIdentifiedTrackEvent(message) { function getEventValueMapFromMappingJson(message, mappingJson, isMultiSupport, config) { let eventValue = {}; - const { addPropertiesAtRoot, afCurrencyAtRoot } = config; - + const { addPropertiesAtRoot, afCurrencyAtRoot, listOfProps } = config; + const clonedProp = message.properties && lodash.cloneDeep(message.properties); if (addPropertiesAtRoot) { - eventValue = message.properties; + eventValue = clonedProp; } else { - set(eventValue, 'properties', message.properties); + if (Array.isArray(listOfProps) && listOfProps.length > 0) { + listOfProps.forEach((prop) => { + eventValue[prop.property] = clonedProp[prop.property]; + delete clonedProp[prop.property]; + }); + } + eventValue.properties = clonedProp; } const sourceKeys = Object.keys(mappingJson); sourceKeys.forEach((sourceKey) => { set(eventValue, mappingJson[sourceKey], get(message, sourceKey)); }); - if ( - isMultiSupport && - message.properties && - message.properties.products && - message.properties.products.length > 0 - ) { + if (isMultiSupport && clonedProp && clonedProp.products && clonedProp.products.length > 0) { const contentIds = []; const quantities = []; const prices = []; - message.properties.products.forEach((product) => { + clonedProp.products.forEach((product) => { contentIds.push(product.product_id); quantities.push(product.quantity); prices.push(product.price); @@ -164,7 +165,7 @@ function getEventValueMapFromMappingJson(message, mappingJson, isMultiSupport, c }; } if (afCurrencyAtRoot) { - eventValue.af_currency = message.properties.currency; + eventValue.af_currency = clonedProp.currency; } eventValue = removeUndefinedValues(eventValue); if (Object.keys(eventValue).length > 0) { diff --git a/test/integrations/destinations/af/processor/data.ts b/test/integrations/destinations/af/processor/data.ts index 9812f7f902..dacef38943 100644 --- a/test/integrations/destinations/af/processor/data.ts +++ b/test/integrations/destinations/af/processor/data.ts @@ -1716,6 +1716,98 @@ export const existingTestCases = [ }, }, }, + { + name: 'af', + description: 'Place selected properties at root track call with af data', + id: 'selectedPropsAtRoot', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + message: { + type: 'track', + event: 'Order Completed', + sentAt: '2020-08-14T05:30:30.118Z', + context: commonContextWithExternalId, + messageId: '7208bbb6-2c4e-45bb-bf5b-ad426f3593e9', + timestamp: '2020-08-14T05:30:30.118Z', + properties: { + ...commonPropertiesWithProduct, + prop1: 'value1', + prop2: 'value2', + Prop3: 'value3', + }, + anonymousId: '50be5c78-6c3f-4b60-be84-97805a316fb1', + integrations: { AF: { af_uid: 'afUid' } }, + }, + destination: { + Config: { + devKey: 'abcde', + androidAppId: 'com.rudderlabs.javascript', + groupTypeTrait: 'email', + groupValueTrait: 'age', + trackProductsOnce: false, + trackRevenuePerProduct: false, + addPropertiesAtRoot: false, + listOfProps: [ + { + property: 'prop1', + }, + { + property: 'prop2', + }, + { + property: 'prop3', + }, + { + property: 'prop4', + }, + ], + }, + }, + }, + ], + method: 'POST', + }, + }, + output: { + response: { + status: 200, + body: [ + { + output: { + version: '1', + type: 'REST', + method: 'POST', + endpoint: commonV1EndPoint, + headers: commonHeader, + params: {}, + body: { + JSON: { + bundleIdentifier: 'com.rudderlabs.javascript', + eventValue: + '{"prop1":"value1","prop2":"value2","properties":{"tax":2,"total":27.5,"coupon":"hasbros","revenue":48,"price":25,"quantity":2,"currency":"ZAR","discount":2.5,"order_id":"50314b8e9bcf000000000000","products":[{"sku":"45790-32","url":"https://www.example.com/product/path","name":"Monopoly: 3rd Edition","price":19,"category":"Games","quantity":1,"image_url":"https:///www.example.com/product/path.jpg","product_id":"507f1f77bcf86cd799439011"},{"sku":"46493-32","name":"Uno Card Game","price":3,"category":"Games","quantity":2,"product_id":"505bd76785ebb509fc183733"}],"shipping":3,"subtotal":22.5,"affiliation":"Google Store","checkout_id":"fksdjfsdjfisjf9sdfjsd9f","Prop3":"value3"},"af_revenue":48,"af_price":[19,3],"af_quantity":[1,2],"af_order_id":"50314b8e9bcf000000000000","af_content_id":["507f1f77bcf86cd799439011","505bd76785ebb509fc183733"]}', + eventName: 'Order Completed', + eventCurrency: 'ZAR', + eventTime: '2020-08-14T05:30:30.118Z', + appsflyer_id: 'afUid', + }, + XML: {}, + JSON_ARRAY: {}, + FORM: {}, + }, + files: {}, + userId: '', + }, + statusCode: 200, + }, + ], + }, + }, + }, ]; export const data = [...existingTestCases, ...newConfigValidationTests]; From 644e97ee53e4075bc9d10977698618f419c14fb0 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 9 Oct 2024 09:54:34 +0000 Subject: [PATCH 9/9] chore(release): 1.82.0 --- CHANGELOG.md | 15 +++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a79be1db38..7b832a7602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [1.82.0](https://github.com/rudderlabs/rudder-transformer/compare/v1.81.0...v1.82.0) (2024-10-09) + + +### Features + +* onboard Amazon Audience ([#3727](https://github.com/rudderlabs/rudder-transformer/issues/3727)) ([5ac8186](https://github.com/rudderlabs/rudder-transformer/commit/5ac81860c51f9971343df8c61bfd0b2de8161735)) +* onboard intercom v2 destination ([#3721](https://github.com/rudderlabs/rudder-transformer/issues/3721)) ([f8cde8c](https://github.com/rudderlabs/rudder-transformer/commit/f8cde8c072eb9415368fb97f53a3070027a3943b)) + + +### Bug Fixes + +* add list of the props, which need to be placed at the root ([#3777](https://github.com/rudderlabs/rudder-transformer/issues/3777)) ([b357dd4](https://github.com/rudderlabs/rudder-transformer/commit/b357dd4e8a49ed66576f731a6aac84da55397475)) +* rakuten amount value rounded up to nearest integer ([#3784](https://github.com/rudderlabs/rudder-transformer/issues/3784)) ([f3046f0](https://github.com/rudderlabs/rudder-transformer/commit/f3046f0ae37c113c1239d988d056fc204f2776a0)) +* webhook proc workflow object assign bug ([#3775](https://github.com/rudderlabs/rudder-transformer/issues/3775)) ([de8e503](https://github.com/rudderlabs/rudder-transformer/commit/de8e503524c1e8e3320f7458c66b8581f121b9bb)) + ## [1.81.0](https://github.com/rudderlabs/rudder-transformer/compare/v1.79.1...v1.81.0) (2024-10-04) diff --git a/package-lock.json b/package-lock.json index b8f677520f..7502bbf5bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rudder-transformer", - "version": "1.81.0", + "version": "1.82.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rudder-transformer", - "version": "1.81.0", + "version": "1.82.0", "license": "ISC", "dependencies": { "@amplitude/ua-parser-js": "0.7.24", diff --git a/package.json b/package.json index 7aedc782ba..ca39df7ff3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rudder-transformer", - "version": "1.81.0", + "version": "1.82.0", "description": "", "homepage": "https://github.com/rudderlabs/rudder-transformer#readme", "bugs": {