Skip to content

Commit

Permalink
feat: cm360 enhanced conversions (#3414)
Browse files Browse the repository at this point in the history
* feat: cm360 enhanced conversions

* refactor: added null, empty checks
  • Loading branch information
Gauravudia authored Jun 14, 2024
1 parent d4d5a89 commit 04d0783
Show file tree
Hide file tree
Showing 9 changed files with 744 additions and 4 deletions.
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"koa": "^2.14.1",
"koa-bodyparser": "^4.4.0",
"koa2-swagger-ui": "^5.7.0",
"libphonenumber-js": "^1.11.1",
"lodash": "^4.17.21",
"match-json": "^1.3.5",
"md5": "^2.3.0",
Expand Down
4 changes: 4 additions & 0 deletions src/v0/destinations/campaign_manager/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ const ConfigCategories = {
type: 'track',
name: 'CampaignManagerTrackConfig',
},
ENHANCED_CONVERSION: {
type: 'track',
name: 'CampaignManagerEnhancedConversionConfig',
},
};

const MAX_BATCH_CONVERSATIONS_SIZE = 1000;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
[
{
"destKey": "hashedEmail",
"sourceKeys": "emailOnly",
"sourceFromGenericMap": true
},
{
"destKey": "hashedPhoneNumber",
"sourceKeys": "phone",
"sourceFromGenericMap": true
},
{
"destKey": "addressInfo.hashedFirstName",
"sourceKeys": "firstName",
"sourceFromGenericMap": true
},
{
"destKey": "addressInfo.hashedLastName",
"sourceKeys": "lastName",
"sourceFromGenericMap": true
},
{
"destKey": "addressInfo.hashedStreetAddress",
"sourceKeys": "street",
"sourceFromGenericMap": true
},
{
"destKey": "addressInfo.city",
"sourceKeys": [
"traits.city",
"traits.address.city",
"context.traits.city",
"context.traits.address.city"
]
},
{
"destKey": "addressInfo.state",
"sourceKeys": [
"traits.state",
"traits.address.state",
"context.traits.state",
"context.traits.address.state"
]
},
{
"destKey": "addressInfo.countryCode",
"sourceKeys": [
"traits.country",
"traits.address.country",
"context.traits.country",
"context.traits.address.country"
]
},
{
"destKey": "addressInfo.postalCode",
"sourceKeys": "zipcode",
"sourceFromGenericMap": true
}
]
15 changes: 14 additions & 1 deletion src/v0/destinations/campaign_manager/transform.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const {
handleRtTfSingleEventError,
getAccessToken,
} = require('../../util');
const { CommonUtils } = require('../../../util/common');

