From aee6766c6319f4712c3bdf2dce1bf8d807bfdb3a Mon Sep 17 00:00:00 2001 From: Anant Jain Date: Sun, 11 Aug 2024 16:26:22 +0530 Subject: [PATCH 1/2] feat: klaviyo onboard unsubscribe profile support --- src/v0/destinations/klaviyo/batchUtil.js | 91 +- src/v0/destinations/klaviyo/batchUtil.test.js | 3 + src/v0/destinations/klaviyo/config.js | 5 + src/v0/destinations/klaviyo/transformV2.js | 62 +- src/v0/destinations/klaviyo/util.js | 50 +- .../klaviyo/processor/groupTestDataV2.ts | 82 +- .../klaviyo/processor/identifyTestDataV2.ts | 112 ++- .../destinations/klaviyo/router/dataV2.ts | 784 ++++++++++++++++++ 8 files changed, 1123 insertions(+), 66 deletions(-) diff --git a/src/v0/destinations/klaviyo/batchUtil.js b/src/v0/destinations/klaviyo/batchUtil.js index 3e99e03deb..0351bd2e2f 100644 --- a/src/v0/destinations/klaviyo/batchUtil.js +++ b/src/v0/destinations/klaviyo/batchUtil.js @@ -25,16 +25,17 @@ const groupSubscribeResponsesUsingListIdV2 = (subscribeResponseList) => { /** * This function takes susbscription as input and batches them into a single request body * @param {subscription} - * subscription= {listId, subscriptionProfileList} + * subscription= {listId, subscriptionProfileList, operation} + * subscription.operation could be either subscribe or unsubscribe */ const generateBatchedSubscriptionRequest = (subscription, destination) => { const subscriptionPayloadResponse = defaultRequestConfig(); // fetching listId from first event as listId is same for all the events const profiles = []; // list of profiles to be subscribed - const { listId, subscriptionProfileList } = subscription; + const { listId, subscriptionProfileList, operation } = subscription; subscriptionProfileList.forEach((profileList) => profiles.push(...profileList)); - subscriptionPayloadResponse.body.JSON = getSubscriptionPayload(listId, profiles); - subscriptionPayloadResponse.endpoint = `${BASE_ENDPOINT}/api/profile-subscription-bulk-create-jobs`; + subscriptionPayloadResponse.body.JSON = getSubscriptionPayload(listId, profiles, operation); + subscriptionPayloadResponse.endpoint = `${BASE_ENDPOINT}/api/${operation === 'subscribe' ? 'profile-subscription-bulk-create-jobs' : 'profile-subscription-bulk-delete-jobs'}`; subscriptionPayloadResponse.headers = { Authorization: `Klaviyo-API-Key ${destination.Config.privateApiKey}`, 'Content-Type': JSON_MIME_TYPE, @@ -90,12 +91,12 @@ const populateArrWithRespectiveProfileData = ( * ex: * profileSubscriptionAndMetadataArr = [ { - subscription: { subscriptionProfileList, listId1 }, + subscription: { subscriptionProfileList, listId1, operation }, metadataList1, profiles: [respectiveProfiles for above metadata] }, { - subscription: { subscriptionProfile List With No Profiles, listId2 }, + subscription: { subscriptionProfile List With No Profiles, listId2, operation }, metadataList2, }, { @@ -107,10 +108,7 @@ const populateArrWithRespectiveProfileData = ( * @param {*} destination * @returns */ -const buildRequestsForProfileSubscriptionAndMetadataArr = ( - profileSubscriptionAndMetadataArr, - destination, -) => { +const buildProfileAndSubscriptionRequests = (profileSubscriptionAndMetadataArr, destination) => { const finalResponseList = []; profileSubscriptionAndMetadataArr.forEach((profileSubscriptionData) => { const batchedRequest = []; @@ -118,7 +116,7 @@ const buildRequestsForProfileSubscriptionAndMetadataArr = ( if (profileSubscriptionData.profiles?.length > 0) { batchedRequest.push(...getProfileRequests(profileSubscriptionData.profiles, destination)); } - + // following condition ensures if no subscriptions are present we won't build subscription payload if (profileSubscriptionData.subscription?.subscriptionProfileList?.length > 0) { batchedRequest.push( generateBatchedSubscriptionRequest(profileSubscriptionData.subscription, destination), @@ -132,46 +130,88 @@ const buildRequestsForProfileSubscriptionAndMetadataArr = ( return finalResponseList; }; -const batchRequestV2 = (subscribeRespList, profileRespList, destination) => { - const subscribeEventGroups = groupSubscribeResponsesUsingListIdV2(subscribeRespList); - let profileSubscriptionAndMetadataArr = []; - const metaDataIndexMap = new Map(); +/** + * This function updates the profileSubscriptionAndMetadataArr array with the subscription requests + * @param {*} subscribeStatusList + * @param {*} profilesList + * @param {*} operation + * @param {*} profileSubscriptionAndMetadataArr + * @param {*} metaDataIndexMap + */ +const updateArrWithSubscriptions = ( + subscribeStatusList, + profilesList, + operation, + profileSubscriptionAndMetadataArr, + metaDataIndexMap, +) => { + const subscribeEventGroups = groupSubscribeResponsesUsingListIdV2(subscribeStatusList); + Object.keys(subscribeEventGroups).forEach((listId) => { // eventChunks = [[e1,e2,e3,..batchSize],[e1,e2,e3,..batchSize]..] const eventChunks = lodash.chunk(subscribeEventGroups[listId], MAX_BATCH_SIZE); - eventChunks.forEach((chunk, index) => { + eventChunks.forEach((chunk) => { // get subscriptionProfiles for the chunk const subscriptionProfileList = chunk.map((event) => event.payload?.profile); // get metadata for this chunk const metadataList = chunk.map((event) => event.metadata); // get list of jobIds from the above metdadata const jobIdList = metadataList.map((metadata) => metadata.jobId); + // using length as index + const index = profileSubscriptionAndMetadataArr.length; // push the jobId: index to metadataIndex mapping which let us know the metadata respective payload index position in batched request jobIdList.forEach((jobId) => { metaDataIndexMap.set(jobId, index); }); profileSubscriptionAndMetadataArr.push({ - subscription: { subscriptionProfileList, listId }, + subscription: { subscriptionProfileList, listId, operation }, metadataList, profiles: [], }); }); }); - profileSubscriptionAndMetadataArr = populateArrWithRespectiveProfileData( +}; + +/** + * This function performs batching for the subscription and unsubscription requests and attaches respective profile request as well if present + * @param {*} subscribeList + * @param {*} unsubscribeList + * @param {*} profilesList + * @param {*} destination + * @returns + */ +const batchRequestV2 = (subscribeList, unsubscribeList, profilesList, destination) => { + const profileSubscriptionAndMetadataArr = []; + const metaDataIndexMap = new Map(); + updateArrWithSubscriptions( + subscribeList, + profilesList, + 'subscribe', profileSubscriptionAndMetadataArr, metaDataIndexMap, - profileRespList, + ); + updateArrWithSubscriptions( + unsubscribeList, + profilesList, + 'unsubscribe', + profileSubscriptionAndMetadataArr, + metaDataIndexMap, + ); + const subscriptionsAndProfileArr = populateArrWithRespectiveProfileData( + profileSubscriptionAndMetadataArr, + metaDataIndexMap, + profilesList, ); /* Till this point I have a profileSubscriptionAndMetadataArr containing the the events in one object for which batching has to happen in following format [ { - subscription: { subscriptionProfileList, listId1 }, + subscription: { subscriptionProfileList, listId1, operation }, metadataList1, profiles: [respectiveProfiles for above metadata] }, { - subscription: { subscriptionProfile List With No Profiles, listId2 }, + subscription: { subscriptionProfile List With No Profiles, listId2, operation }, metadataList2, }, { @@ -180,14 +220,11 @@ const batchRequestV2 = (subscribeRespList, profileRespList, destination) => { } ] */ - return buildRequestsForProfileSubscriptionAndMetadataArr( - profileSubscriptionAndMetadataArr, - destination, - ); + return buildProfileAndSubscriptionRequests(subscriptionsAndProfileArr, destination); /* for identify calls with batching batched with identify with no batching - we will sonctruct O/P as: + we will construct O/P as: [ - [2 calls for identifywith batching], + [2 calls for identify with batching], [1 call identify calls with batching] ] */ diff --git a/src/v0/destinations/klaviyo/batchUtil.test.js b/src/v0/destinations/klaviyo/batchUtil.test.js index af1afd8670..9c04a402ca 100644 --- a/src/v0/destinations/klaviyo/batchUtil.test.js +++ b/src/v0/destinations/klaviyo/batchUtil.test.js @@ -1,3 +1,4 @@ +const { OperatorType } = require('@rudderstack/json-template-engine'); const { groupSubscribeResponsesUsingListIdV2, populateArrWithRespectiveProfileData, @@ -94,6 +95,7 @@ describe('generateBatchedSubscriptionRequest', () => { const subscription = { listId: 'test-list-id', subscriptionProfileList: [[{ id: 'profile1' }, { id: 'profile2' }], [{ id: 'profile3' }]], + operation: 'subscribe', }; const destination = { Config: { @@ -144,6 +146,7 @@ describe('generateBatchedSubscriptionRequest', () => { const subscription = { listId: 'test-list-id', subscriptionProfileList: [], + operation: 'subscribe', }; const destination = { Config: { diff --git a/src/v0/destinations/klaviyo/config.js b/src/v0/destinations/klaviyo/config.js index 54216852f7..9f907330df 100644 --- a/src/v0/destinations/klaviyo/config.js +++ b/src/v0/destinations/klaviyo/config.js @@ -19,6 +19,11 @@ const CONFIG_CATEGORIES = { VIEWED_PRODUCT: { name: 'ViewedProduct' }, ADDED_TO_CART: { name: 'AddedToCart' }, ITEMS: { name: 'Items' }, + SUBSCRIBE: { name: 'KlaviyoProfileV2', apiUrl: '/api/profile-subscription-bulk-create-jobs' }, + UNSUBSCRIBE: { + name: 'KlaviyoProfileV2', + apiUrl: '/api/profile-subscription-bulk-delete-jobs', + }, }; const ecomExclusionKeys = [ 'name', diff --git a/src/v0/destinations/klaviyo/transformV2.js b/src/v0/destinations/klaviyo/transformV2.js index ad98d2f559..6d04cb8644 100644 --- a/src/v0/destinations/klaviyo/transformV2.js +++ b/src/v0/destinations/klaviyo/transformV2.js @@ -7,9 +7,9 @@ const { EventType, MappedToDestinationKey } = require('../../../constants'); const { CONFIG_CATEGORIES, MAPPING_CONFIG } = require('./config'); const { constructProfile, - subscribeUserToListV2, + subscribeOrUnsubscribeUserToListV2, buildRequest, - buildSubscriptionRequest, + buildSubscriptionOrUnsubscriptionPayload, getTrackRequests, fetchTransformedEvents, addSubscribeFlagToTraits, @@ -24,6 +24,7 @@ const { adduserIdFromExternalId, groupEventsByType, flattenJson, + isDefinedAndNotNull, } = require('../../util'); /** @@ -49,9 +50,17 @@ const identifyRequestHandler = (message, category, destination) => { } const payload = removeUndefinedAndNullValues(constructProfile(message, destination, true)); const response = { profile: payload }; - // check if user wants to subscribe profile or not and listId is present or not - if (traitsInfo?.properties?.subscribe && (traitsInfo.properties?.listId || listId)) { - response.subscription = subscribeUserToListV2(message, traitsInfo, destination); + // check if user wants to subscribe/unsubscribe profile or do nothing and listId is present or not + if ( + isDefinedAndNotNull(traitsInfo?.properties?.subscribe) && + (traitsInfo.properties?.listId || listId) + ) { + response.subscription = subscribeOrUnsubscribeUserToListV2( + message, + traitsInfo, + destination, + traitsInfo.properties.subscribe ? 'subscribe' : 'unsubscribe', + ); } return response; }; @@ -93,7 +102,7 @@ const trackOrScreenRequestHandler = (message, category, destination) => { }; /** - * Main handlerfunc for group request add/subscribe users to the list based on property sent + * Main handlerfunc for group request add/subscribe to or remove/delete users to the list based on property sent * DOCS:https://developers.klaviyo.com/en/reference/subscribe_profiles * @param {*} message * @param {*} category @@ -105,11 +114,17 @@ const groupRequestHandler = (message, category, destination) => { throw new InstrumentationError('groupId is a required field for group events'); } const traitsInfo = getFieldValueFromMessage(message, 'traits'); - if (!traitsInfo?.subscribe) { - throw new InstrumentationError('Subscribe flag should be true for group call'); + if (!isDefinedAndNotNull(traitsInfo?.subscribe)) { + throw new InstrumentationError('Subscribe flag should be included in group call'); } - // throwing error for subscribe flag - return { subscription: subscribeUserToListV2(message, traitsInfo, destination) }; + return { + subscription: subscribeOrUnsubscribeUserToListV2( + message, + traitsInfo, + destination, + traitsInfo.subscribe ? 'subscribe' : 'unsubscribe', + ), + }; }; const processEvent = (event) => { @@ -152,9 +167,7 @@ const processV2 = (event) => { respList.push(buildRequest(response.profile, destination, CONFIG_CATEGORIES.IDENTIFYV2)); } if (response.subscription) { - respList.push( - buildSubscriptionRequest(response.subscription, destination, CONFIG_CATEGORIES.TRACKV2), - ); + respList.push(buildSubscriptionOrUnsubscriptionPayload(response.subscription, destination)); } if (response.event) { respList.push(buildRequest(response.event, destination, CONFIG_CATEGORIES.TRACKV2)); @@ -163,9 +176,19 @@ const processV2 = (event) => { }; // This function separates subscribe, proifle and event responses from process () and other responses in chunks -const getEventChunks = (input, subscribeRespList, profileRespList, eventRespList) => { +const getEventChunks = ( + input, + subscribeRespList, + profileRespList, + eventRespList, + unsubscriptionList, +) => { if (input.payload.subscription) { - subscribeRespList.push({ payload: input.payload.subscription, metadata: input.metadata }); + if (input.payload.subscription.operation === 'subscribe') { + subscribeRespList.push({ payload: input.payload.subscription, metadata: input.metadata }); + } else { + unsubscriptionList.push({ payload: input.payload.subscription, metadata: input.metadata }); + } } if (input.payload.profile) { profileRespList.push({ payload: input.payload.profile, metadata: input.metadata }); @@ -179,6 +202,7 @@ const processRouter = (inputs, reqMetadata) => { const batchResponseList = []; const batchErrorRespList = []; const subscribeRespList = []; + const unsubscriptionList = []; const profileRespList = []; const eventRespList = []; const { destination } = inputs[0]; @@ -197,6 +221,7 @@ const processRouter = (inputs, reqMetadata) => { subscribeRespList, profileRespList, eventRespList, + unsubscriptionList, ); } } catch (error) { @@ -204,7 +229,12 @@ const processRouter = (inputs, reqMetadata) => { batchErrorRespList.push(errRespEvent); } }); - const batchedResponseList = batchRequestV2(subscribeRespList, profileRespList, destination); + const batchedResponseList = batchRequestV2( + subscribeRespList, + unsubscriptionList, + profileRespList, + destination, + ); const trackRespList = getTrackRequests(eventRespList, destination); batchResponseList.push(...trackRespList, ...batchedResponseList); diff --git a/src/v0/destinations/klaviyo/util.js b/src/v0/destinations/klaviyo/util.js index 7b2b011d43..6b0d9a0d4f 100644 --- a/src/v0/destinations/klaviyo/util.js +++ b/src/v0/destinations/klaviyo/util.js @@ -457,8 +457,9 @@ const constructProfile = (message, destination, isIdentifyCall) => { /** * This function is used for creating profile response for subscribing users to a particular list for V2 * DOCS: https://developers.klaviyo.com/en/reference/subscribe_profiles + * Return an object with listId, profiles and operation */ -const subscribeUserToListV2 = (message, traitsInfo, destination) => { +const subscribeOrUnsubscribeUserToListV2 = (message, traitsInfo, destination, operation) => { // listId from message properties are preferred over Config listId const { consent } = destination.Config; let { listId } = destination.Config; @@ -469,7 +470,9 @@ const subscribeUserToListV2 = (message, traitsInfo, destination) => { email, phone_number: phone, }; - if (subscribeConsent) { + + // used only for subscription and not for unsubscription + if (operation === 'subscribe' && subscribeConsent) { if (!Array.isArray(subscribeConsent)) { subscribeConsent = [subscribeConsent]; } @@ -483,13 +486,20 @@ const subscribeUserToListV2 = (message, traitsInfo, destination) => { const profile = removeUndefinedAndNullValues({ type: 'profile', - id: getDestinationExternalID(message, 'klaviyo-profileId'), + id: + operation === 'subscribe' + ? getDestinationExternalID(message, 'klaviyo-profileId') + : undefined, // id is not applicable for unsubscription attributes: removeUndefinedAndNullValues(profileAttributes), }); if (!email && !phone && profile.id) { - throw new InstrumentationError( - 'Profile Id, Email or/and Phone are required to subscribe to a list', - ); + if (operation === 'subscribe') { + throw new InstrumentationError( + 'Profile Id, Email or/and Phone are required to subscribe to a list', + ); + } else { + throw new InstrumentationError('Email or/and Phone are required to unsubscribe from a list'); + } } // fetch list id from message if (traitsInfo?.properties?.listId) { @@ -499,17 +509,20 @@ const subscribeUserToListV2 = (message, traitsInfo, destination) => { if (message.type === 'group') { listId = message.groupId; } - - return { listId, profile: [profile] }; + return { listId, profile: [profile], operation }; }; /** * This Create a subscription payload to subscribe profile(s) to list listId * @param {*} listId * @param {*} profiles + * @param {*} operation can be either subscribe or unsubscribe */ -const getSubscriptionPayload = (listId, profiles) => ({ +const getSubscriptionPayload = (listId, profiles, operation) => ({ data: { - type: 'profile-subscription-bulk-create-job', + type: + operation === 'subscribe' + ? 'profile-subscription-bulk-create-job' + : 'profile-subscription-bulk-delete-job', attributes: { profiles: { data: profiles } }, relationships: { list: { @@ -523,14 +536,15 @@ const getSubscriptionPayload = (listId, profiles) => ({ }); /** - * This function accepts subscriptions object and builds a request for it + * This function accepts subscriptions/ unsubscription object and builds a request for it * @param {*} subscription * @param {*} destination + * @param {*} operation can be either subscription or unsubscription * @returns defaultRequestConfig */ -const buildSubscriptionRequest = (subscription, destination) => { +const buildSubscriptionOrUnsubscriptionPayload = (subscription, destination) => { const response = defaultRequestConfig(); - response.endpoint = `${BASE_ENDPOINT}/api/profile-subscription-bulk-create-jobs`; + response.endpoint = `${BASE_ENDPOINT}${CONFIG_CATEGORIES[subscription.operation.toUpperCase()].apiUrl}`; response.method = defaultPostRequestConfig.requestMethod; response.headers = { Authorization: `Klaviyo-API-Key ${destination.Config.privateApiKey}`, @@ -538,7 +552,11 @@ const buildSubscriptionRequest = (subscription, destination) => { 'Content-Type': JSON_MIME_TYPE, revision, }; - response.body.JSON = getSubscriptionPayload(subscription.listId, subscription.profile); + response.body.JSON = getSubscriptionPayload( + subscription.listId, + subscription.profile, + subscription.operation, + ); return response; }; @@ -602,10 +620,10 @@ module.exports = { profileUpdateResponseBuilder, getIdFromNewOrExistingProfile, constructProfile, - subscribeUserToListV2, + subscribeOrUnsubscribeUserToListV2, getProfileMetadataAndMetadataFields, buildRequest, - buildSubscriptionRequest, + buildSubscriptionOrUnsubscriptionPayload, getTrackRequests, fetchTransformedEvents, addSubscribeFlagToTraits, diff --git a/test/integrations/destinations/klaviyo/processor/groupTestDataV2.ts b/test/integrations/destinations/klaviyo/processor/groupTestDataV2.ts index dcd7fbc38e..da7769b110 100644 --- a/test/integrations/destinations/klaviyo/processor/groupTestDataV2.ts +++ b/test/integrations/destinations/klaviyo/processor/groupTestDataV2.ts @@ -32,7 +32,7 @@ const headers = { revision: '2024-06-15', }; -const endpoint = 'https://a.klaviyo.com/api/profile-subscription-bulk-create-jobs'; +const subscriptionEndpoint = 'https://a.klaviyo.com/api/profile-subscription-bulk-create-jobs'; const commonOutputSubscriptionProps = { profiles: { @@ -50,6 +50,19 @@ const commonOutputSubscriptionProps = { ], }, }; +const commonOutputUnsubscriptionProps = { + profiles: { + data: [ + { + type: 'profile', + attributes: { + email: 'test@rudderstack.com', + phone_number: '+12 345 678 900', + }, + }, + ], + }, +}; const subscriptionRelations = { list: { @@ -59,12 +72,13 @@ const subscriptionRelations = { }, }, }; +const unsubscriptionEndpoint = 'https://a.klaviyo.com/api/profile-subscription-bulk-delete-jobs'; export const groupTestData: ProcessorTestData[] = [ { id: 'klaviyo-group-test-1', name: 'klaviyo', - description: 'Simple group call', + description: 'Simple group call for subscription', scenario: 'Business', successCriteria: 'Response should contain only group payload and status code should be 200, for the group payload a subscription payload should be present in the final payload with email and phone', @@ -109,7 +123,67 @@ export const groupTestData: ProcessorTestData[] = [ relationships: subscriptionRelations, }, }, - endpoint: endpoint, + endpoint: subscriptionEndpoint, + headers: headers, + method: 'POST', + userId: '', + }), + statusCode: 200, + metadata: generateMetadata(1), + }, + ], + }, + }, + }, + { + id: 'klaviyo-group-test-1', + name: 'klaviyo', + description: 'Simple group call for subscription', + scenario: 'Business', + successCriteria: + 'Response should contain only group payload and status code should be 200, for the group payload a unsubscription payload should be present in the final payload with email and phone', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + destination, + message: generateSimplifiedGroupPayload({ + userId: 'user123', + groupId: 'group_list_id', + traits: { + subscribe: false, + }, + context: { + traits: { + email: 'test@rudderstack.com', + phone: '+12 345 678 900', + consent: ['email'], + }, + }, + timestamp: '2020-01-21T00:21:34.208Z', + }), + metadata: generateMetadata(1), + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + output: transformResultBuilder({ + JSON: { + data: { + type: 'profile-subscription-bulk-delete-job', + attributes: commonOutputUnsubscriptionProps, + relationships: subscriptionRelations, + }, + }, + endpoint: unsubscriptionEndpoint, headers: headers, method: 'POST', userId: '', @@ -122,7 +196,7 @@ export const groupTestData: ProcessorTestData[] = [ }, }, { - id: 'klaviyo-group-test-2', + id: 'klaviyo-group-test-3', name: 'klaviyo', description: 'Simple group call without groupId', scenario: 'Business', diff --git a/test/integrations/destinations/klaviyo/processor/identifyTestDataV2.ts b/test/integrations/destinations/klaviyo/processor/identifyTestDataV2.ts index 80ae918af0..612bfe88f8 100644 --- a/test/integrations/destinations/klaviyo/processor/identifyTestDataV2.ts +++ b/test/integrations/destinations/klaviyo/processor/identifyTestDataV2.ts @@ -87,6 +87,19 @@ const commonOutputSubscriptionProps = { ], }, }; +const commonOutputUnsubscriptionProps = { + profiles: { + data: [ + { + type: 'profile', + attributes: { + email: 'test@rudderstack.com', + phone_number: '+12 345 578 900', + }, + }, + ], + }, +}; const subscriptionRelations = { list: { data: { @@ -109,6 +122,7 @@ const sentAt = '2021-01-03T17:02:53.195Z'; const originalTimestamp = '2021-01-03T17:02:53.193Z'; const userProfileCommonEndpoint = 'https://a.klaviyo.com/api/profile-import'; const subscribeEndpoint = 'https://a.klaviyo.com/api/profile-subscription-bulk-create-jobs'; +const unsubscribeEndpoint = 'https://a.klaviyo.com/api/profile-subscription-bulk-delete-jobs'; export const identifyData: ProcessorTestData[] = [ { @@ -206,7 +220,99 @@ export const identifyData: ProcessorTestData[] = [ id: 'klaviyo-identify-150624-test-2', name: 'klaviyo', description: - '150624 -> Profile without subscribing the user and get klaviyo id from externalId', + '150624 -> Identify call with flattenProperties enabled in destination config and unsubscribe', + scenario: 'Business', + successCriteria: + 'The profile response should contain the flattened properties of the friend object and one request object for subscribe', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + destination: overrideDestination(destination, { flattenProperties: true }), + message: generateSimplifiedIdentifyPayload({ + sentAt, + userId, + context: { + traits: { + ...commonTraits, + properties: { ...commonTraits.properties, subscribe: false }, + friend: { + names: { + first: 'Alice', + last: 'Smith', + }, + age: 25, + }, + }, + }, + anonymousId, + originalTimestamp, + }), + metadata: generateMetadata(2), + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + output: transformResultBuilder({ + userId: '', + method: 'POST', + endpoint: userProfileCommonEndpoint, + headers: commonOutputHeaders, + JSON: { + data: { + type: 'profile', + attributes: { + ...commonOutputUserProps, + properties: { + ...commonOutputUserProps.properties, + 'friend.age': 25, + 'friend.names.first': 'Alice', + 'friend.names.last': 'Smith', + }, + }, + meta: { + patch_properties: {}, + }, + }, + }, + }), + statusCode: 200, + metadata: generateMetadata(2), + }, + { + output: transformResultBuilder({ + userId: '', + method: 'POST', + endpoint: unsubscribeEndpoint, + headers: commonOutputHeaders, + JSON: { + data: { + type: 'profile-subscription-bulk-delete-job', + attributes: commonOutputUnsubscriptionProps, + relationships: subscriptionRelations, + }, + }, + }), + statusCode: 200, + metadata: generateMetadata(2), + }, + ], + }, + }, + }, + { + id: 'klaviyo-identify-150624-test-3', + name: 'klaviyo', + description: + '150624 -> Profile without subscribing/unsubscribing the user and get klaviyo id from externalId', scenario: 'Business', successCriteria: 'Response should contain only profile update payload and status code should be 200 as subscribe is set to false in the payload', @@ -234,7 +340,7 @@ export const identifyData: ProcessorTestData[] = [ appendList2: 'New Value 2', unappendList1: 'Old Value 1', unappendList2: 'Old Value 2', - properties: { ...commonTraits.properties, subscribe: false }, + properties: { ...commonTraits.properties, subscribe: undefined }, }, }, integrations: { @@ -292,7 +398,7 @@ export const identifyData: ProcessorTestData[] = [ }, }, { - id: 'klaviyo-identify-150624-test-5', + id: 'klaviyo-identify-150624-test-4', name: 'klaviyo', description: '150624 -> Identify call with enforceEmailAsPrimary enabled in destination config', scenario: 'Business', diff --git a/test/integrations/destinations/klaviyo/router/dataV2.ts b/test/integrations/destinations/klaviyo/router/dataV2.ts index 714560fdfd..5a0a06fad1 100644 --- a/test/integrations/destinations/klaviyo/router/dataV2.ts +++ b/test/integrations/destinations/klaviyo/router/dataV2.ts @@ -1260,4 +1260,788 @@ export const dataV2: RouterTestData[] = [ }, }, }, + { + id: 'klaviyo-router-150624-test-5', + name: 'klaviyo', + description: '150624 -> Only Identify calls with some subcribe and some unsubscribe operation', + scenario: 'Framework', + successCriteria: + 'All the subscription events with same listId should be batched and same for unsubscribe as well.', + feature: 'router', + module: 'destination', + version: 'v0', + input: { + request: { + body: { + input: [ + { + message: { + // user 1 idenitfy call with anonymousId and subscription as true + channel: 'web', + traits: { + email: 'testklaviyo1@rs.com', + firstname: 'Test Klaviyo 1', + properties: { + subscribe: true, + listId: 'configListId', + consent: ['email'], + }, + }, + context: {}, + anonymousId: 'anonTestKlaviyo1', + type: 'identify', + userId: 'testKlaviyo1', + integrations: { + All: true, + }, + }, + metadata: generateMetadata(1, 'testKlaviyo1'), + destination, + }, + { + message: { + // user 2 idenitfy call with no anonymousId and subscription as true + channel: 'web', + traits: { + email: 'testklaviyo2@rs.com', + firstname: 'Test Klaviyo 2', + properties: { + subscribe: true, + listId: 'configListId', + consent: ['email'], + }, + }, + context: {}, + type: 'identify', + userId: 'testKlaviyo2', + integrations: { + All: true, + }, + }, + metadata: generateMetadata(2, 'testKlaviyo2'), + destination, + }, + { + message: { + // user 3 idenitfy call with no anonymousId and subscription as false + channel: 'web', + traits: { + email: 'testklaviyo3@rs.com', + firstname: 'Test Klaviyo 3', + properties: { + subscribe: false, + listId: 'configListId', + consent: ['email'], + }, + }, + context: {}, + type: 'identify', + userId: 'testKlaviyo3', + integrations: { + All: true, + }, + }, + metadata: generateMetadata(3, 'testKlaviyo3'), + destination, + }, + { + message: { + // user 4 idenitfy call with anonymousId and subscription as false + channel: 'web', + traits: { + email: 'testklaviyo4@rs.com', + firstname: 'Test Klaviyo 4', + properties: { + subscribe: false, + listId: 'configListId', + consent: ['email'], + }, + }, + context: {}, + type: 'identify', + anonymousId: 'anon id 4', + userId: 'testKlaviyo4', + integrations: { + All: true, + }, + }, + metadata: generateMetadata(4, 'testKlaviyo4'), + destination, + }, + ], + destType: 'klaviyo', + }, + }, + }, + output: { + response: { + status: 200, + body: { + output: [ + { + // 2 identify calls and one batched subscription request for user 1 and user 2 + batchedRequest: [ + { + version: '1', + type: 'REST', + method: 'POST', + endpoint: userProfileCommonEndpoint, + headers, + params: {}, + body: { + JSON: { + data: { + type: 'profile', + attributes: { + external_id: 'testKlaviyo1', + email: 'testklaviyo1@rs.com', + first_name: 'Test Klaviyo 1', + anonymous_id: 'anonTestKlaviyo1', + properties: {}, + }, + meta: { + patch_properties: {}, + }, + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + files: {}, + }, + { + version: '1', + type: 'REST', + method: 'POST', + endpoint: userProfileCommonEndpoint, + headers, + params: {}, + body: { + JSON: { + data: { + type: 'profile', + attributes: { + external_id: 'testKlaviyo2', + email: 'testklaviyo2@rs.com', + first_name: 'Test Klaviyo 2', + properties: {}, + }, + meta: { + patch_properties: {}, + }, + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + files: {}, + }, + { + version: '1', + type: 'REST', + method: 'POST', + endpoint: 'https://a.klaviyo.com/api/profile-subscription-bulk-create-jobs', + headers, + params: {}, + body: { + JSON: { + data: { + type: 'profile-subscription-bulk-create-job', + attributes: { + profiles: { + data: [ + { + type: 'profile', + attributes: { + email: 'testklaviyo1@rs.com', + subscriptions: { + email: { marketing: { consent: 'SUBSCRIBED' } }, + }, + }, + }, + { + type: 'profile', + attributes: { + email: 'testklaviyo2@rs.com', + subscriptions: { + email: { marketing: { consent: 'SUBSCRIBED' } }, + }, + }, + }, + ], + }, + }, + relationships: { + list: { + data: { + type: 'list', + id: 'configListId', + }, + }, + }, + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + files: {}, + }, + ], + metadata: [generateMetadata(1, 'testKlaviyo1'), generateMetadata(2, 'testKlaviyo2')], + batched: true, + statusCode: 200, + destination, + }, + { + // 2 identify calls and one batched unsubscription request for user 3 and user 4 + batchedRequest: [ + { + version: '1', + type: 'REST', + method: 'POST', + endpoint: userProfileCommonEndpoint, + headers, + params: {}, + body: { + JSON: { + data: { + type: 'profile', + attributes: { + external_id: 'testKlaviyo3', + email: 'testklaviyo3@rs.com', + first_name: 'Test Klaviyo 3', + properties: {}, + }, + meta: { + patch_properties: {}, + }, + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + files: {}, + }, + { + version: '1', + type: 'REST', + method: 'POST', + endpoint: userProfileCommonEndpoint, + headers, + params: {}, + body: { + JSON: { + data: { + type: 'profile', + attributes: { + external_id: 'testKlaviyo4', + email: 'testklaviyo4@rs.com', + first_name: 'Test Klaviyo 4', + anonymous_id: 'anon id 4', + properties: {}, + }, + meta: { + patch_properties: {}, + }, + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + files: {}, + }, + { + version: '1', + type: 'REST', + method: 'POST', + endpoint: 'https://a.klaviyo.com/api/profile-subscription-bulk-delete-jobs', + headers, + params: {}, + body: { + JSON: { + data: { + type: 'profile-subscription-bulk-delete-job', + attributes: { + profiles: { + data: [ + { + type: 'profile', + attributes: { + email: 'testklaviyo3@rs.com', + }, + }, + { + type: 'profile', + attributes: { + email: 'testklaviyo4@rs.com', + }, + }, + ], + }, + }, + relationships: { + list: { + data: { + type: 'list', + id: 'configListId', + }, + }, + }, + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + files: {}, + }, + ], + metadata: [generateMetadata(3, 'testKlaviyo3'), generateMetadata(4, 'testKlaviyo4')], + batched: true, + statusCode: 200, + destination, + }, + ], + }, + }, + }, + }, + { + id: 'klaviyo-router-150624-test-6', + name: 'klaviyo', + description: + '150624 -> Router tests to have some anonymous track event, some identify events with unsubscription and some identified track event', + scenario: 'Framework', + successCriteria: + 'All the unsubscription events under same message type should be batched and respective profile requests should also be placed in same batched request', + feature: 'router', + module: 'destination', + version: 'v0', + input: { + request: { + body: { + input: [ + { + message: { + // user 1 track call with userId and anonymousId + channel: 'web', + context: { + traits: { + email: 'testklaviyo1@email.com', + firstname: 'Test Klaviyo 1', + }, + }, + type: 'track', + anonymousId: 'anonTestKlaviyo1', + userId: 'testKlaviyo1', + event: 'purchase', + properties: { + price: '12', + }, + }, + metadata: generateMetadata(1, 'testKlaviyo1'), + destination, + }, + { + message: { + // Anonymous Tracking -> user 2 track call with anonymousId only + channel: 'web', + context: { + traits: {}, + }, + type: 'track', + anonymousId: 'anonTestKlaviyo2', + event: 'viewed product', + properties: { + price: '120', + }, + }, + metadata: generateMetadata(2), + destination, + }, + { + message: { + // user 2 idenitfy call with anonymousId and subscription + channel: 'web', + traits: { + email: 'testklaviyo2@rs.com', + firstname: 'Test Klaviyo 2', + properties: { + subscribe: false, + listId: 'configListId', + consent: ['email'], + }, + }, + context: {}, + anonymousId: 'anonTestKlaviyo2', + type: 'identify', + userId: 'testKlaviyo2', + integrations: { + All: true, + }, + }, + metadata: generateMetadata(3, 'testKlaviyo2'), + destination, + }, + { + message: { + // user 2 track call with email only + channel: 'web', + context: { + traits: { + email: 'testklaviyo2@email.com', + firstname: 'Test Klaviyo 2', + }, + }, + type: 'track', + userId: 'testKlaviyo2', + event: 'purchase', + properties: { + price: '120', + }, + }, + metadata: generateMetadata(4, 'testKlaviyo2'), + destination, + }, + { + message: { + // for user 3 identify call without anonymousId and subscriptiontraits: + channel: 'web', + traits: { + email: 'testklaviyo3@rs.com', + firstname: 'Test Klaviyo 3', + properties: { + subscribe: false, + listId: 'configListId', + consent: ['email', 'sms'], + }, + }, + context: {}, + type: 'identify', + userId: 'testKlaviyo3', + integrations: { + All: true, + }, + }, + metadata: generateMetadata(5, 'testKlaviyo3'), + destination, + }, + ], + destType: 'klaviyo', + }, + }, + }, + output: { + response: { + status: 200, + body: { + output: [ + { + // user 1 track call with userId and anonymousId + batchedRequest: { + version: '1', + type: 'REST', + method: 'POST', + endpoint: 'https://a.klaviyo.com/api/events', + headers: { + Authorization: 'Klaviyo-API-Key dummyPrivateApiKey', + Accept: 'application/json', + 'Content-Type': 'application/json', + revision: '2024-06-15', + }, + params: {}, + body: { + JSON: { + data: { + type: 'event', + attributes: { + properties: { + price: '12', + }, + profile: { + data: { + type: 'profile', + attributes: { + external_id: 'testKlaviyo1', + anonymous_id: 'anonTestKlaviyo1', + email: 'testklaviyo1@email.com', + first_name: 'Test Klaviyo 1', + properties: {}, + meta: { + patch_properties: {}, + }, + }, + }, + }, + metric: { + data: { + type: 'metric', + attributes: { + name: 'purchase', + }, + }, + }, + }, + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + files: {}, + }, + metadata: [generateMetadata(1, 'testKlaviyo1')], + batched: false, + statusCode: 200, + destination, + }, + { + // anonn event for user 2 + batchedRequest: { + version: '1', + type: 'REST', + method: 'POST', + endpoint: 'https://a.klaviyo.com/api/events', + headers: { + Authorization: 'Klaviyo-API-Key dummyPrivateApiKey', + Accept: 'application/json', + 'Content-Type': 'application/json', + revision: '2024-06-15', + }, + params: {}, + body: { + JSON: { + data: { + type: 'event', + attributes: { + properties: { + price: '120', + }, + profile: { + data: { + type: 'profile', + attributes: { + anonymous_id: 'anonTestKlaviyo2', + properties: {}, + meta: { + patch_properties: {}, + }, + }, + }, + }, + metric: { + data: { + type: 'metric', + attributes: { + name: 'viewed product', + }, + }, + }, + }, + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + files: {}, + }, + metadata: [generateMetadata(2)], + batched: false, + statusCode: 200, + destination, + }, + { + // identify call for user 2 and user 3 with subscription + batchedRequest: [ + { + version: '1', + type: 'REST', + method: 'POST', + endpoint: 'https://a.klaviyo.com/api/profile-import', + headers: { + Authorization: 'Klaviyo-API-Key dummyPrivateApiKey', + Accept: 'application/json', + 'Content-Type': 'application/json', + revision: '2024-06-15', + }, + params: {}, + body: { + JSON: { + data: { + type: 'profile', + attributes: { + external_id: 'testKlaviyo2', + anonymous_id: 'anonTestKlaviyo2', + email: 'testklaviyo2@rs.com', + first_name: 'Test Klaviyo 2', + properties: {}, + }, + meta: { + patch_properties: {}, + }, + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + files: {}, + }, + { + version: '1', + type: 'REST', + method: 'POST', + endpoint: 'https://a.klaviyo.com/api/profile-import', + headers: { + Authorization: 'Klaviyo-API-Key dummyPrivateApiKey', + Accept: 'application/json', + 'Content-Type': 'application/json', + revision: '2024-06-15', + }, + params: {}, + body: { + JSON: { + data: { + type: 'profile', + attributes: { + external_id: 'testKlaviyo3', + email: 'testklaviyo3@rs.com', + first_name: 'Test Klaviyo 3', + properties: {}, + }, + meta: { + patch_properties: {}, + }, + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + files: {}, + }, + { + version: '1', + type: 'REST', + method: 'POST', + endpoint: 'https://a.klaviyo.com/api/profile-subscription-bulk-delete-jobs', + headers: { + Authorization: 'Klaviyo-API-Key dummyPrivateApiKey', + 'Content-Type': 'application/json', + Accept: 'application/json', + revision: '2024-06-15', + }, + params: {}, + body: { + JSON: { + data: { + type: 'profile-subscription-bulk-delete-job', + attributes: { + profiles: { + data: [ + { + type: 'profile', + attributes: { + email: 'testklaviyo2@rs.com', + }, + }, + { + type: 'profile', + attributes: { + email: 'testklaviyo3@rs.com', + }, + }, + ], + }, + }, + relationships: { + list: { + data: { + type: 'list', + id: 'configListId', + }, + }, + }, + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + files: {}, + }, + ], + metadata: [generateMetadata(3, 'testKlaviyo2'), generateMetadata(5, 'testKlaviyo3')], + batched: true, + statusCode: 200, + destination, + }, + { + batchedRequest: { + version: '1', + type: 'REST', + method: 'POST', + endpoint: 'https://a.klaviyo.com/api/events', + headers: { + Authorization: 'Klaviyo-API-Key dummyPrivateApiKey', + Accept: 'application/json', + 'Content-Type': 'application/json', + revision: '2024-06-15', + }, + params: {}, + body: { + JSON: { + data: { + type: 'event', + attributes: { + properties: { + price: '120', + }, + profile: { + data: { + type: 'profile', + attributes: { + external_id: 'testKlaviyo2', + email: 'testklaviyo2@email.com', + first_name: 'Test Klaviyo 2', + properties: {}, + meta: { + patch_properties: {}, + }, + }, + }, + }, + metric: { + data: { + type: 'metric', + attributes: { + name: 'purchase', + }, + }, + }, + }, + }, + }, + JSON_ARRAY: {}, + XML: {}, + FORM: {}, + }, + files: {}, + }, + metadata: [generateMetadata(4, 'testKlaviyo2')], + batched: false, + statusCode: 200, + destination, + }, + ], + }, + }, + }, + }, ]; From f2ddc92d7aef949d644924f89e4896532157b03b Mon Sep 17 00:00:00 2001 From: Anant Jain Date: Mon, 19 Aug 2024 10:34:09 +0530 Subject: [PATCH 2/2] fix: reduce cognitive complexity --- src/v0/destinations/klaviyo/util.js | 30 +++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/v0/destinations/klaviyo/util.js b/src/v0/destinations/klaviyo/util.js index 6b0d9a0d4f..4421764d95 100644 --- a/src/v0/destinations/klaviyo/util.js +++ b/src/v0/destinations/klaviyo/util.js @@ -454,6 +454,24 @@ const constructProfile = (message, destination, isIdentifyCall) => { return { data }; }; +/** This function update profile with consents for subscribing to email and/or phone + * @param {*} profileAttributes + * @param {*} subscribeConsent + * @param {*} email + * @param {*} phone + */ +const updateProfileWithConsents = (profileAttributes, subscribeConsent, email, phone) => { + let consent = subscribeConsent; + if (!Array.isArray(consent)) { + consent = [consent]; + } + if (consent.includes('email') && email) { + set(profileAttributes, 'subscriptions.email.marketing.consent', 'SUBSCRIBED'); + } + if (consent.includes('sms') && phone) { + set(profileAttributes, 'subscriptions.sms.marketing.consent', 'SUBSCRIBED'); + } +}; /** * This function is used for creating profile response for subscribing users to a particular list for V2 * DOCS: https://developers.klaviyo.com/en/reference/subscribe_profiles @@ -463,7 +481,7 @@ const subscribeOrUnsubscribeUserToListV2 = (message, traitsInfo, destination, op // listId from message properties are preferred over Config listId const { consent } = destination.Config; let { listId } = destination.Config; - let subscribeConsent = traitsInfo.consent || traitsInfo.properties?.consent || consent; + const subscribeConsent = traitsInfo.consent || traitsInfo.properties?.consent || consent; const email = getFieldValueFromMessage(message, 'email'); const phone = getFieldValueFromMessage(message, 'phone'); const profileAttributes = { @@ -473,15 +491,7 @@ const subscribeOrUnsubscribeUserToListV2 = (message, traitsInfo, destination, op // used only for subscription and not for unsubscription if (operation === 'subscribe' && subscribeConsent) { - if (!Array.isArray(subscribeConsent)) { - subscribeConsent = [subscribeConsent]; - } - if (subscribeConsent.includes('email') && email) { - set(profileAttributes, 'subscriptions.email.marketing.consent', 'SUBSCRIBED'); - } - if (subscribeConsent.includes('sms') && phone) { - set(profileAttributes, 'subscriptions.sms.marketing.consent', 'SUBSCRIBED'); - } + updateProfileWithConsents(profileAttributes, subscribeConsent, email, phone); } const profile = removeUndefinedAndNullValues({