diff --git a/.github/workflows/create-hotfix-branch.yml b/.github/workflows/create-hotfix-branch.yml index aa928b4646..d1397cb608 100644 --- a/.github/workflows/create-hotfix-branch.yml +++ b/.github/workflows/create-hotfix-branch.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest # Only allow these users to create new hotfix branch from 'main' - if: github.ref == 'refs/heads/main' && (github.actor == 'ItsSudip' || github.actor == 'krishna2020' || github.actor == 'saikumarrs' || github.actor == 'sandeepdsvs' || github.actor == 'shrouti1507' || github.actor == 'anantjain45823') && (github.triggering_actor == 'ItsSudip' || github.triggering_actor == 'krishna2020' || github.triggering_actor == 'saikumarrs' || github.triggering_actor == 'sandeepdsvs' || github.triggering_actor == 'shrouti1507' || github.triggering_actor == 'anantjain45823') + if: github.ref == 'refs/heads/main' && (github.actor == 'ItsSudip' || github.actor == 'krishna2020' || github.actor == 'saikumarrs' || github.actor == 'sandeepdsvs' || github.actor == 'shrouti1507' || github.actor == 'anantjain45823' || github.actor == 'chandumlg' || github.actor == 'mihir-4116') && (github.triggering_actor == 'ItsSudip' || github.triggering_actor == 'krishna2020' || github.triggering_actor == 'saikumarrs' || github.triggering_actor == 'sandeepdsvs' || github.triggering_actor == 'shrouti1507' || github.triggering_actor == 'anantjain45823' || github.triggering_actor == 'chandumlg' || github.triggering_actor == 'mihir-4116') steps: - name: Create Branch uses: peterjgrainger/action-create-branch@v2.4.0 diff --git a/.github/workflows/draft-new-release.yml b/.github/workflows/draft-new-release.yml index 044885316d..0657561f2b 100644 --- a/.github/workflows/draft-new-release.yml +++ b/.github/workflows/draft-new-release.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest # Only allow release stakeholders to initiate releases - if: (github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/hotfix/')) && (github.actor == 'ItsSudip' || github.actor == 'krishna2020' || github.actor == 'saikumarrs' || github.actor == 'sandeepdsvs' || github.actor == 'shrouti1507' || github.actor == 'anantjain45823') && (github.triggering_actor == 'ItsSudip' || github.triggering_actor == 'krishna2020' || github.triggering_actor == 'saikumarrs' || github.triggering_actor == 'sandeepdsvs' || github.triggering_actor == 'shrouti1507' || github.triggering_actor == 'anantjain45823') + if: (github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/hotfix/')) && (github.actor == 'ItsSudip' || github.actor == 'krishna2020' || github.actor == 'saikumarrs' || github.actor == 'sandeepdsvs' || github.actor == 'shrouti1507' || github.actor == 'anantjain45823' || github.actor == 'chandumlg' || github.actor == 'mihir-4116') && (github.triggering_actor == 'ItsSudip' || github.triggering_actor == 'krishna2020' || github.triggering_actor == 'saikumarrs' || github.triggering_actor == 'sandeepdsvs' || github.triggering_actor == 'shrouti1507' || github.triggering_actor == 'anantjain45823' || github.triggering_actor == 'chandumlg' || github.triggering_actor == 'mihir-4116') steps: - name: Checkout uses: actions/checkout@v3.5.3 diff --git a/.github/workflows/prepare-for-prod-rollback.yml b/.github/workflows/prepare-for-prod-rollback.yml index 3a2a60abc4..97c5a3dd7d 100644 --- a/.github/workflows/prepare-for-prod-rollback.yml +++ b/.github/workflows/prepare-for-prod-rollback.yml @@ -10,7 +10,7 @@ jobs: # Only allow to be deployed from tags and main branch # Only allow specific actors to trigger - if: (startsWith(github.ref, 'refs/tags/') || startsWith(github.ref, 'refs/heads/main')) && (github.actor == 'ItsSudip' || github.actor == 'krishna2020' || github.actor == 'saikumarrs') && (github.triggering_actor == 'ItsSudip' || github.triggering_actor == 'krishna2020' || github.triggering_actor == 'saikumarrs') + if: (startsWith(github.ref, 'refs/tags/') || startsWith(github.ref, 'refs/heads/main')) && (github.actor == 'ItsSudip' || github.actor == 'krishna2020' || github.actor == 'saikumarrs' || github.actor == 'chandumlg') && (github.triggering_actor == 'ItsSudip' || github.triggering_actor == 'krishna2020' || github.triggering_actor == 'saikumarrs' || github.triggering_actor == 'chandumlg') steps: - name: Get Target Version diff --git a/CHANGELOG.md b/CHANGELOG.md index 12690b81ff..4ffec3a8d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ 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.45.3](https://github.com/rudderlabs/rudder-transformer/compare/v1.45.2...v1.45.3) (2023-10-17) + + +### Bug Fixes + +* ut metadata map ts type ([c8d3882](https://github.com/rudderlabs/rudder-transformer/commit/c8d3882baccc57d7b892c55ff9811c951afb5ec6)) + +### [1.45.2](https://github.com/rudderlabs/rudder-transformer/compare/v1.45.1...v1.45.2) (2023-10-17) + + +### Bug Fixes + +* add event metadata to 298 status code responses ([f0493dc](https://github.com/rudderlabs/rudder-transformer/commit/f0493dccfd47bfe1897ebcec27141e2df31393c0)) + +### [1.45.1](https://github.com/rudderlabs/rudder-transformer/compare/v1.45.0...v1.45.1) (2023-10-17) + + +### Bug Fixes + +* **clevertap:** invalid parameters ordering issue ([a70d4db](https://github.com/rudderlabs/rudder-transformer/commit/a70d4db57b302abc710907aadb8570944d54165a)) +* **clevertap:** parameters ordering issue ([#2727](https://github.com/rudderlabs/rudder-transformer/issues/2727)) ([bd6e096](https://github.com/rudderlabs/rudder-transformer/commit/bd6e096db3dc6b9bd2d607084b8a38ff315fab9c)) + ## [1.45.0](https://github.com/rudderlabs/rudder-transformer/compare/v1.44.2...v1.45.0) (2023-10-11) diff --git a/package-lock.json b/package-lock.json index d2bfe402a1..b3d5eb7885 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rudder-transformer", - "version": "1.45.0", + "version": "1.45.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rudder-transformer", - "version": "1.45.0", + "version": "1.45.3", "license": "ISC", "dependencies": { "@amplitude/ua-parser-js": "^0.7.24", diff --git a/package.json b/package.json index d16ef9ea9f..1d45524515 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rudder-transformer", - "version": "1.45.0", + "version": "1.45.3", "description": "", "homepage": "https://github.com/rudderlabs/rudder-transformer#readme", "bugs": { diff --git a/src/constants/destinationCanonicalNames.js b/src/constants/destinationCanonicalNames.js index 6c7ec83ed9..870c534db0 100644 --- a/src/constants/destinationCanonicalNames.js +++ b/src/constants/destinationCanonicalNames.js @@ -3,6 +3,14 @@ const DestHandlerMap = { }; const DestCanonicalNames = { + facebook_conversions: [ + 'fb_conversions', + 'fb conversions', + 'FacebookConversions', + 'Facebook Conversions', + 'FB Conversions', + 'Facebook_Conversions', + ], fb_pixel: [ 'fb_pixel', 'fb pixel', diff --git a/src/services/userTransform.ts b/src/services/userTransform.ts index d54c105b02..5104418f6d 100644 --- a/src/services/userTransform.ts +++ b/src/services/userTransform.ts @@ -7,6 +7,7 @@ import { ProcessorTransformationResponse, UserTransformationResponse, UserTransformationServiceResponse, + MessageIdMetadataMap, } from '../types/index'; import { RespStatusError, @@ -49,8 +50,15 @@ export default class UserTransformService { const eventsToProcess = destEvents as ProcessorTransformationRequest[]; const transformationVersionId = eventsToProcess[0]?.destination?.Transformations[0]?.VersionID; - const messageIds = eventsToProcess.map((ev) => ev.metadata?.messageId); - const messageIdsSet = new Set(messageIds); + const messageIds: string[] = []; + const messageIdsSet = new Set(); + const messageIdMetadataMap: MessageIdMetadataMap = {}; + eventsToProcess.forEach((ev) => { + messageIds.push(ev.metadata?.messageId); + messageIdsSet.add(ev.metadata?.messageId); + messageIdMetadataMap[ev.metadata?.messageId] = ev.metadata; + }); + const messageIdsInOutputSet = new Set(); const commonMetadata = { @@ -125,7 +133,7 @@ export default class UserTransformService { const droppedEvents = messageIdsNotInOutput.map((id) => ({ statusCode: HTTP_CUSTOM_STATUS_CODES.FILTERED, metadata: { - ...commonMetadata, + ...(isEmpty(messageIdMetadataMap[id]) ? commonMetadata : messageIdMetadataMap[id]), messageId: id, messageIds: null, }, diff --git a/src/types/index.ts b/src/types/index.ts index ff0c1f88b9..5a35b697d6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -52,6 +52,10 @@ type Metadata = { transformationId: string; }; +type MessageIdMetadataMap = { + [key: string]: Metadata; +}; + type UserTransformationInput = { VersionID: string; ID: string; @@ -223,6 +227,7 @@ type ComparatorInput = { export { Metadata, + MessageIdMetadataMap, UserTransformationLibrary, ProcessorTransformationRequest, ProcessorTransformationResponse, diff --git a/src/v0/destinations/clevertap/transform.js b/src/v0/destinations/clevertap/transform.js index e3d9ca1083..5c1e28c086 100644 --- a/src/v0/destinations/clevertap/transform.js +++ b/src/v0/destinations/clevertap/transform.js @@ -419,7 +419,7 @@ const processRouterDest = (inputs, reqMetadata) => { batchedEvents.forEach((batch) => { const batchedRequest = generateClevertapBatchedPayload(batch.events, batch.destination); batchResponseList.push( - getSuccessRespEvents(batchedRequest, batch.metadata, batch.destination, reqMetadata), + getSuccessRespEvents(batchedRequest, batch.metadata, batch.destination), ); }); } diff --git a/src/v0/destinations/facebook_conversions/config.js b/src/v0/destinations/facebook_conversions/config.js new file mode 100644 index 0000000000..fc04f13be6 --- /dev/null +++ b/src/v0/destinations/facebook_conversions/config.js @@ -0,0 +1,126 @@ +const { getMappingConfig } = require('../../util'); + +const ENDPOINT = (datasetId, accessToken) => + `https://graph.facebook.com/v17.0/${datasetId}/events?access_token=${accessToken}`; + +const CONFIG_CATEGORIES = { + USERDATA: { + standard: false, + type: 'identify', + name: 'FBCUserDataConfig', + }, + COMMON: { name: 'FBCCommonConfig' }, + APPDATA: { name: 'FBCAppEventsConfig' }, + SIMPLE_TRACK: { + standard: false, + type: 'simple track', + name: 'FBCSimpleCustomConfig', + }, + PRODUCT_LIST_VIEWED: { + standard: true, + type: 'product list viewed', + eventName: 'ViewContent', + name: 'FBCProductListViewedCustomData', + }, + PRODUCT_VIEWED: { + standard: true, + type: 'product viewed', + eventName: 'ViewContent', + name: 'FBCProductViewedCustomData', + }, + PRODUCT_ADDED: { + standard: true, + type: 'product added', + eventName: 'AddToCart', + name: 'FBCProductAddedCustomData', + }, + ORDER_COMPLETED: { + standard: true, + type: 'order completed', + eventName: 'Purchase', + name: 'FBCOrderCompletedCustomData', + }, + PRODUCTS_SEARCHED: { + standard: true, + type: 'products searched', + eventName: 'Search', + name: 'FBCProductSearchedCustomData', + }, + CHECKOUT_STARTED: { + standard: true, + type: 'checkout started', + eventName: 'InitiateCheckout', + name: 'FBCCheckoutStartedCustomData', + }, + PAYMENT_INFO_ENTERED: { + standard: true, + type: 'payment info entered', + eventName: 'AddPaymentInfo', + name: 'FBCPaymentInfoEnteredCustomData', + }, + PRODUCT_ADDED_TO_WISHLIST: { + standard: true, + type: 'product added to wishlist', + eventName: 'AddToWishlist', + name: 'FBCProductAddedToWishlistCustomData', + }, + OTHER_STANDARD: { + standard: true, + type: 'otherStandard', + name: 'FBCSimpleCustomConfig', + }, + PAGE_VIEW: { + standard: true, + type: 'page_view', + eventName: 'PageView', + name: 'FBCSimpleCustomConfig', + }, +}; + +const MAPPING_CONFIG = getMappingConfig(CONFIG_CATEGORIES, __dirname); +const ACTION_SOURCES_VALUES = [ + 'email', + 'website', + 'app', + 'phone_call', + 'chat', + 'physical_store', + 'system_generated', + 'other', +]; + +const OTHER_STANDARD_EVENTS = [ + 'Lead', + 'CompleteRegistration', + 'Contact', + 'CustomizeProduct', + 'Donate', + 'FindLocation', + 'Schedule', + 'StartTrial', + 'SubmitApplication', + 'Subscribe', +]; + +const FB_CONVERSIONS_DEFAULT_EXCLUSION = ['opt_out', 'event_id', 'action_source']; +const STANDARD_ECOMM_EVENTS_CATEGORIES = [ + CONFIG_CATEGORIES.PRODUCT_LIST_VIEWED, + CONFIG_CATEGORIES.PRODUCT_VIEWED, + CONFIG_CATEGORIES.PRODUCT_ADDED, + CONFIG_CATEGORIES.ORDER_COMPLETED, + CONFIG_CATEGORIES.PRODUCTS_SEARCHED, + CONFIG_CATEGORIES.CHECKOUT_STARTED, + CONFIG_CATEGORIES.PAYMENT_INFO_ENTERED, + CONFIG_CATEGORIES.PRODUCT_ADDED_TO_WISHLIST, +]; + +module.exports = { + ENDPOINT, + MAPPING_CONFIG, + CONFIG_CATEGORIES, + ACTION_SOURCES_VALUES, + FB_CONVERSIONS_DEFAULT_EXCLUSION, + STANDARD_ECOMM_EVENTS_CATEGORIES, + OTHER_STANDARD_EVENTS, + DESTINATION: 'FACEBOOK_CONVERSIONS', +}; diff --git a/src/v0/destinations/facebook_conversions/data/FBCAppEventsConfig.json b/src/v0/destinations/facebook_conversions/data/FBCAppEventsConfig.json new file mode 100644 index 0000000000..e8e2944ea0 --- /dev/null +++ b/src/v0/destinations/facebook_conversions/data/FBCAppEventsConfig.json @@ -0,0 +1,101 @@ +[ + { + "destKey": "advertiser_tracking_enabled", + "sourceKeys": "context.device.adTrackingEnabled", + "required": true + }, + { + "destKey": "application_tracking_enabled", + "sourceKeys": "properties.application_tracking_enabled", + "required": true + }, + { + "destKey": "extinfo.0", + "sourceKeys": "context.device.type", + "required": true, + "metadata": { + "type": "toLower" + } + }, + { + "destKey": "extinfo.1", + "sourceKeys": "context.app.namespace" + }, + { + "destKey": "extinfo.2", + "sourceKeys": "context.app.build" + }, + { + "destKey": "extinfo.3", + "sourceKeys": "context.app.version" + }, + { + "destKey": "extinfo.4", + "sourceKeys": "context.os.version", + "required": true + }, + { + "destKey": "extinfo.5", + "sourceKeys": "context.device.model" + }, + { + "destKey": "extinfo.6", + "sourceKeys": "context.locale" + }, + { + "destKey": "extinfo.7", + "sourceKeys": "context.abv_timezone" + }, + { + "destKey": "extinfo.8", + "sourceKeys": "context.network.carrier" + }, + { + "destKey": "extinfo.9", + "sourceKeys": "context.screen.width" + }, + { + "destKey": "extinfo.10", + "sourceKeys": "context.screen.height" + }, + { + "destKey": "extinfo.11", + "sourceKeys": "context.screen.density" + }, + { + "destKey": "extinfo.12", + "sourceKeys": "context.cpu_cores" + }, + { + "destKey": "extinfo.13", + "sourceKeys": "context.ext_storage_size" + }, + { + "destKey": "extinfo.14", + "sourceKeys": "context.avl_storage_size" + }, + { + "destKey": "extinfo.15", + "sourceKeys": "context.timezone" + }, + { + "destKey": "campaign_ids", + "sourceKeys": ["properties.campaignId", "context.traits.campaignId", "context.campaign.name"] + }, + { + "destKey": "install_referrer", + "sourceKeys": "properties.install_referrer" + }, + { + "destKey": "installer_package", + "sourceKeys": "properties.installer_package" + }, + { + "destKey": "url_schemes", + "sourceKeys": "properties.url_schemes" + }, + { + "destKey": "windows_attribution_id", + "sourceKeys": "properties.windows_attribution_id" + } +] diff --git a/src/v0/destinations/facebook_conversions/data/FBCCheckoutStartedCustomData.json b/src/v0/destinations/facebook_conversions/data/FBCCheckoutStartedCustomData.json new file mode 100644 index 0000000000..6bef31195b --- /dev/null +++ b/src/v0/destinations/facebook_conversions/data/FBCCheckoutStartedCustomData.json @@ -0,0 +1,53 @@ +[ + { + "destKey": "content_ids", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "contents", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "content_type", + "sourceKeys": "", + "metadata": { + "defaultValue": "product" + } + }, + { + "destKey": "content_category", + "sourceKeys": "properties.category" + }, + { + "destKey": "currency", + "sourceKeys": "properties.currency", + "metadata": { + "defaultValue": "USD" + } + }, + { + "destKey": "value", + "sourceKeys": [ + "properties.revenue", + "properties.value", + "properties.price", + "properties.total" + ], + "metadata": { + "type": "numberForRevenue" + } + }, + { + "destKey": "num_items", + "sourceKeys": "", + "metadata": { + "defaultValue": 0 + } + } +] diff --git a/src/v0/destinations/facebook_conversions/data/FBCCommonConfig.json b/src/v0/destinations/facebook_conversions/data/FBCCommonConfig.json new file mode 100644 index 0000000000..11a81a20ab --- /dev/null +++ b/src/v0/destinations/facebook_conversions/data/FBCCommonConfig.json @@ -0,0 +1,37 @@ +[ + { + "destKey": "event_name", + "sourceKeys": ["event", "type"], + "required": true + }, + { + "destKey": "event_time", + "sourceKeys": "timestamp", + "sourceFromGenericMap": true, + "required": true, + "metadata": { + "type": "secondTimestamp" + } + }, + { + "destKey": "event_source_url", + "sourceKeys": "pageUrl", + "sourceFromGenericMap": true + }, + { + "destKey": "opt_out", + "sourceKeys": ["traits.opt_out", "context.traits.opt_out", "properties.opt_out"] + }, + { + "destKey": "event_id", + "sourceKeys": ["traits.event_id", "context.traits.event_id", "properties.event_id", "messageId"] + }, + { + "destKey": "action_source", + "sourceKeys": [ + "traits.action_source", + "context.traits.action_source", + "properties.action_source" + ] + } +] diff --git a/src/v0/destinations/facebook_conversions/data/FBCOrderCompletedCustomData.json b/src/v0/destinations/facebook_conversions/data/FBCOrderCompletedCustomData.json new file mode 100644 index 0000000000..799ddfcd85 --- /dev/null +++ b/src/v0/destinations/facebook_conversions/data/FBCOrderCompletedCustomData.json @@ -0,0 +1,57 @@ +[ + { + "destKey": "content_ids", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "contents", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "content_type", + "sourceKeys": "", + "metadata": { + "defaultValue": "product" + } + }, + { + "destKey": "content_category", + "sourceKeys": "properties.category" + }, + { + "destKey": "content_name", + "sourceKeys": ["properties.content_name", "properties.contentName"] + }, + { + "destKey": "currency", + "sourceKeys": "properties.currency", + "metadata": { + "defaultValue": "USD" + } + }, + { + "destKey": "value", + "sourceKeys": [ + "properties.revenue", + "properties.value", + "properties.price", + "properties.total" + ], + "metadata": { + "type": "numberForRevenue" + } + }, + { + "destKey": "num_items", + "sourceKeys": "", + "metadata": { + "defaultValue": 0 + } + } +] diff --git a/src/v0/destinations/facebook_conversions/data/FBCPaymentInfoEnteredCustomData.json b/src/v0/destinations/facebook_conversions/data/FBCPaymentInfoEnteredCustomData.json new file mode 100644 index 0000000000..5f346732a1 --- /dev/null +++ b/src/v0/destinations/facebook_conversions/data/FBCPaymentInfoEnteredCustomData.json @@ -0,0 +1,42 @@ +[ + { + "destKey": "content_ids", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "contents", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "content_category", + "sourceKeys": "properties.category", + "metadata": { + "defaultValue": "" + } + }, + { + "destKey": "currency", + "sourceKeys": "properties.currency", + "metadata": { + "defaultValue": "USD" + } + }, + { + "destKey": "value", + "sourceKeys": [ + "properties.revenue", + "properties.value", + "properties.price", + "properties.total" + ], + "metadata": { + "type": "numberForRevenue" + } + } +] diff --git a/src/v0/destinations/facebook_conversions/data/FBCProductAddedCustomData.json b/src/v0/destinations/facebook_conversions/data/FBCProductAddedCustomData.json new file mode 100644 index 0000000000..28e981d9e5 --- /dev/null +++ b/src/v0/destinations/facebook_conversions/data/FBCProductAddedCustomData.json @@ -0,0 +1,56 @@ +[ + { + "destKey": "content_ids", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "contents", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "content_type", + "sourceKeys": "", + "metadata": { + "defaultValue": "product" + } + }, + { + "destKey": "content_category", + "sourceKeys": "properties.category", + "metadata": { + "defaultValue": "" + } + }, + { + "destKey": "content_name", + "sourceKeys": ["properties.name", "properties.product_name"], + "metadata": { + "defaultValue": "" + } + }, + { + "destKey": "currency", + "sourceKeys": "properties.currency", + "metadata": { + "defaultValue": "USD" + } + }, + { + "destKey": "value", + "sourceKeys": [ + "properties.revenue", + "properties.value", + "properties.price", + "properties.total" + ], + "metadata": { + "type": "numberForRevenue" + } + } +] diff --git a/src/v0/destinations/facebook_conversions/data/FBCProductAddedToWishlistCustomData.json b/src/v0/destinations/facebook_conversions/data/FBCProductAddedToWishlistCustomData.json new file mode 100644 index 0000000000..796d7ab3d4 --- /dev/null +++ b/src/v0/destinations/facebook_conversions/data/FBCProductAddedToWishlistCustomData.json @@ -0,0 +1,49 @@ +[ + { + "destKey": "content_ids", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "contents", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "content_category", + "sourceKeys": "properties.category", + "metadata": { + "defaultValue": "" + } + }, + { + "destKey": "content_name", + "sourceKeys": ["properties.name", "properties.product_name"], + "metadata": { + "defaultValue": "" + } + }, + { + "destKey": "currency", + "sourceKeys": "properties.currency", + "metadata": { + "defaultValue": "USD" + } + }, + { + "destKey": "value", + "sourceKeys": [ + "properties.revenue", + "properties.value", + "properties.price", + "properties.total" + ], + "metadata": { + "type": "numberForRevenue" + } + } +] diff --git a/src/v0/destinations/facebook_conversions/data/FBCProductListViewedCustomData.json b/src/v0/destinations/facebook_conversions/data/FBCProductListViewedCustomData.json new file mode 100644 index 0000000000..4a4e29d34e --- /dev/null +++ b/src/v0/destinations/facebook_conversions/data/FBCProductListViewedCustomData.json @@ -0,0 +1,53 @@ +[ + { + "destKey": "content_ids", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "contents", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "content_type", + "sourceKeys": "", + "metadata": { + "defaultValue": "product" + } + }, + { + "destKey": "content_category", + "sourceKeys": "properties.category", + "metadata": { + "defaultValue": "" + } + }, + { + "destKey": "content_name", + "sourceKeys": ["properties.content_name", "properties.contentName"] + }, + { + "destKey": "currency", + "sourceKeys": "properties.currency", + "metadata": { + "defaultValue": "USD" + } + }, + { + "destKey": "value", + "sourceKeys": [ + "properties.revenue", + "properties.value", + "properties.price", + "properties.total" + ], + "metadata": { + "type": "numberForRevenue" + } + } +] diff --git a/src/v0/destinations/facebook_conversions/data/FBCProductSearchedCustomData.json b/src/v0/destinations/facebook_conversions/data/FBCProductSearchedCustomData.json new file mode 100644 index 0000000000..08b2e755ec --- /dev/null +++ b/src/v0/destinations/facebook_conversions/data/FBCProductSearchedCustomData.json @@ -0,0 +1,53 @@ +[ + { + "destKey": "content_ids", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "contents", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "content_type", + "sourceKeys": "", + "metadata": { + "defaultValue": "product" + } + }, + { + "destKey": "content_category", + "sourceKeys": "properties.category", + "metadata": { + "defaultValue": "" + } + }, + { + "destKey": "currency", + "sourceKeys": "properties.currency", + "metadata": { + "defaultValue": "USD" + } + }, + { + "destKey": "value", + "sourceKeys": [ + "properties.revenue", + "properties.value", + "properties.price", + "properties.total" + ], + "metadata": { + "type": "numberForRevenue" + } + }, + { + "destKey": "search_string", + "sourceKeys": "properties.query" + } +] diff --git a/src/v0/destinations/facebook_conversions/data/FBCProductViewedCustomData.json b/src/v0/destinations/facebook_conversions/data/FBCProductViewedCustomData.json new file mode 100644 index 0000000000..28e981d9e5 --- /dev/null +++ b/src/v0/destinations/facebook_conversions/data/FBCProductViewedCustomData.json @@ -0,0 +1,56 @@ +[ + { + "destKey": "content_ids", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "contents", + "sourceKeys": "", + "metadata": { + "defaultValue": [] + } + }, + { + "destKey": "content_type", + "sourceKeys": "", + "metadata": { + "defaultValue": "product" + } + }, + { + "destKey": "content_category", + "sourceKeys": "properties.category", + "metadata": { + "defaultValue": "" + } + }, + { + "destKey": "content_name", + "sourceKeys": ["properties.name", "properties.product_name"], + "metadata": { + "defaultValue": "" + } + }, + { + "destKey": "currency", + "sourceKeys": "properties.currency", + "metadata": { + "defaultValue": "USD" + } + }, + { + "destKey": "value", + "sourceKeys": [ + "properties.revenue", + "properties.value", + "properties.price", + "properties.total" + ], + "metadata": { + "type": "numberForRevenue" + } + } +] diff --git a/src/v0/destinations/facebook_conversions/data/FBCSimpleCustomConfig.json b/src/v0/destinations/facebook_conversions/data/FBCSimpleCustomConfig.json new file mode 100644 index 0000000000..e506b33334 --- /dev/null +++ b/src/v0/destinations/facebook_conversions/data/FBCSimpleCustomConfig.json @@ -0,0 +1,21 @@ +[ + { + "destKey": "value", + "sourceKeys": [ + "properties.revenue", + "properties.value", + "properties.price", + "properties.total" + ], + "metadata": { + "type": "numberForRevenue" + } + }, + { + "destKey": "currency", + "sourceKeys": "properties.currency", + "metadata": { + "defaultValue": "USD" + } + } +] diff --git a/src/v0/destinations/facebook_conversions/data/FBCUserDataConfig.json b/src/v0/destinations/facebook_conversions/data/FBCUserDataConfig.json new file mode 100644 index 0000000000..2d72260ac6 --- /dev/null +++ b/src/v0/destinations/facebook_conversions/data/FBCUserDataConfig.json @@ -0,0 +1,169 @@ +[ + { + "destKey": "external_id", + "sourceKeys": [ + "userId", + "traits.userId", + "traits.id", + "context.traits.userId", + "context.traits.id", + "anonymousId" + ], + "required": true, + "metadata": { + "type": "hashToSha256" + } + }, + { + "destKey": "em", + "sourceKeys": ["traits.email", "context.traits.email"], + "required": false, + "metadata": { + "type": ["trim", "toLower", "hashToSha256"] + } + }, + { + "destKey": "ph", + "sourceKeys": ["traits.phone", "context.traits.phone"], + "required": false, + "metadata": { + "type": "hashToSha256" + } + }, + { + "destKey": "ge", + "sourceKeys": ["traits.gender", "context.traits.gender"], + "required": false, + "metadata": { + "type": "getFbGenderVal" + } + }, + { + "destKey": "db", + "sourceKeys": ["traits.birthday", "context.traits.birthday"], + "required": false, + "metadata": { + "type": "hashToSha256" + } + }, + { + "destKey": "ln", + "sourceKeys": [ + "traits.lastname", + "traits.lastName", + "traits.last_name", + "context.traits.lastname", + "context.traits.lastName", + "context.traits.last_name" + ], + "required": false, + "metadata": { + "type": ["trim", "toLower", "hashToSha256"] + } + }, + { + "destKey": "fn", + "sourceKeys": [ + "traits.firstname", + "traits.firstName", + "traits.first_name", + "context.traits.firstname", + "context.traits.firstName", + "context.traits.first_name" + ], + "required": false, + "metadata": { + "type": ["trim", "toLower", "hashToSha256"] + } + }, + { + "destKey": "name", + "sourceKeys": ["traits.name", "context.traits.name"], + "required": false, + "metadata": { + "type": "toString" + } + }, + { + "destKey": "ct", + "sourceKeys": ["traits.address.city", "context.traits.address.city"], + "required": false, + "metadata": { + "type": ["toLower", "hashToSha256"] + } + }, + { + "destKey": "st", + "sourceKeys": ["traits.address.state", "context.traits.address.state"], + "required": false, + "metadata": { + "type": ["toLower", "hashToSha256"] + } + }, + { + "destKey": "zp", + "sourceKeys": [ + "traits.address.zip", + "context.traits.address.zip", + "traits.address.postalCode", + "context.traits.address.postalCode" + ], + "required": false, + "metadata": { + "type": ["toLower", "hashToSha256"] + } + }, + { + "destKey": "country", + "sourceKeys": ["traits.address.country", "context.traits.address.country"], + "required": false, + "metadata": { + "type": ["toLower", "hashToSha256"] + } + }, + { + "destKey": "client_ip_address", + "sourceKeys": ["context.ip", "request_ip"], + "required": false + }, + { + "destKey": "client_user_agent", + "sourceKeys": "context.userAgent", + "required": false + }, + { + "destKey": "fbc", + "sourceKeys": "context.fbc", + "required": false + }, + { + "destKey": "fbp", + "sourceKeys": "context.fbp", + "required": false + }, + { + "destKey": "subscription_id", + "sourceKeys": "context.subscription_id", + "required": false + }, + { + "destKey": "lead_id", + "sourceKeys": "context.lead_id", + "required": false + }, + { + "destKey": "fb_login_id", + "sourceKeys": "context.fb_login_id", + "required": false + }, + { + "destKey": "madid", + "sourceKeys": "context.device.advertisingId", + "required": false + }, + { + "destKey": "anon_id", + "sourceKeys": ["properties.anon_id", "context.device.advertisingId"], + "required": false + } +] diff --git a/src/v0/destinations/facebook_conversions/networkHandler.js b/src/v0/destinations/facebook_conversions/networkHandler.js new file mode 100644 index 0000000000..0ea7aff7da --- /dev/null +++ b/src/v0/destinations/facebook_conversions/networkHandler.js @@ -0,0 +1,6 @@ +const { networkHandler, errorResponseHandler } = require('../../util/facebookUtils/networkHandler'); + +module.exports = { + networkHandler, + errorResponseHandler, +}; diff --git a/src/v0/destinations/facebook_conversions/transform.js b/src/v0/destinations/facebook_conversions/transform.js new file mode 100644 index 0000000000..dec1ef2e6c --- /dev/null +++ b/src/v0/destinations/facebook_conversions/transform.js @@ -0,0 +1,191 @@ +/* eslint-disable no-param-reassign */ +const get = require('get-value'); +const moment = require('moment'); +const { + CONFIG_CATEGORIES, + MAPPING_CONFIG, + FB_CONVERSIONS_DEFAULT_EXCLUSION, + DESTINATION, + ENDPOINT, +} = require('./config'); +const { EventType } = require('../../../constants'); + +const { + constructPayload, + extractCustomFields, + flattenJson, + getIntegrationsObj, + getValidDynamicFormConfig, + simpleProcessRouterDest, + getHashFromArray, + getFieldValueFromMessage, +} = require('../../util'); + +const { + populateCustomDataBasedOnCategory, + getCategoryFromEvent, + getActionSource, + fetchAppData, +} = require('./utils'); + +const { + transformedPayloadData, + fetchUserData, + formingFinalResponse, +} = require('../../util/facebookUtils'); + +const { InstrumentationError, ConfigurationError } = require('../../util/errorTypes'); + +const responseBuilderSimple = (message, category, destination) => { + const { Config, ID } = destination; + let { categoryToContent } = Config; + if (Array.isArray(categoryToContent)) { + categoryToContent = getValidDynamicFormConfig(categoryToContent, 'from', 'to', DESTINATION, ID); + } + + const { + blacklistPiiProperties, + whitelistPiiProperties, + limitedDataUSage, + testDestination, + testEventCode, + datasetId, + accessToken, + actionSource, + } = Config; + const integrationsObj = getIntegrationsObj(message, DESTINATION.toLowerCase()); + + const userData = fetchUserData( + message, + Config, + MAPPING_CONFIG[CONFIG_CATEGORIES.USERDATA.name], + DESTINATION.toLowerCase(), + ); + + if (category.standard) { + message.event = category.eventName; + } + + const commonData = constructPayload( + message, + MAPPING_CONFIG[CONFIG_CATEGORIES.COMMON.name], + DESTINATION.toLowerCase(), + ); + commonData.action_source = getActionSource(commonData, actionSource); + + let customData = {}; + customData = flattenJson( + extractCustomFields(message, customData, ['properties'], FB_CONVERSIONS_DEFAULT_EXCLUSION), + ); + + customData = transformedPayloadData( + message, + customData, + blacklistPiiProperties, + whitelistPiiProperties, + integrationsObj, + ); + customData = populateCustomDataBasedOnCategory(customData, message, category, categoryToContent); + + if (limitedDataUSage) { + const dataProcessingOptions = get(message, 'context.dataProcessingOptions'); + if (dataProcessingOptions && Array.isArray(dataProcessingOptions)) { + [ + commonData.data_processing_options, + commonData.data_processing_options_country, + commonData.data_processing_options_state, + ] = dataProcessingOptions; + } + } + + let appData = {}; + if (commonData.action_source === 'app') { + appData = fetchAppData(message); + } + + return formingFinalResponse( + userData, + commonData, + customData, + ENDPOINT(datasetId, accessToken), + testDestination, + testEventCode, + appData, + ); +}; + +const processEvent = (message, destination) => { + if (!message.type) { + throw new InstrumentationError("'type' is missing"); + } + + const timeStamp = getFieldValueFromMessage(message, 'timestamp'); + if (timeStamp) { + const start = moment.unix(moment(timeStamp).format('X')); + const current = moment.unix(moment().format('X')); + // calculates past event in days + const deltaDay = Math.ceil(moment.duration(current.diff(start)).asDays()); + // calculates future event in minutes + const deltaMin = Math.ceil(moment.duration(start.diff(current)).asMinutes()); + if (deltaDay > 7 || deltaMin > 1) { + throw new InstrumentationError( + 'Events must be sent within seven days of their occurrence or up to one minute in the future.', + ); + } + } + + const { datasetId, accessToken } = destination.Config; + if (!datasetId) { + throw new ConfigurationError('Dataset Id not found. Aborting'); + } + if (!accessToken) { + throw new ConfigurationError('Access token not found. Aborting'); + } + + let eventsToEvents; + if (Array.isArray(destination.Config.eventsToEvents)) { + eventsToEvents = getValidDynamicFormConfig( + destination.Config.eventsToEvents, + 'from', + 'to', + DESTINATION, + destination.ID, + ); + } + + const messageType = message.type.toLowerCase(); + let category; + let mappedEvent; + switch (messageType) { + case EventType.PAGE: + case EventType.SCREEN: + category = CONFIG_CATEGORIES.PAGE_VIEW; + break; + case EventType.TRACK: + if (!message.event || typeof message.event !== 'string') { + throw new InstrumentationError("'event' is required and should be a string"); + } + if (eventsToEvents) { + const eventMappingHash = getHashFromArray(eventsToEvents); + mappedEvent = eventMappingHash[message.event.toLowerCase()]; + } + category = getCategoryFromEvent(mappedEvent || message.event.toLowerCase()); + break; + default: + throw new InstrumentationError(`Message type ${messageType} not supported`); + } + // build the response + return responseBuilderSimple(message, category, destination); +}; + +const process = (event) => processEvent(event.message, event.destination); + +const processRouterDest = async (inputs, reqMetadata) => { + const respList = await simpleProcessRouterDest(inputs, process, reqMetadata); + return respList; +}; + +module.exports = { + process, + processRouterDest, +}; diff --git a/src/v0/destinations/facebook_conversions/utils.js b/src/v0/destinations/facebook_conversions/utils.js new file mode 100644 index 0000000000..24d0ddc9c2 --- /dev/null +++ b/src/v0/destinations/facebook_conversions/utils.js @@ -0,0 +1,206 @@ +const { + CONFIG_CATEGORIES, + OTHER_STANDARD_EVENTS, + STANDARD_ECOMM_EVENTS_CATEGORIES, + MAPPING_CONFIG, + ACTION_SOURCES_VALUES, + DESTINATION, +} = require('./config'); +const { constructPayload, isObject, isAppleFamily } = require('../../util'); +const { getContentType, getContentCategory } = require('../../util/facebookUtils'); +const { InstrumentationError } = require('../../util/errorTypes'); + +const getActionSource = (payload, fallbackActionSource) => { + let actionSource = fallbackActionSource; + if (payload.action_source) { + const isActionSourceValid = ACTION_SOURCES_VALUES.includes(payload.action_source); + if (!isActionSourceValid) { + throw new InstrumentationError('Invalid Action Source type'); + } + actionSource = payload.action_source; + } + + return actionSource; +}; + +const getCategoryFromEvent = (eventName) => { + let category = STANDARD_ECOMM_EVENTS_CATEGORIES.find( + (configCategory) => eventName === configCategory.type || eventName === configCategory.eventName, + ); + + if (!category && OTHER_STANDARD_EVENTS.includes(eventName)) { + category = CONFIG_CATEGORIES.OTHER_STANDARD; + category.eventName = eventName; + } + + if (!category && eventName === CONFIG_CATEGORIES.PAGE_VIEW.eventName) { + category = CONFIG_CATEGORIES.PAGE_VIEW; + } + + if (!category) { + category = CONFIG_CATEGORIES.SIMPLE_TRACK; + } + + return category; +}; + +const populateContentsAndContentIDs = (productPropertiesArray, fallbackQuantity) => { + const contentIds = []; + const contents = []; + if (Array.isArray(productPropertiesArray)) { + productPropertiesArray.forEach((productProps) => { + if (isObject(productProps)) { + const productId = productProps.product_id || productProps.sku || productProps.id; + if (productId) { + contentIds.push(productId); + contents.push({ + id: productId, + quantity: productProps.quantity || fallbackQuantity || 1, + item_price: productProps.price, + }); + } + } + }); + } + + return { contentIds, contents }; +}; + +const validateProductSearchedData = (eventTypeCustomData) => { + const query = eventTypeCustomData.search_string; + const validQueryType = ['string', 'number', 'boolean']; + if (query && !validQueryType.includes(typeof query)) { + throw new InstrumentationError("'query' should be in string format only"); + } +}; + +const populateCustomDataBasedOnCategory = (customData, message, category, categoryToContent) => { + let eventTypeCustomData = {}; + if (category.name) { + eventTypeCustomData = constructPayload(message, MAPPING_CONFIG[category.name]); + } + + switch (category.type) { + case 'product list viewed': { + const { contentIds, contents } = populateContentsAndContentIDs( + message.properties?.products, + message.properties?.quantity, + ); + + const contentCategory = eventTypeCustomData.content_category; + let contentType; + if (contentIds.length > 0) { + contentType = 'product'; + } else if (contentCategory) { + contentIds.push(contentCategory); + contents.push({ + id: contentCategory, + quantity: 1, + }); + contentType = 'product_group'; + } + + eventTypeCustomData = { + ...eventTypeCustomData, + content_ids: contentIds, + contents, + content_type: getContentType( + message, + contentType, + categoryToContent, + DESTINATION.toLowerCase(), + ), + content_category: getContentCategory(contentCategory), + }; + break; + } + case 'product added': + case 'product viewed': + case 'products searched': + case 'payment info entered': + case 'product added to wishlist': { + const contentCategory = eventTypeCustomData.content_category; + const contentType = eventTypeCustomData.content_type; + const { contentIds, contents } = populateContentsAndContentIDs([message.properties]); + eventTypeCustomData = { + ...eventTypeCustomData, + content_ids: contentIds, + contents, + content_type: getContentType( + message, + contentType, + categoryToContent, + DESTINATION.toLowerCase(), + ), + content_category: getContentCategory(contentCategory), + }; + validateProductSearchedData(eventTypeCustomData); + break; + } + case 'order completed': + case 'checkout started': { + const { contentIds, contents } = populateContentsAndContentIDs( + message.properties?.products, + message.properties?.quantity, + ); + + const contentCategory = eventTypeCustomData.content_category; + const contentType = eventTypeCustomData.content_type; + + eventTypeCustomData = { + ...eventTypeCustomData, + content_ids: contentIds, + contents, + content_type: getContentType( + message, + contentType, + categoryToContent, + DESTINATION.toLowerCase(), + ), + content_category: getContentCategory(contentCategory), + num_items: contentIds.length, + }; + break; + } + case 'page_view': + case 'otherStandard': + case 'simple track': + default: + eventTypeCustomData = { ...eventTypeCustomData }; + break; + } + + return { ...customData, ...eventTypeCustomData }; +}; + +const fetchAppData = (message) => { + const appData = constructPayload( + message, + MAPPING_CONFIG[CONFIG_CATEGORIES.APPDATA.name], + 'fb_pixel', + ); + + if (appData) { + let sourceSDK = appData.extinfo[0]; + if (sourceSDK === 'android') { + sourceSDK = 'a2'; + } else if (isAppleFamily(sourceSDK)) { + sourceSDK = 'i2'; + } else { + // if the sourceSDK is not android or ios + throw new InstrumentationError( + 'Extended device information i.e, "context.device.type" is not a valid value. It should be either android or ios/watchos/ipados/tvos', + ); + } + appData.extinfo[0] = sourceSDK; + } + + return appData; +}; + +module.exports = { + fetchAppData, + getActionSource, + getCategoryFromEvent, + populateCustomDataBasedOnCategory, +}; diff --git a/src/v0/destinations/facebook_pixel/networkHandler.js b/src/v0/destinations/facebook_pixel/networkHandler.js index f02453e1eb..0ea7aff7da 100644 --- a/src/v0/destinations/facebook_pixel/networkHandler.js +++ b/src/v0/destinations/facebook_pixel/networkHandler.js @@ -1,220 +1,4 @@ -const { isEmpty } = require('lodash'); -const get = require('get-value'); -const { - processAxiosResponse, - getDynamicErrorType, -} = require('../../../adapters/utils/networkUtils'); -const { prepareProxyRequest, proxyRequest } = require('../../../adapters/network'); -const { NetworkError } = require('../../util/errorTypes'); -const tags = require('../../util/tags'); -const { ErrorDetailsExtractorBuilder } = require('../../../util/error-extractor'); - -/** - * Only under below mentioned scenario(s), add the errorCodes, subCodes etc,. to this map - * - * The actual API reference doc to which events from Rudderstack are being sent - * https://developers.facebook.com/docs/marketing-api/reference/ads-pixel/events/v13.0 - * - * The documents referred while formulating the error responses - * 1. https://developers.facebook.com/docs/graph-api/guides/error-handling/ - * - This seems like a generic document that contains errors possible for Graph API - * 2. https://developers.facebook.com/docs/marketing-api/error-reference/ - * - The doc seems to be more related to Marketing API - * 3. https://developers.facebook.com/docs/marketing-api/error-reference/ -{ - // A scenario where in we have to know the error with code and subcode, we can conclude that - // this error will have a particular status, statTags - code1: { - subCode1: { - status: , - // Only tags that are unique to this error needs to be provided - statTags: {}, - messageDetails: { - message: "", - field: - }, - - } - } -} - */ -const errorDetailsMap = { - 100: { - // This error talks about event being sent after seven days or so - 2804003: new ErrorDetailsExtractorBuilder() - .setStatus(400) - .setMessageField('error_user_title') - .build(), - 2804001: new ErrorDetailsExtractorBuilder() - .setStatus(400) - .setMessageField('error_user_title') - .build(), - 2804007: new ErrorDetailsExtractorBuilder() - .setStatus(400) - .setMessageField('error_user_title') - .build(), - 2804016: new ErrorDetailsExtractorBuilder() - .setStatus(400) - .setMessageField('error_user_title') - .build(), - 2804017: new ErrorDetailsExtractorBuilder() - .setStatus(400) - .setMessageField('error_user_title') - .build(), - 2804019: new ErrorDetailsExtractorBuilder() - .setStatus(400) - .setMessageField('error_user_title') - .build(), - 2804048: new ErrorDetailsExtractorBuilder() - .setStatus(400) - .setMessageField('error_user_title') - .build(), - // This error-subcode indicates that the business access token expired or is invalid or sufficient permissions are not provided - // since there is involvement of changes required on dashboard to make event successful - // for now, we are aborting this error-subCode combination - 33: new ErrorDetailsExtractorBuilder() - .setStatus(400) - .setMessage( - "Object with ID 'PIXEL_ID' does not exist, cannot be loaded due to missing permissions, or does not support this operation", - ) - .build(), - default: new ErrorDetailsExtractorBuilder() - .setStatus(400) - .setMessage('Invalid Parameter') - .build(), - }, - 1: { - // An unknown error occurred. - // This error may occur if you set level to adset but the correct value should be campaign - 99: new ErrorDetailsExtractorBuilder() - .setStatus(500) - .setMessage( - 'This error may occur if you set level to adset but the correct value should be campaign', - ) - .build(), - default: new ErrorDetailsExtractorBuilder() - .setStatus(500) - .setMessage('An unknown error occurred') - .build(), - }, - 190: { - 460: new ErrorDetailsExtractorBuilder() - .setStatus(400) - .setMessage( - 'The session has been invalidated because the user changed their password or Facebook has changed the session for security reasons', - ) - .build(), - default: new ErrorDetailsExtractorBuilder() - .setStatus(400) - .setMessage('Invalid OAuth 2.0 access token') - .build(), - }, - 3: { - default: new ErrorDetailsExtractorBuilder() - .setStatus(400) - .setMessage('Capability or permissions issue.') - .build(), - }, - 2: { - default: new ErrorDetailsExtractorBuilder() - .setStatus(500) - .setMessage('Temporary issue due to downtime.') - .build(), - }, - 341: { - default: new ErrorDetailsExtractorBuilder() - .setStatus(500) - .setMessage('Application limit reached: Temporary issue due to downtime or throttling') - .build(), - }, - 368: { - default: new ErrorDetailsExtractorBuilder() - .setStatus(500) - .setMessage('Temporarily blocked for policies violations.') - .build(), - }, - 5000: { - default: new ErrorDetailsExtractorBuilder() - .setStatus(500) - .setMessage('Unknown Error Code') - .build(), - }, - 4: { - default: new ErrorDetailsExtractorBuilder() - .setStatus(429) - .setMessage('API Too Many Calls') - .build(), - }, - 17: { - default: new ErrorDetailsExtractorBuilder() - .setStatus(429) - .setMessage('API User Too Many Calls') - .build(), - }, -}; - -const getErrorDetailsFromErrorMap = (error) => { - const { code, error_subcode: subCode } = error; - let errDetails; - if (!isEmpty(errorDetailsMap[code])) { - errDetails = errorDetailsMap[code][subCode] || errorDetailsMap[code]?.default; - } - return errDetails; -}; - -const getStatus = (error) => { - const errorDetail = getErrorDetailsFromErrorMap(error); - let errorStatus = 500; - const isErrorDetailEmpty = isEmpty(errorDetail); - if (isErrorDetailEmpty) { - // Unhandled error response - return {status: errorStatus, tags: { [tags.TAG_NAMES.META]: tags.METADATA.UNHANDLED_STATUS_CODE, } } - } - errorStatus = errorDetail.status; - - let errorMessage = errorDetail?.messageDetails?.message; - if (errorDetail?.messageDetails?.field) { - errorMessage = get(error, errorDetail?.messageDetails?.field); - } - - return { status: errorStatus, errorMessage }; -}; - -const errorResponseHandler = (destResponse) => { - const { response } = destResponse; - if (!response.error) { - // successful response from facebook pixel api - return; - } - const { error } = response; - const { status, errorMessage, tags: errorStatTags } = getStatus(error); - throw new NetworkError( - `${errorMessage || error.message || 'Unknown failure during response transformation'}`, - status, - { - ...errorStatTags, - [tags.TAG_NAMES.ERROR_TYPE]: getDynamicErrorType(status), - }, - { ...response, status: destResponse.status }, - ); -}; - -const destResponseHandler = (destinationResponse) => { - errorResponseHandler(destinationResponse); - return { - destinationResponse: destinationResponse.response, - message: 'Request Processed Successfully', - status: destinationResponse.status, - }; -}; - -function networkHandler() { - // The order of execution also happens in this way - this.prepareProxy = prepareProxyRequest; - this.proxy = proxyRequest; - this.processAxiosResponse = processAxiosResponse; - this.responseHandler = destResponseHandler; -} +const { networkHandler, errorResponseHandler } = require('../../util/facebookUtils/networkHandler'); module.exports = { networkHandler, diff --git a/src/v0/destinations/facebook_pixel/transform.js b/src/v0/destinations/facebook_pixel/transform.js index 48c4c30563..02c416cfde 100644 --- a/src/v0/destinations/facebook_pixel/transform.js +++ b/src/v0/destinations/facebook_pixel/transform.js @@ -21,18 +21,21 @@ const { } = require('../../util'); const { - transformedPayloadData, getActionSource, - fetchUserData, handleProduct, handleSearch, handleProductListViewed, handleOrder, - formingFinalResponse, populateCustomDataBasedOnCategory, getCategoryFromEvent, } = require('./utils'); +const { + transformedPayloadData, + fetchUserData, + formingFinalResponse, +} = require('../../util/facebookUtils'); + const { InstrumentationError, ConfigurationError } = require('../../util/errorTypes'); const responseBuilderSimple = (message, category, destination) => { @@ -64,7 +67,12 @@ const responseBuilderSimple = (message, category, destination) => { const endpoint = `https://graph.facebook.com/v17.0/${pixelId}/events?access_token=${accessToken}`; - const userData = fetchUserData(message, Config); + const userData = fetchUserData( + message, + Config, + MAPPING_CONFIG[CONFIG_CATEGORIES.USERDATA.name], + 'fb_pixel', + ); const commonData = constructPayload( message, diff --git a/src/v0/destinations/facebook_pixel/utils.js b/src/v0/destinations/facebook_pixel/utils.js index 7e4a644a4a..d642c446fd 100644 --- a/src/v0/destinations/facebook_pixel/utils.js +++ b/src/v0/destinations/facebook_pixel/utils.js @@ -1,22 +1,7 @@ -const sha256 = require('sha256'); -const { - isObject, - getFieldValueFromMessage, - formatTimeStamp, - getIntegrationsObj, - constructPayload, - defaultPostRequestConfig, - defaultRequestConfig, - getHashFromArray, -} = require('../../util'); -const { - ACTION_SOURCES_VALUES, - CONFIG_CATEGORIES, - MAPPING_CONFIG, - OTHER_STANDARD_EVENTS, -} = require('./config'); - -const { InstrumentationError, TransformationError } = require('../../util/errorTypes'); +const { isObject } = require('../../util'); +const { ACTION_SOURCES_VALUES, CONFIG_CATEGORIES, OTHER_STANDARD_EVENTS } = require('./config'); +const { getContentType, getContentCategory } = require('../../util/facebookUtils'); +const { InstrumentationError } = require('../../util/errorTypes'); /** format revenue according to fb standards with max two decimal places. * @param revenue @@ -31,195 +16,6 @@ const formatRevenue = (revenue) => { throw new InstrumentationError('Revenue could not be converted to number'); }; -/** - * - * @param {*} message Rudder Payload - * @param {*} defaultValue product / product_group - * @param {*} categoryToContent example: [ { from: 'clothing', to: 'product' } ] - * - * We will be mapping properties.category to user provided content else taking the default value as per ecomm spec - * If category is clothing it will be set to ["product"] - * @return Content Type array as defined in: - * - https://developers.facebook.com/docs/facebook-pixel/reference/#object-properties - */ -const getContentType = (message, defaultValue, categoryToContent) => { - const { properties } = message; - const integrationsObj = getIntegrationsObj(message, 'fb_pixel'); - - if (integrationsObj?.contentType) { - return integrationsObj.contentType; - } - - let { category } = properties; - if (!category) { - const { products } = properties; - if (products && products.length > 0 && Array.isArray(products) && isObject(products[0])) { - category = products[0].category; - } - } - - if (Array.isArray(categoryToContent) && category) { - const categoryToContentHash = getHashFromArray(categoryToContent, 'from', 'to', false); - if (categoryToContentHash[category]) { - return categoryToContentHash[category]; - } - } - - return defaultValue; -}; - -/** This function transforms the payloads according to the config settings and adds, removes or hashes pii data. -@param message --> the rudder payload - -{ - anonymousId: 'c82cbdff-e5be-4009-ac78-cdeea09ab4b1', - destination_props: { Fb: { app_id: 'RudderFbApp' } }, - 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' }, - screen: { height: '100', density: 50 }, - traits: { - email: 'abc@gmail.com', - anonymousId: 'c82cbdff-e5be-4009-ac78-cdeea09ab4b1' - } - }, - event: 'spin_result', - integrations: { - All: true, - FacebookPixel: { - dataProcessingOptions: [Array], - fbc: 'fb.1.1554763741205.AbCdEfGhIjKlMnOpQrStUvWxYz1234567890', - fbp: 'fb.1.1554763741205.234567890', - fb_login_id: 'fb_id', - lead_id: 'lead_id' - } - }, - message_id: 'a80f82be-9bdc-4a9f-b2a5-15621ee41df8', - properties: { revenue: 400, additional_bet_index: 0 }, - timestamp: '2019-09-01T15:46:51.693229+05:30', - type: 'track' - } - -@param customData --> properties -{ revenue: 400, additional_bet_index: 0 } - -@param blacklistPiiProperties --> -[ { blacklistPiiProperties: 'phone', blacklistPiiHash: true } ] // hashes the phone property - -@param whitelistPiiProperties --> -[ { whitelistPiiProperties: 'email' } ] // sets email - - -@param integrationsObj --> -{ hashed: true } - -*/ - -const transformedPayloadData = ( - message, - customData, - blacklistPiiProperties, - whitelistPiiProperties, - integrationsObj, -) => { - const defaultPiiProperties = [ - 'email', - 'firstName', - 'lastName', - 'firstname', - 'lastname', - 'first_name', - 'last_name', - 'gender', - 'city', - 'country', - 'phone', - 'state', - 'zip', - 'postalCode', - 'birthday', - ]; - const clonedCustomData = { ...customData }; - const finalBlacklistPiiProperties = blacklistPiiProperties || []; - const finalWhitelistPiiProperties = whitelistPiiProperties || []; - const customBlackListedPiiProperties = {}; - - // create list of whitelisted properties - const customWhiteListedProperties = finalWhitelistPiiProperties.map( - (propObject) => propObject.whitelistPiiProperties, - ); - - // create map of blacklisted properties - finalBlacklistPiiProperties.forEach((property) => { - const singularConfigInstance = property; - customBlackListedPiiProperties[singularConfigInstance.blacklistPiiProperties] = - singularConfigInstance.blacklistPiiHash; - }); - - // remove properties which are default pii properties and not whitelisted - Object.keys(clonedCustomData).forEach((eventProp) => { - const isDefaultPiiProperty = defaultPiiProperties.includes(eventProp); - const isProperyWhiteListed = customWhiteListedProperties.includes(eventProp); - - if (Object.prototype.hasOwnProperty.call(customBlackListedPiiProperties, eventProp)) { - if (customBlackListedPiiProperties[eventProp]) { - // if customBlackListedPiiProperty is marked to be hashed from UI - clonedCustomData[eventProp] = integrationsObj?.hashed - ? String(message.properties[eventProp]) - : sha256(String(message.properties[eventProp])); - } else if (isDefaultPiiProperty && !isProperyWhiteListed) { - delete clonedCustomData[eventProp]; - } - } else if (isDefaultPiiProperty && !isProperyWhiteListed) { - delete clonedCustomData[eventProp]; - } - }); - - return clonedCustomData; -}; - -/** - * - * @param {*} message - * @returns string which is fbc parameter - * - * version : "fb" (default) - * - * subdomainIndex : 1 ( recommended by facebook, as well as our JS SDK sets cookies on the main domain, i.e "facebook.com") - * - * creationTime : mapped to originalTimestamp converted in miliseconds - * - * fbclid : deduced query paramter from context.page.url - * - * ref: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc#fbc - */ -const deduceFbcParam = (message) => { - const url = message.context?.page?.url; - if (!url) { - return undefined; - } - let parseUrl; - try { - parseUrl = new URL(url); - } catch { - return undefined; - } - const paramsList = new URLSearchParams(parseUrl.search); - const fbclid = paramsList.get('fbclid'); - - if (!fbclid) { - return undefined; - } - const creationTime = getFieldValueFromMessage(message, 'timestamp'); - return `fb.1.${formatTimeStamp(creationTime)}.${fbclid}`; -}; - /** * Returns action source * ref : https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event#action-source @@ -244,61 +40,6 @@ const getActionSource = (payload, channel) => { return actionSource; }; -/** - * This method gets content category with proper error-handling - * - * @param {*} category - * @returns The content category as a string - */ -const getContentCategory = (category) => { - let contentCategory = category; - if (Array.isArray(contentCategory)) { - contentCategory = contentCategory.map(String).join(','); - } - if ( - contentCategory && - typeof contentCategory !== 'string' && - typeof contentCategory !== 'object' - ) { - contentCategory = String(contentCategory); - } - if ( - contentCategory && - typeof contentCategory !== 'string' && - !Array.isArray(contentCategory) && - typeof contentCategory === 'object' - ) { - throw new InstrumentationError("'properties.category' must be either be a string or an array"); - } - return contentCategory; -}; - -const fetchUserData = (message, Config) => { - const integrationsObj = getIntegrationsObj(message, 'fb_pixel'); - const userData = constructPayload( - message, - MAPPING_CONFIG[CONFIG_CATEGORIES.USERDATA.name], - 'fb_pixel', - ); - const { removeExternalId } = Config; - if (removeExternalId) { - delete userData.external_id; - } - - if (userData) { - const split = userData.name?.split(' '); - if (split && split.length === 2) { - const hashValue = (value) => (integrationsObj?.hashed ? value : sha256(value)); - userData.fn = hashValue(split[0]); - userData.ln = hashValue(split[1]); - } - delete userData.name; - userData.fbc = userData.fbc || deduceFbcParam(message); - } - - return userData; -}; - /** * * @param {*} message Rudder element @@ -570,51 +311,13 @@ const getCategoryFromEvent = (eventName) => { return category; }; -const formingFinalResponse = ( - userData, - commonData, - customData, - endpoint, - testDestination, - testEventCode, -) => { - if (userData && commonData) { - const response = defaultRequestConfig(); - response.endpoint = endpoint; - response.method = defaultPostRequestConfig.requestMethod; - const jsonStringify = JSON.stringify({ - user_data: userData, - ...commonData, - custom_data: customData, - }); - const payload = { - data: [jsonStringify], - }; - - // Ref: https://developers.facebook.com/docs/marketing-api/conversions-api/using-the-api/ - // Section: Test Events Tool - if (testDestination) { - payload.test_event_code = testEventCode; - } - response.body.FORM = payload; - return response; - } - // fail-safety for developer error - throw new TransformationError('Payload could not be constructed'); -}; - module.exports = { - deduceFbcParam, formatRevenue, - getContentType, - transformedPayloadData, getActionSource, - fetchUserData, handleProduct, handleSearch, handleProductListViewed, handleOrder, - formingFinalResponse, populateCustomDataBasedOnCategory, getCategoryFromEvent, }; diff --git a/src/v0/util/facebookUtils/index.js b/src/v0/util/facebookUtils/index.js new file mode 100644 index 0000000000..d39d1c4ce9 --- /dev/null +++ b/src/v0/util/facebookUtils/index.js @@ -0,0 +1,301 @@ +const sha256 = require('sha256'); +const { + isObject, + getIntegrationsObj, + getHashFromArray, + constructPayload, + defaultRequestConfig, + defaultPostRequestConfig, + getFieldValueFromMessage, + formatTimeStamp, +} = require('../index'); +const { InstrumentationError, TransformationError } = require('../errorTypes'); + +/** This function transforms the payloads according to the config settings and adds, removes or hashes pii data. + @param message --> the rudder payload + + { + anonymousId: 'c82cbdff-e5be-4009-ac78-cdeea09ab4b1', + destination_props: { Fb: { app_id: 'RudderFbApp' } }, + 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' }, + screen: { height: '100', density: 50 }, + traits: { + email: 'abc@gmail.com', + anonymousId: 'c82cbdff-e5be-4009-ac78-cdeea09ab4b1' + } + }, + event: 'spin_result', + integrations: { + All: true, + FacebookPixel: { + dataProcessingOptions: [Array], + fbc: 'fb.1.1554763741205.AbCdEfGhIjKlMnOpQrStUvWxYz1234567890', + fbp: 'fb.1.1554763741205.234567890', + fb_login_id: 'fb_id', + lead_id: 'lead_id' + } + }, + message_id: 'a80f82be-9bdc-4a9f-b2a5-15621ee41df8', + properties: { revenue: 400, additional_bet_index: 0 }, + timestamp: '2019-09-01T15:46:51.693229+05:30', + type: 'track' + } + + @param customData --> properties + { revenue: 400, additional_bet_index: 0 } + + @param blacklistPiiProperties --> + [ { blacklistPiiProperties: 'phone', blacklistPiiHash: true } ] // hashes the phone property + + @param whitelistPiiProperties --> + [ { whitelistPiiProperties: 'email' } ] // sets email + + + @param integrationsObj --> + { hashed: true } + + */ + +const transformedPayloadData = ( + message, + customData, + blacklistPiiProperties, + whitelistPiiProperties, + integrationsObj, +) => { + const defaultPiiProperties = [ + 'email', + 'firstName', + 'lastName', + 'firstname', + 'lastname', + 'first_name', + 'last_name', + 'gender', + 'city', + 'country', + 'phone', + 'state', + 'zip', + 'postalCode', + 'birthday', + ]; + const clonedCustomData = { ...customData }; + const finalBlacklistPiiProperties = blacklistPiiProperties || []; + const finalWhitelistPiiProperties = whitelistPiiProperties || []; + const customBlackListedPiiProperties = {}; + + // create list of whitelisted properties + const customWhiteListedProperties = finalWhitelistPiiProperties.map( + (propObject) => propObject.whitelistPiiProperties, + ); + + // create map of blacklisted properties + finalBlacklistPiiProperties.forEach((property) => { + const singularConfigInstance = property; + customBlackListedPiiProperties[singularConfigInstance.blacklistPiiProperties] = + singularConfigInstance.blacklistPiiHash; + }); + + // remove properties which are default pii properties and not whitelisted + Object.keys(clonedCustomData).forEach((eventProp) => { + const isDefaultPiiProperty = defaultPiiProperties.includes(eventProp); + const isProperyWhiteListed = customWhiteListedProperties.includes(eventProp); + + if (Object.hasOwn(customBlackListedPiiProperties, eventProp)) { + if (customBlackListedPiiProperties[eventProp]) { + // if customBlackListedPiiProperty is marked to be hashed from UI + clonedCustomData[eventProp] = integrationsObj?.hashed + ? String(message.properties[eventProp]) + : sha256(String(message.properties[eventProp])); + } else if (isDefaultPiiProperty && !isProperyWhiteListed) { + delete clonedCustomData[eventProp]; + } + } else if (isDefaultPiiProperty && !isProperyWhiteListed) { + delete clonedCustomData[eventProp]; + } + }); + + return clonedCustomData; +}; + +/** + * + * @param {*} message Rudder Payload + * @param {*} defaultValue product / product_group + * @param {*} categoryToContent example: [ { from: 'clothing', to: 'product' } ] + * @param {*} destinationName destination name + * + * We will be mapping properties.category to user provided content else taking the default value as per ecomm spec + * If category is clothing it will be set to ["product"] + * @return Content Type array as defined in: + * - https://developers.facebook.com/docs/facebook-pixel/reference/#object-properties + */ +const getContentType = (message, defaultValue, categoryToContent, destinationName) => { + const { properties } = message; + const integrationsObj = getIntegrationsObj(message, destinationName || 'fb_pixel'); + + if (integrationsObj?.contentType) { + return integrationsObj.contentType; + } + + let { category } = properties; + if (!category) { + const { products } = properties; + if (products && products.length > 0 && Array.isArray(products) && isObject(products[0])) { + category = products[0].category; + } + } + + if (Array.isArray(categoryToContent) && category) { + const categoryToContentHash = getHashFromArray(categoryToContent, 'from', 'to', false); + if (categoryToContentHash[category]) { + return categoryToContentHash[category]; + } + } + + return defaultValue; +}; + +/** + * This method gets content category with proper error-handling + * + * @param {*} category + * @returns The content category as a string + */ +const getContentCategory = (category) => { + let contentCategory = category; + if (Array.isArray(contentCategory)) { + contentCategory = contentCategory.map(String).join(','); + } + if ( + contentCategory && + typeof contentCategory !== 'string' && + typeof contentCategory !== 'object' + ) { + contentCategory = String(contentCategory); + } + if ( + contentCategory && + typeof contentCategory !== 'string' && + !Array.isArray(contentCategory) && + typeof contentCategory === 'object' + ) { + throw new InstrumentationError("'properties.category' must be either be a string or an array"); + } + return contentCategory; +}; + +/** + * + * @param {*} message + * @returns string which is fbc parameter + * + * version : "fb" (default) + * + * subdomainIndex : 1 ( recommended by facebook, as well as our JS SDK sets cookies on the main domain, i.e "facebook.com") + * + * creationTime : mapped to originalTimestamp converted in miliseconds + * + * fbclid : deduced query paramter from context.page.url + * + * ref: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc#fbc + */ +const deduceFbcParam = (message) => { + const url = message.context?.page?.url; + if (!url) { + return undefined; + } + let parseUrl; + try { + parseUrl = new URL(url); + } catch { + return undefined; + } + const paramsList = new URLSearchParams(parseUrl.search); + const fbclid = paramsList.get('fbclid'); + + if (!fbclid) { + return undefined; + } + const creationTime = getFieldValueFromMessage(message, 'timestamp'); + return `fb.1.${formatTimeStamp(creationTime)}.${fbclid}`; +}; + +const fetchUserData = (message, Config, mappingJson, destinationName) => { + const integrationsObj = getIntegrationsObj(message, destinationName); + const userData = constructPayload(message, mappingJson, destinationName); + const { removeExternalId } = Config; + if (removeExternalId) { + delete userData.external_id; + } + + if (userData) { + const split = userData.name?.split(' '); + if (split && split.length === 2) { + const hashValue = (value) => (integrationsObj?.hashed ? value : sha256(value)); + userData.fn = hashValue(split[0]); + userData.ln = hashValue(split[1]); + } + delete userData.name; + userData.fbc = userData.fbc || deduceFbcParam(message); + } + + return userData; +}; + +const formingFinalResponse = ( + userData, + commonData, + customData, + endpoint, + testDestination, + testEventCode, + appData, +) => { + if (userData && commonData) { + const response = defaultRequestConfig(); + response.endpoint = endpoint; + response.method = defaultPostRequestConfig.requestMethod; + const jsonData = { + user_data: userData, + ...commonData, + }; + if (appData && Object.keys(appData).length > 0) { + jsonData.app_data = appData; + } + if (customData && Object.keys(customData).length > 0) { + jsonData.custom_data = customData; + } + const jsonStringify = JSON.stringify(jsonData); + const payload = { + data: [jsonStringify], + }; + + // Ref: https://developers.facebook.com/docs/marketing-api/conversions-api/using-the-api/ + // Section: Test Events Tool + if (testDestination) { + payload.test_event_code = testEventCode; + } + response.body.FORM = payload; + return response; + } + // fail-safety for developer error + throw new TransformationError('Payload could not be constructed'); +}; + +module.exports = { + getContentType, + getContentCategory, + transformedPayloadData, + formingFinalResponse, + fetchUserData, +}; diff --git a/src/v0/destinations/facebook_pixel/utils.test.js b/src/v0/util/facebookUtils/index.test.js similarity index 98% rename from src/v0/destinations/facebook_pixel/utils.test.js rename to src/v0/util/facebookUtils/index.test.js index acd14ad2a8..98e4ccec40 100644 --- a/src/v0/destinations/facebook_pixel/utils.test.js +++ b/src/v0/util/facebookUtils/index.test.js @@ -1,4 +1,4 @@ -const { transformedPayloadData } = require('../../../../src/v0/destinations/facebook_pixel/utils'); +const { transformedPayloadData } = require('./index'); const sha256 = require('sha256'); describe('transformedPayloadData_function', () => { diff --git a/src/v0/util/facebookUtils/networkHandler.js b/src/v0/util/facebookUtils/networkHandler.js new file mode 100644 index 0000000000..d53d0f76f9 --- /dev/null +++ b/src/v0/util/facebookUtils/networkHandler.js @@ -0,0 +1,225 @@ +const { isEmpty } = require('lodash'); +const get = require('get-value'); +const { + processAxiosResponse, + getDynamicErrorType, +} = require('../../../adapters/utils/networkUtils'); +const { prepareProxyRequest, proxyRequest } = require('../../../adapters/network'); +const { NetworkError } = require('../errorTypes'); +const tags = require('../tags'); +const { ErrorDetailsExtractorBuilder } = require('../../../util/error-extractor'); + +/** + * Only under below mentioned scenario(s), add the errorCodes, subCodes etc,. to this map + * + * The actual API reference doc to which events from Rudderstack are being sent + * https://developers.facebook.com/docs/marketing-api/reference/ads-pixel/events/v13.0 + * + * The documents referred while formulating the error responses + * 1. https://developers.facebook.com/docs/graph-api/guides/error-handling/ + * - This seems like a generic document that contains errors possible for Graph API + * 2. https://developers.facebook.com/docs/marketing-api/error-reference/ + * - The doc seems to be more related to Marketing API + * 3. https://developers.facebook.com/docs/marketing-api/error-reference/ +{ + // A scenario where in we have to know the error with code and subcode, we can conclude that + // this error will have a particular status, statTags + code1: { + subCode1: { + status: , + // Only tags that are unique to this error needs to be provided + statTags: {}, + messageDetails: { + message: "", + field: + }, + + } + } +} + */ +const errorDetailsMap = { + 100: { + // This error talks about event being sent after seven days or so + 2804003: new ErrorDetailsExtractorBuilder() + .setStatus(400) + .setMessageField('error_user_title') + .build(), + 2804001: new ErrorDetailsExtractorBuilder() + .setStatus(400) + .setMessageField('error_user_title') + .build(), + 2804007: new ErrorDetailsExtractorBuilder() + .setStatus(400) + .setMessageField('error_user_title') + .build(), + 2804016: new ErrorDetailsExtractorBuilder() + .setStatus(400) + .setMessageField('error_user_title') + .build(), + 2804017: new ErrorDetailsExtractorBuilder() + .setStatus(400) + .setMessageField('error_user_title') + .build(), + 2804019: new ErrorDetailsExtractorBuilder() + .setStatus(400) + .setMessageField('error_user_title') + .build(), + 2804048: new ErrorDetailsExtractorBuilder() + .setStatus(400) + .setMessageField('error_user_title') + .build(), + // This error-subcode indicates that the business access token expired or is invalid or sufficient permissions are not provided + // since there is involvement of changes required on dashboard to make event successful + // for now, we are aborting this error-subCode combination + 33: new ErrorDetailsExtractorBuilder() + .setStatus(400) + .setMessage( + "Object with ID 'PIXEL_ID' does not exist, cannot be loaded due to missing permissions, or does not support this operation", + ) + .build(), + default: new ErrorDetailsExtractorBuilder() + .setStatus(400) + .setMessage('Invalid Parameter') + .build(), + }, + 1: { + // An unknown error occurred. + // This error may occur if you set level to adset but the correct value should be campaign + 99: new ErrorDetailsExtractorBuilder() + .setStatus(500) + .setMessage( + 'This error may occur if you set level to adset but the correct value should be campaign', + ) + .build(), + default: new ErrorDetailsExtractorBuilder() + .setStatus(500) + .setMessage('An unknown error occurred') + .build(), + }, + 190: { + 460: new ErrorDetailsExtractorBuilder() + .setStatus(400) + .setMessage( + 'The session has been invalidated because the user changed their password or Facebook has changed the session for security reasons', + ) + .build(), + default: new ErrorDetailsExtractorBuilder() + .setStatus(400) + .setMessage('Invalid OAuth 2.0 access token') + .build(), + }, + 3: { + default: new ErrorDetailsExtractorBuilder() + .setStatus(400) + .setMessage('Capability or permissions issue.') + .build(), + }, + 2: { + default: new ErrorDetailsExtractorBuilder() + .setStatus(500) + .setMessage('Temporary issue due to downtime.') + .build(), + }, + 341: { + default: new ErrorDetailsExtractorBuilder() + .setStatus(500) + .setMessage('Application limit reached: Temporary issue due to downtime or throttling') + .build(), + }, + 368: { + default: new ErrorDetailsExtractorBuilder() + .setStatus(500) + .setMessage('Temporarily blocked for policies violations.') + .build(), + }, + 5000: { + default: new ErrorDetailsExtractorBuilder() + .setStatus(500) + .setMessage('Unknown Error Code') + .build(), + }, + 4: { + default: new ErrorDetailsExtractorBuilder() + .setStatus(429) + .setMessage('API Too Many Calls') + .build(), + }, + 17: { + default: new ErrorDetailsExtractorBuilder() + .setStatus(429) + .setMessage('API User Too Many Calls') + .build(), + }, +}; + +const getErrorDetailsFromErrorMap = (error) => { + const { code, error_subcode: subCode } = error; + let errDetails; + if (!isEmpty(errorDetailsMap[code])) { + errDetails = errorDetailsMap[code][subCode] || errorDetailsMap[code]?.default; + } + return errDetails; +}; + +const getStatus = (error) => { + const errorDetail = getErrorDetailsFromErrorMap(error); + let errorStatus = 500; + const isErrorDetailEmpty = isEmpty(errorDetail); + if (isErrorDetailEmpty) { + // Unhandled error response + return { + status: errorStatus, + tags: { [tags.TAG_NAMES.META]: tags.METADATA.UNHANDLED_STATUS_CODE }, + }; + } + errorStatus = errorDetail.status; + + let errorMessage = errorDetail?.messageDetails?.message; + if (errorDetail?.messageDetails?.field) { + errorMessage = get(error, errorDetail?.messageDetails?.field); + } + + return { status: errorStatus, errorMessage }; +}; + +const errorResponseHandler = (destResponse) => { + const { response } = destResponse; + if (!response.error) { + // successful response from facebook pixel api + return; + } + const { error } = response; + const { status, errorMessage, tags: errorStatTags } = getStatus(error); + throw new NetworkError( + `${errorMessage || error.message || 'Unknown failure during response transformation'}`, + status, + { + ...errorStatTags, + [tags.TAG_NAMES.ERROR_TYPE]: getDynamicErrorType(status), + }, + { ...response, status: destResponse.status }, + ); +}; + +const destResponseHandler = (destinationResponse) => { + errorResponseHandler(destinationResponse); + return { + destinationResponse: destinationResponse.response, + message: 'Request Processed Successfully', + status: destinationResponse.status, + }; +}; + +function networkHandler() { + // The order of execution also happens in this way + this.prepareProxy = prepareProxyRequest; + this.proxy = proxyRequest; + this.processAxiosResponse = processAxiosResponse; + this.responseHandler = destResponseHandler; +} + +module.exports = { + networkHandler, + errorResponseHandler, +}; diff --git a/src/v0/util/index.js b/src/v0/util/index.js index 34bd5e34ca..a8f56ddbf3 100644 --- a/src/v0/util/index.js +++ b/src/v0/util/index.js @@ -372,6 +372,9 @@ const hashToSha256 = (value) => sha256(value); // Check what type of gender and convert to f or m const getFbGenderVal = (gender) => { + if (typeof gender !== 'string') { + return null; + } if ( gender.toUpperCase() === 'FEMALE' || gender.toUpperCase() === 'F' || @@ -2047,11 +2050,11 @@ const getAuthErrCategoryFromStCode = (status) => { return ''; }; -const validateEventType = event => { - if(!event || typeof event !== "string"){ - throw new InstrumentationError("Event is a required field and should be a string"); +const validateEventType = (event) => { + if (!event || typeof event !== 'string') { + throw new InstrumentationError('Event is a required field and should be a string'); } -} +}; // ======================================================================== // EXPORTS // ======================================================================== diff --git a/test/__tests__/clevertap.test.js b/test/__tests__/clevertap.test.js index f5c95c3dfd..1a07c2f6bc 100644 --- a/test/__tests__/clevertap.test.js +++ b/test/__tests__/clevertap.test.js @@ -42,7 +42,7 @@ describe(`${name} Tests`, () => { describe("Router Tests", () => { it("Payload", async () => { - const routerOutput = await transformer.processRouterDest(inputRouterData); + const routerOutput = await transformer.processRouterDest(inputRouterData, {namespace: 'unknown', cluster: 'unknown'}); expect(routerOutput).toEqual(expectedRouterData); }); }); diff --git a/test/__tests__/data/facebook_conversions.json b/test/__tests__/data/facebook_conversions.json new file mode 100644 index 0000000000..62d0a49c0f --- /dev/null +++ b/test/__tests__/data/facebook_conversions.json @@ -0,0 +1,947 @@ +[ + { + "description": "Timestamp validation. Events must be sent within seven days of their occurrence or up to one minute in the future", + "input": { + "message": { + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1", + "channel": "web", + "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" + }, + "screen": { + "height": "100", + "density": 50 + }, + "traits": { + "email": " aBc@gmail.com ", + "address": { + "zip": 1234 + }, + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1" + } + }, + "event": "randomevent", + "integrations": { + "All": true + }, + "message_id": "a80f82be-9bdc-4a9f-b2a5-15621ee41df8", + "properties": { + "revenue": 400, + "additional_bet_index": 0 + }, + "timestamp": "2023-09-01T15:46:51.693229+05:30", + "type": "track" + }, + "destination": { + "Config": { + "limitedDataUsage": true, + "blacklistPiiProperties": [ + { + "blacklistPiiProperties": "", + "blacklistPiiHash": false + } + ], + "accessToken": "09876", + "datasetId": "dummyID", + "eventsToEvents": [ + { + "from": "", + "to": "" + } + ], + "eventCustomProperties": [ + { + "eventCustomProperties": "" + } + ], + "removeExternalId": true, + "whitelistPiiProperties": [ + { + "whitelistPiiProperties": "" + } + ] + }, + "Enabled": true + } + }, + "output": { + "error": "Events must be sent within seven days of their occurrence or up to one minute in the future." + } + }, + { + "description": "Track event without event property set", + "input": { + "message": { + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1", + "channel": "web", + "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" + }, + "screen": { + "height": "100", + "density": 50 + }, + "traits": { + "email": " aBc@gmail.com ", + "address": { + "zip": 1234 + }, + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1" + } + }, + "event": "", + "integrations": { + "All": true + }, + "message_id": "a80f82be-9bdc-4a9f-b2a5-15621ee41df8", + "properties": { + "revenue": 400, + "additional_bet_index": 0 + }, + "timestamp": "2023-10-15T15:46:51.693229+05:30", + "type": "track" + }, + "destination": { + "Config": { + "limitedDataUsage": true, + "blacklistPiiProperties": [ + { + "blacklistPiiProperties": "", + "blacklistPiiHash": false + } + ], + "accessToken": "09876", + "datasetId": "dummyID", + "eventsToEvents": [ + { + "from": "", + "to": "" + } + ], + "eventCustomProperties": [ + { + "eventCustomProperties": "" + } + ], + "removeExternalId": true, + "whitelistPiiProperties": [ + { + "whitelistPiiProperties": "" + } + ], + "actionSource": "website" + }, + "Enabled": true + } + }, + "output": { + "error": "'event' is required and should be a string" + } + }, + { + "description": "Simple track event", + "input": { + "message": { + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1", + "channel": "web", + "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" + }, + "screen": { + "height": "100", + "density": 50 + }, + "traits": { + "email": " aBc@gmail.com ", + "address": { + "zip": 1234 + }, + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1" + } + }, + "event": "spin_result", + "integrations": { + "All": true + }, + "message_id": "a80f82be-9bdc-4a9f-b2a5-15621ee41df8", + "properties": { + "revenue": 400, + "additional_bet_index": 0 + }, + "timestamp": "2023-10-15T15:46:51.693229+05:30", + "type": "track" + }, + "destination": { + "Config": { + "limitedDataUsage": true, + "blacklistPiiProperties": [ + { + "blacklistPiiProperties": "", + "blacklistPiiHash": false + } + ], + "accessToken": "09876", + "datasetId": "dummyID", + "eventsToEvents": [ + { + "from": "", + "to": "" + } + ], + "eventCustomProperties": [ + { + "eventCustomProperties": "" + } + ], + "removeExternalId": true, + "whitelistPiiProperties": [ + { + "whitelistPiiProperties": "" + } + ], + "actionSource": "website" + }, + "Enabled": true + } + }, + "output": { + "version": "1", + "type": "REST", + "method": "POST", + "endpoint": "https://graph.facebook.com/v17.0/dummyID/events?access_token=09876", + "headers": {}, + "params": {}, + "body": { + "JSON": {}, + "XML": {}, + "JSON_ARRAY": {}, + "FORM": { + "data": [ + "{\"user_data\":{\"em\":\"48ddb93f0b30c475423fe177832912c5bcdce3cc72872f8051627967ef278e08\",\"zp\":\"03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4\"},\"event_name\":\"spin_result\",\"event_time\":1697365011,\"action_source\":\"website\",\"custom_data\":{\"revenue\":400,\"additional_bet_index\":0,\"value\":400,\"currency\":\"USD\"}}" + ] + } + }, + "files": {} + } + }, + { + "description": "Track event with standard event products searched", + "input": { + "message": { + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1", + "channel": "web", + "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" + }, + "screen": { + "height": "100", + "density": 50 + }, + "traits": { + "email": " aBc@gmail.com ", + "address": { + "zip": 1234 + }, + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1" + } + }, + "event": "products searched", + "integrations": { + "All": true + }, + "message_id": "a80f82be-9bdc-4a9f-b2a5-15621ee41df8", + "properties": { + "revenue": 400, + "additional_bet_index": 0 + }, + "timestamp": "2023-10-15T15:46:51.693229+05:30", + "type": "track" + }, + "destination": { + "Config": { + "limitedDataUsage": true, + "blacklistPiiProperties": [ + { + "blacklistPiiProperties": "", + "blacklistPiiHash": false + } + ], + "accessToken": "09876", + "datasetId": "dummyID", + "eventsToEvents": [ + { + "from": "", + "to": "" + } + ], + "eventCustomProperties": [ + { + "eventCustomProperties": "" + } + ], + "removeExternalId": true, + "whitelistPiiProperties": [ + { + "whitelistPiiProperties": "" + } + ], + "actionSource": "website" + }, + "Enabled": true + } + }, + "output": { + "version": "1", + "type": "REST", + "method": "POST", + "endpoint": "https://graph.facebook.com/v17.0/dummyID/events?access_token=09876", + "headers": {}, + "params": {}, + "body": { + "JSON": {}, + "XML": {}, + "JSON_ARRAY": {}, + "FORM": { + "data": [ + "{\"user_data\":{\"em\":\"48ddb93f0b30c475423fe177832912c5bcdce3cc72872f8051627967ef278e08\",\"zp\":\"03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4\"},\"event_name\":\"Search\",\"event_time\":1697365011,\"action_source\":\"website\",\"custom_data\":{\"revenue\":400,\"additional_bet_index\":0,\"content_ids\":[],\"contents\":[],\"content_type\":\"product\",\"currency\":\"USD\",\"value\":400}}" + ] + } + }, + "files": {} + } + }, + { + "description": "Track event with standard event product added", + "input": { + "message": { + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1", + "channel": "web", + "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" + }, + "screen": { + "height": "100", + "density": 50 + }, + "traits": { + "email": " aBc@gmail.com ", + "address": { + "zip": 1234 + }, + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1" + } + }, + "event": "product added", + "integrations": { + "All": true + }, + "message_id": "a80f82be-9bdc-4a9f-b2a5-15621ee41df8", + "properties": { + "revenue": 400, + "additional_bet_index": 0 + }, + "timestamp": "2023-10-15T15:46:51.693229+05:30", + "type": "track" + }, + "destination": { + "Config": { + "limitedDataUsage": true, + "blacklistPiiProperties": [ + { + "blacklistPiiProperties": "", + "blacklistPiiHash": false + } + ], + "accessToken": "09876", + "datasetId": "dummyID", + "eventsToEvents": [ + { + "from": "", + "to": "" + } + ], + "eventCustomProperties": [ + { + "eventCustomProperties": "" + } + ], + "removeExternalId": true, + "whitelistPiiProperties": [ + { + "whitelistPiiProperties": "" + } + ], + "actionSource": "website" + }, + "Enabled": true + } + }, + "output": { + "version": "1", + "type": "REST", + "method": "POST", + "endpoint": "https://graph.facebook.com/v17.0/dummyID/events?access_token=09876", + "headers": {}, + "params": {}, + "body": { + "JSON": {}, + "XML": {}, + "JSON_ARRAY": {}, + "FORM": { + "data": [ + "{\"user_data\":{\"em\":\"48ddb93f0b30c475423fe177832912c5bcdce3cc72872f8051627967ef278e08\",\"zp\":\"03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4\"},\"event_name\":\"AddToCart\",\"event_time\":1697365011,\"action_source\":\"website\",\"custom_data\":{\"revenue\":400,\"additional_bet_index\":0,\"content_ids\":[],\"contents\":[],\"content_type\":\"product\",\"currency\":\"USD\",\"value\":400}}" + ] + } + }, + "files": {} + } + }, + { + "description": "Track event with standard event product viewed", + "input": { + "message": { + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1", + "channel": "web", + "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" + }, + "screen": { + "height": "100", + "density": 50 + }, + "traits": { + "email": " aBc@gmail.com ", + "address": { + "zip": 1234 + }, + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1" + } + }, + "event": "product viewed", + "integrations": { + "All": true + }, + "message_id": "a80f82be-9bdc-4a9f-b2a5-15621ee41df8", + "properties": { + "revenue": 400, + "additional_bet_index": 0 + }, + "timestamp": "2023-10-15T15:46:51.693229+05:30", + "type": "track" + }, + "destination": { + "Config": { + "limitedDataUsage": true, + "blacklistPiiProperties": [ + { + "blacklistPiiProperties": "", + "blacklistPiiHash": false + } + ], + "accessToken": "09876", + "datasetId": "dummyID", + "eventsToEvents": [ + { + "from": "", + "to": "" + } + ], + "eventCustomProperties": [ + { + "eventCustomProperties": "" + } + ], + "removeExternalId": true, + "whitelistPiiProperties": [ + { + "whitelistPiiProperties": "" + } + ], + "actionSource": "website" + }, + "Enabled": true + } + }, + "output": { + "version": "1", + "type": "REST", + "method": "POST", + "endpoint": "https://graph.facebook.com/v17.0/dummyID/events?access_token=09876", + "headers": {}, + "params": {}, + "body": { + "JSON": {}, + "XML": {}, + "JSON_ARRAY": {}, + "FORM": { + "data": [ + "{\"user_data\":{\"em\":\"48ddb93f0b30c475423fe177832912c5bcdce3cc72872f8051627967ef278e08\",\"zp\":\"03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4\"},\"event_name\":\"ViewContent\",\"event_time\":1697365011,\"action_source\":\"website\",\"custom_data\":{\"revenue\":400,\"additional_bet_index\":0,\"content_ids\":[],\"contents\":[],\"content_type\":\"product\",\"currency\":\"USD\",\"value\":400}}" + ] + } + }, + "files": {} + } + }, + { + "description": "Track event with standard event product list viewed", + "input": { + "message": { + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1", + "channel": "web", + "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" + }, + "screen": { + "height": "100", + "density": 50 + }, + "traits": { + "email": " aBc@gmail.com ", + "address": { + "zip": 1234 + }, + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1" + } + }, + "event": "product list viewed", + "integrations": { + "All": true + }, + "message_id": "a80f82be-9bdc-4a9f-b2a5-15621ee41df8", + "properties": { + "revenue": 400, + "additional_bet_index": 0, + "products": [ + { + "product_id": 1234, + "quantity": 5, + "price": 55 + } + ] + }, + "timestamp": "2023-10-15T15:46:51.693229+05:30", + "type": "track" + }, + "destination": { + "Config": { + "limitedDataUsage": true, + "blacklistPiiProperties": [ + { + "blacklistPiiProperties": "", + "blacklistPiiHash": false + } + ], + "accessToken": "09876", + "datasetId": "dummyID", + "eventsToEvents": [ + { + "from": "", + "to": "" + } + ], + "eventCustomProperties": [ + { + "eventCustomProperties": "" + } + ], + "removeExternalId": true, + "whitelistPiiProperties": [ + { + "whitelistPiiProperties": "" + } + ], + "actionSource": "website" + }, + "Enabled": true + } + }, + "output": { + "version": "1", + "type": "REST", + "method": "POST", + "endpoint": "https://graph.facebook.com/v17.0/dummyID/events?access_token=09876", + "headers": {}, + "params": {}, + "body": { + "JSON": {}, + "XML": {}, + "JSON_ARRAY": {}, + "FORM": { + "data": [ + "{\"user_data\":{\"em\":\"48ddb93f0b30c475423fe177832912c5bcdce3cc72872f8051627967ef278e08\",\"zp\":\"03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4\"},\"event_name\":\"ViewContent\",\"event_time\":1697365011,\"action_source\":\"website\",\"custom_data\":{\"revenue\":400,\"additional_bet_index\":0,\"products[0].product_id\":1234,\"products[0].quantity\":5,\"products[0].price\":55,\"content_ids\":[1234],\"contents\":[{\"id\":1234,\"quantity\":5,\"item_price\":55}],\"content_type\":\"product\",\"currency\":\"USD\",\"value\":400}}" + ] + } + }, + "files": {} + } + }, + { + "description": "Track event with standard event product list viewed without products array", + "input": { + "message": { + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1", + "channel": "web", + "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" + }, + "screen": { + "height": "100", + "density": 50 + }, + "traits": { + "email": " aBc@gmail.com ", + "address": { + "zip": 1234 + }, + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1" + } + }, + "event": "product list viewed", + "integrations": { + "All": true + }, + "message_id": "a80f82be-9bdc-4a9f-b2a5-15621ee41df8", + "properties": { + "revenue": 400, + "additional_bet_index": 0, + "category": "randomCategory" + }, + "timestamp": "2023-10-15T15:46:51.693229+05:30", + "type": "track" + }, + "destination": { + "Config": { + "limitedDataUsage": true, + "blacklistPiiProperties": [ + { + "blacklistPiiProperties": "", + "blacklistPiiHash": false + } + ], + "accessToken": "09876", + "datasetId": "dummyID", + "eventsToEvents": [ + { + "from": "", + "to": "" + } + ], + "eventCustomProperties": [ + { + "eventCustomProperties": "" + } + ], + "removeExternalId": true, + "whitelistPiiProperties": [ + { + "whitelistPiiProperties": "" + } + ], + "actionSource": "website" + }, + "Enabled": true + } + }, + "output": { + "version": "1", + "type": "REST", + "method": "POST", + "endpoint": "https://graph.facebook.com/v17.0/dummyID/events?access_token=09876", + "headers": {}, + "params": {}, + "body": { + "JSON": {}, + "XML": {}, + "JSON_ARRAY": {}, + "FORM": { + "data": [ + "{\"user_data\":{\"em\":\"48ddb93f0b30c475423fe177832912c5bcdce3cc72872f8051627967ef278e08\",\"zp\":\"03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4\"},\"event_name\":\"ViewContent\",\"event_time\":1697365011,\"action_source\":\"website\",\"custom_data\":{\"revenue\":400,\"additional_bet_index\":0,\"category\":\"randomCategory\",\"content_ids\":[\"randomCategory\"],\"contents\":[{\"id\":\"randomCategory\",\"quantity\":1}],\"content_type\":\"product_group\",\"content_category\":\"randomCategory\",\"currency\":\"USD\",\"value\":400}}" + ] + } + }, + "files": {} + } + }, + { + "description": "Track event with standard event product added to wishlist", + "input": { + "message": { + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1", + "channel": "web", + "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" + }, + "screen": { + "height": "100", + "density": 50 + }, + "traits": { + "email": " aBc@gmail.com ", + "address": { + "zip": 1234 + }, + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1" + } + }, + "event": "product added to wishlist", + "integrations": { + "All": true + }, + "message_id": "a80f82be-9bdc-4a9f-b2a5-15621ee41df8", + "properties": { + "revenue": 400, + "additional_bet_index": 0 + }, + "timestamp": "2023-10-15T15:46:51.693229+05:30", + "type": "track" + }, + "destination": { + "Config": { + "limitedDataUsage": true, + "blacklistPiiProperties": [ + { + "blacklistPiiProperties": "", + "blacklistPiiHash": false + } + ], + "accessToken": "09876", + "datasetId": "dummyID", + "eventsToEvents": [ + { + "from": "", + "to": "" + } + ], + "eventCustomProperties": [ + { + "eventCustomProperties": "" + } + ], + "removeExternalId": true, + "whitelistPiiProperties": [ + { + "whitelistPiiProperties": "" + } + ], + "actionSource": "website" + }, + "Enabled": true + } + }, + "output": { + "version": "1", + "type": "REST", + "method": "POST", + "endpoint": "https://graph.facebook.com/v17.0/dummyID/events?access_token=09876", + "headers": {}, + "params": {}, + "body": { + "JSON": {}, + "XML": {}, + "JSON_ARRAY": {}, + "FORM": { + "data": [ + "{\"user_data\":{\"em\":\"48ddb93f0b30c475423fe177832912c5bcdce3cc72872f8051627967ef278e08\",\"zp\":\"03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4\"},\"event_name\":\"AddToWishlist\",\"event_time\":1697365011,\"action_source\":\"website\",\"custom_data\":{\"revenue\":400,\"additional_bet_index\":0,\"content_ids\":[],\"contents\":[],\"currency\":\"USD\",\"value\":400}}" + ] + } + }, + "files": {} + } + }, + { + "description": "Track event with standard event payment info entered", + "input": { + "message": { + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1", + "channel": "web", + "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" + }, + "screen": { + "height": "100", + "density": 50 + }, + "traits": { + "email": " aBc@gmail.com ", + "address": { + "zip": 1234 + }, + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1" + } + }, + "event": "payment info entered", + "integrations": { + "All": true + }, + "message_id": "a80f82be-9bdc-4a9f-b2a5-15621ee41df8", + "properties": { + "revenue": 400, + "additional_bet_index": 0 + }, + "timestamp": "2023-10-15T15:46:51.693229+05:30", + "type": "track" + }, + "destination": { + "Config": { + "limitedDataUsage": true, + "blacklistPiiProperties": [ + { + "blacklistPiiProperties": "", + "blacklistPiiHash": false + } + ], + "accessToken": "09876", + "datasetId": "dummyID", + "eventsToEvents": [ + { + "from": "", + "to": "" + } + ], + "eventCustomProperties": [ + { + "eventCustomProperties": "" + } + ], + "removeExternalId": true, + "whitelistPiiProperties": [ + { + "whitelistPiiProperties": "" + } + ], + "actionSource": "website" + }, + "Enabled": true + } + }, + "output": { + "version": "1", + "type": "REST", + "method": "POST", + "endpoint": "https://graph.facebook.com/v17.0/dummyID/events?access_token=09876", + "headers": {}, + "params": {}, + "body": { + "JSON": {}, + "XML": {}, + "JSON_ARRAY": {}, + "FORM": { + "data": [ + "{\"user_data\":{\"em\":\"48ddb93f0b30c475423fe177832912c5bcdce3cc72872f8051627967ef278e08\",\"zp\":\"03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4\"},\"event_name\":\"AddPaymentInfo\",\"event_time\":1697365011,\"action_source\":\"website\",\"custom_data\":{\"revenue\":400,\"additional_bet_index\":0,\"content_ids\":[],\"contents\":[],\"currency\":\"USD\",\"value\":400}}" + ] + } + }, + "files": {} + } + } +] diff --git a/test/__tests__/data/facebook_conversions_router_input.json b/test/__tests__/data/facebook_conversions_router_input.json new file mode 100644 index 0000000000..4abe66d3c4 --- /dev/null +++ b/test/__tests__/data/facebook_conversions_router_input.json @@ -0,0 +1,158 @@ +[ + { + "metadata": { + "jobId": 1 + }, + "message": { + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1", + "channel": "web", + "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" + }, + "screen": { + "height": "100", + "density": 50 + }, + "traits": { + "email": " aBc@gmail.com ", + "address": { + "zip": 1234 + }, + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1" + } + }, + "event": "spin_result", + "integrations": { + "All": true + }, + "message_id": "a80f82be-9bdc-4a9f-b2a5-15621ee41df8", + "properties": { + "revenue": 400, + "additional_bet_index": 0 + }, + "timestamp": "2023-10-15T15:46:51.693229+05:30", + "type": "track" + }, + "destination": { + "Config": { + "limitedDataUsage": true, + "blacklistPiiProperties": [ + { + "blacklistPiiProperties": "", + "blacklistPiiHash": false + } + ], + "accessToken": "09876", + "datasetId": "dummyID", + "eventsToEvents": [ + { + "from": "", + "to": "" + } + ], + "eventCustomProperties": [ + { + "eventCustomProperties": "" + } + ], + "removeExternalId": true, + "whitelistPiiProperties": [ + { + "whitelistPiiProperties": "" + } + ], + "actionSource": "website" + }, + "Enabled": true + } + }, + { + "metadata": { + "jobId": 2 + }, + "message": { + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1", + "channel": "web", + "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" + }, + "screen": { + "height": "100", + "density": 50 + }, + "traits": { + "email": " aBc@gmail.com ", + "address": { + "zip": 1234 + }, + "anonymousId": "c82cbdff-e5be-4009-ac78-cdeea09ab4b1" + } + }, + "event": "products searched", + "integrations": { + "All": true + }, + "message_id": "a80f82be-9bdc-4a9f-b2a5-15621ee41df8", + "properties": { + "revenue": 400, + "additional_bet_index": 0 + }, + "timestamp": "2023-10-15T15:46:51.693229+05:30", + "type": "track" + }, + "destination": { + "Config": { + "limitedDataUsage": true, + "blacklistPiiProperties": [ + { + "blacklistPiiProperties": "", + "blacklistPiiHash": false + } + ], + "accessToken": "09876", + "datasetId": "dummyID", + "eventsToEvents": [ + { + "from": "", + "to": "" + } + ], + "eventCustomProperties": [ + { + "eventCustomProperties": "" + } + ], + "removeExternalId": true, + "whitelistPiiProperties": [ + { + "whitelistPiiProperties": "" + } + ], + "actionSource": "website" + }, + "Enabled": true + } + } +] diff --git a/test/__tests__/data/facebook_conversions_router_output.json b/test/__tests__/data/facebook_conversions_router_output.json new file mode 100644 index 0000000000..d376a91027 --- /dev/null +++ b/test/__tests__/data/facebook_conversions_router_output.json @@ -0,0 +1,122 @@ +[ + { + "batchedRequest": { + "version": "1", + "type": "REST", + "method": "POST", + "endpoint": "https://graph.facebook.com/v17.0/dummyID/events?access_token=09876", + "headers": {}, + "params": {}, + "body": { + "JSON": {}, + "XML": {}, + "JSON_ARRAY": {}, + "FORM": { + "data": [ + "{\"user_data\":{\"em\":\"48ddb93f0b30c475423fe177832912c5bcdce3cc72872f8051627967ef278e08\",\"zp\":\"03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4\"},\"event_name\":\"spin_result\",\"event_time\":1697365011,\"action_source\":\"website\",\"custom_data\":{\"revenue\":400,\"additional_bet_index\":0,\"value\":400,\"currency\":\"USD\"}}" + ] + } + }, + "files": {} + }, + "metadata": [ + { + "jobId": 1 + } + ], + "batched": false, + "statusCode": 200, + "destination": { + "Config": { + "limitedDataUsage": true, + "blacklistPiiProperties": [ + { + "blacklistPiiProperties": "", + "blacklistPiiHash": false + } + ], + "accessToken": "09876", + "datasetId": "dummyID", + "eventsToEvents": [ + { + "from": "", + "to": "" + } + ], + "eventCustomProperties": [ + { + "eventCustomProperties": "" + } + ], + "removeExternalId": true, + "whitelistPiiProperties": [ + { + "whitelistPiiProperties": "" + } + ], + "actionSource": "website" + }, + "Enabled": true + } + }, + { + "batchedRequest": { + "version": "1", + "type": "REST", + "method": "POST", + "endpoint": "https://graph.facebook.com/v17.0/dummyID/events?access_token=09876", + "headers": {}, + "params": {}, + "body": { + "JSON": {}, + "XML": {}, + "JSON_ARRAY": {}, + "FORM": { + "data": [ + "{\"user_data\":{\"em\":\"48ddb93f0b30c475423fe177832912c5bcdce3cc72872f8051627967ef278e08\",\"zp\":\"03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4\"},\"event_name\":\"Search\",\"event_time\":1697365011,\"action_source\":\"website\",\"custom_data\":{\"revenue\":400,\"additional_bet_index\":0,\"content_ids\":[],\"contents\":[],\"content_type\":\"product\",\"currency\":\"USD\",\"value\":400}}" + ] + } + }, + "files": {} + }, + "metadata": [ + { + "jobId": 2 + } + ], + "batched": false, + "statusCode": 200, + "destination": { + "Config": { + "limitedDataUsage": true, + "blacklistPiiProperties": [ + { + "blacklistPiiProperties": "", + "blacklistPiiHash": false + } + ], + "accessToken": "09876", + "datasetId": "dummyID", + "eventsToEvents": [ + { + "from": "", + "to": "" + } + ], + "eventCustomProperties": [ + { + "eventCustomProperties": "" + } + ], + "removeExternalId": true, + "whitelistPiiProperties": [ + { + "whitelistPiiProperties": "" + } + ], + "actionSource": "website" + }, + "Enabled": true + } + } +] diff --git a/test/__tests__/facebook_conversions.js b/test/__tests__/facebook_conversions.js new file mode 100644 index 0000000000..a450952efe --- /dev/null +++ b/test/__tests__/facebook_conversions.js @@ -0,0 +1,46 @@ +const integration = "facebook_conversions"; +const name = "facebook_conversions"; + +const fs = require("fs"); +const path = require("path"); + +const version = "v0"; +const transformer = require(`../../src/${version}/destinations/${integration}/transform`); + +// Processor Test Data +const testDataFile = fs.readFileSync( + path.resolve(__dirname, `./data/${integration}.json`) +); +const testData = JSON.parse(testDataFile); + +// Router Test Data +const inputRouterDataFile = fs.readFileSync( + path.resolve(__dirname, `./data/${integration}_router_input.json`) +); +const outputRouterDataFile = fs.readFileSync( + path.resolve(__dirname, `./data/${integration}_router_output.json`) +); +const inputRouterData = JSON.parse(inputRouterDataFile); +const expectedRouterData = JSON.parse(outputRouterDataFile); + +describe(`${name} Tests`, () => { + describe("Processor", () => { + testData.forEach((dataPoint, index) => { + it(`${index}. ${integration} - ${dataPoint.description}`, async () => { + try { + const output = await transformer.process(dataPoint.input); + expect(output).toEqual(dataPoint.output); + } catch (error) { + expect(error.message).toEqual(dataPoint.output.error); + } + }); + }); + }); + + describe("Router Tests", () => { + it("Payload", async () => { + const routerOutput = await transformer.processRouterDest(inputRouterData); + expect(routerOutput).toEqual(expectedRouterData); + }); + }); +});