const {
ConfigCategories,
Expand All @@ -22,7 +23,7 @@ const {
MAX_BATCH_CONVERSATIONS_SIZE,
} = require('./config');

const { convertToMicroseconds } = require('./util');
const { convertToMicroseconds, prepareUserIdentifiers } = require('./util');
const { JSON_MIME_TYPE } = require('../../util/constant');

function isEmptyObject(obj) {
Expand Down Expand Up @@ -105,6 +106,18 @@ function processTrack(message, metadata, destination) {
}
}

if (
destination.Config.enableEnhancedConversions &&
message.properties.requestType === 'batchupdate'
) {
const userIdentifiers = CommonUtils.toArray(
prepareUserIdentifiers(message, destination.Config.isHashingRequired ?? true),
);
if (userIdentifiers.length > 0) {
requestJson.userIdentifiers = userIdentifiers;
}
}

const endpointUrl = prepareUrl(message, destination);
return buildResponse(
requestJson,
Expand Down
106 changes: 106 additions & 0 deletions src/v0/destinations/campaign_manager/util.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
const { parsePhoneNumber } = require('libphonenumber-js');
const sha256 = require('sha256');
const { InstrumentationError } = require('@rudderstack/integrations-lib');
const {
constructPayload,
isDefinedAndNotNull,
removeUndefinedAndNullValues,
isEmptyObject,
} = require('../../util');
const { ConfigCategories, mappingConfig } = require('./config');

function convertToMicroseconds(input) {
const timestamp = Date.parse(input);

Expand Down Expand Up @@ -28,6 +39,101 @@ function convertToMicroseconds(input) {
return timestamp;
}

const normalizeEmail = (email) => {
const domains = ['@gmail.com', '@googlemail.com'];

const matchingDomain = domains.find((domain) => email.endsWith(domain));

if (matchingDomain) {
const localPart = email.split('@')[0].replace(/\./g, '');
return `${localPart}${matchingDomain}`;
}

return email;
};

const normalizePhone = (phone, countryCode) => {
const phoneNumberObject = parsePhoneNumber(phone, countryCode);
if (phoneNumberObject && phoneNumberObject.isValid()) {
return phoneNumberObject.format('E.164');
}
throw new InstrumentationError('Invalid phone number');
};

// ref:- https://developers.google.com/doubleclick-advertisers/guides/conversions_ec#hashing
const normalizeAndHash = (key, value, options) => {
if (!isDefinedAndNotNull(value)) return value;

let normalizedValue;
const trimmedValue = value.trim().toLowerCase();
switch (key) {
case 'hashedEmail':
normalizedValue = normalizeEmail(trimmedValue);
break;
case 'hashedPhoneNumber':
normalizedValue = normalizePhone(trimmedValue, options.countryCode);
break;
case 'hashedFirstName':
case 'hashedLastName':
case 'hashedStreetAddress':
normalizedValue = trimmedValue;
break;
default:
return value;
}
return sha256(normalizedValue);
};

const prepareUserIdentifiers = (message, isHashingRequired) => {
const payload = constructPayload(
message,
mappingConfig[ConfigCategories.ENHANCED_CONVERSION.name],
);

if (isHashingRequired) {
payload.hashedEmail = normalizeAndHash('hashedEmail', payload.hashedEmail);
payload.hashedPhoneNumber = normalizeAndHash('hashedPhoneNumber', payload.hashedPhoneNumber, {
options: payload.addressInfo?.countryCode,
});

if (!isEmptyObject(payload.addressInfo)) {
payload.addressInfo.hashedFirstName = normalizeAndHash(
'hashedFirstName',
payload.addressInfo.hashedFirstName,
);

payload.addressInfo.hashedLastName = normalizeAndHash(
'hashedLastName',
payload.addressInfo.hashedLastName,
);

payload.addressInfo.hashedStreetAddress = normalizeAndHash(
'hashedStreetAddress',
payload.addressInfo.hashedStreetAddress,
);
}
}

const userIdentifiers = [];
if (isDefinedAndNotNull(payload.hashedEmail)) {
userIdentifiers.push({ hashedEmail: payload.hashedEmail });
}
if (isDefinedAndNotNull(payload.hashedPhoneNumber)) {
userIdentifiers.push({ hashedPhoneNumber: payload.hashedPhoneNumber });
}

payload.addressInfo = removeUndefinedAndNullValues(payload.addressInfo);
if (!isEmptyObject(payload.addressInfo)) {
userIdentifiers.push({ addressInfo: payload.addressInfo });
}

return userIdentifiers;
};

module.exports = {
convertToMicroseconds,
normalizeEmail,
normalizePhone,
normalizeAndHash,
prepareUserIdentifiers,
};
36 changes: 35 additions & 1 deletion src/v0/destinations/campaign_manager/util.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
const { convertToMicroseconds } = require('./util');
const sha256 = require('sha256');
const {
convertToMicroseconds,
normalizeEmail,
normalizePhone,
normalizeAndHash,
} = require('./util');

describe('convertToMicroseconds utility test', () => {
it('ISO 8601 input', () => {
Expand All @@ -21,3 +27,31 @@ describe('convertToMicroseconds utility test', () => {
expect(convertToMicroseconds('1697013935000')).toEqual(1697013935000000);
});
});

describe('normalizeEmail', () => {
it('should remove dots from the local part for gmail.com addresses', () => {
const email = '[email protected]';
const normalized = normalizeEmail(email);
expect(normalized).toBe('[email protected]');
});

it('should return the same email if no google domain is present', () => {
const email = '[email protected]';
const normalized = normalizeEmail(email);
expect(normalized).toBe('[email protected]');
});
});

describe('normalizePhone', () => {
it('should return a valid E.164 formatted phone number when provided with correct inputs', () => {
const validPhone = '4155552671';
const countryCode = 'US';
expect(normalizePhone(validPhone, countryCode)).toBe('+14155552671');
});

it('should throw an InstrumentationError when the phone number is too short or too long', () => {
const invalidPhone = '123';
const countryCode = 'US';
expect(() => normalizePhone(invalidPhone, countryCode)).toThrow('Invalid phone number');
});
});
8 changes: 6 additions & 2 deletions src/v0/util/data/GenericFieldMapping.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,16 @@
"traits.DOB",
"context.traits.DOB"
],

"state": ["traits.state", "context.traits.state"],
"country": ["traits.country", "context.traits.country"],
"region": ["traits.region", "context.traits.region"],
"city": ["traits.address.city", "context.traits.address.city"],

"street": [
"traits.street",
"traits.address.street",
"context.traits.street",
"context.traits.address.street"
],
"avatar": [
"traits.avatar",
"context.traits.avatar",
Expand Down
Loading

0 comments on commit 04d0783

Please sign in to comment.