From ee2ca857dff3996c060c9d07409c839e6b36a43f Mon Sep 17 00:00:00 2001 From: Maryam Sharif Date: Tue, 25 Jun 2024 09:17:43 -0500 Subject: [PATCH 01/15] New Action + properties --- .../google-enhanced-conversions/index.ts | 5 +- .../userList/__tests__/index.test.ts | 9 ++ .../userList/generated-types.ts | 64 ++++++++ .../userList/index.ts | 153 ++++++++++++++++++ 4 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts index f1e5571d13..d9b0b430b2 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts @@ -5,6 +5,8 @@ import uploadCallConversion from './uploadCallConversion' import uploadClickConversion from './uploadClickConversion' import uploadConversionAdjustment from './uploadConversionAdjustment' +import userList from './userList' + interface RefreshTokenResponse { access_token: string scope: string @@ -75,7 +77,8 @@ const destination: DestinationDefinition = { postConversion, uploadClickConversion, uploadCallConversion, - uploadConversionAdjustment + uploadConversionAdjustment, + userList } } diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__tests__/index.test.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__tests__/index.test.ts new file mode 100644 index 0000000000..deff026560 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/__tests__/index.test.ts @@ -0,0 +1,9 @@ +// import nock from 'nock' +// import { createTestEvent, createTestIntegration } from '@segment/actions-core' +// import Destination from '../../index' + +// const testDestination = createTestIntegration(Destination) + +// describe('GoogleEnhancedConversions.userList', () => { +// // TODO: Test your action +// }) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts new file mode 100644 index 0000000000..c51a7eb800 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts @@ -0,0 +1,64 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The user's first name. + */ + first_name?: string + /** + * The user's last name. + */ + last_name?: string + /** + * The user's email address. + */ + email?: string + /** + * The user's phone number. + */ + phone?: string + /** + * The user's country code. + */ + country_code?: string + /** + * The user's postal code. + */ + postal_code?: string + /** + * Should Segment hash the user’s data before sending it to Google? Set this to false if you are already sending hashed data. + */ + hash_data?: boolean + /** + * Advertiser-assigned user ID for Customer Match upload. + */ + crm_id?: string + /** + * Mobile device ID (advertising ID/IDFA). + */ + mobile_advertising_id?: string + /** + * A string that uniquely identifies a mobile application from which the data was collected. + */ + app_id?: string + /** + * This represents consent for ad user data.For more information on consent, refer to [Google Ads API Consent](https://developers.google.com/google-ads/api/rest/reference/rest/v15/Consent). + */ + ad_user_data_consent_state: string + /** + * This represents consent for ad personalization. This can only be set for OfflineUserDataJobService and UserDataService.For more information on consent, refer to [Google Ads API Consent](https://developers.google.com/google-ads/api/rest/reference/rest/v15/Consent). + */ + ad_personalization_consent_state: string + /** + * The ID of the List that users will be synced to. + */ + external_audience_id?: string + /** + * Enable batching for the request. + */ + enable_batching?: boolean + /** + * The name of the current Segment event. + */ + event_name: string +} diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts new file mode 100644 index 0000000000..910d4798fc --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts @@ -0,0 +1,153 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Customer Match User List', + description: 'Sync a Segment Engage Audience into a Google Customer Match User List.', + defaultSubscription: 'event = "Audience Entered" or event = "Audience Exited"', + fields: { + first_name: { + label: 'First Name', + description: "The user's first name.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.context.traits.firstName' }, + then: { '@path': '$.context.traits.firstName' }, + else: { '@path': '$.properties.firstName' } + } + } + }, + last_name: { + label: 'Last Name', + description: "The user's last name.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.context.traits.lastName' }, + then: { '@path': '$.context.traits.lastName' }, + else: { '@path': '$.properties.lastName' } + } + } + }, + email: { + label: 'Email', + description: "The user's email address.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.context.traits.email' }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } + } + } + }, + phone: { + label: 'Phone', + description: "The user's phone number.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.context.traits.phone' }, + then: { '@path': '$.context.traits.phone' }, + else: { '@path': '$.properties.phone' } + } + } + }, + country_code: { + label: 'Country Code', + description: "The user's country code.", + type: 'string' + }, + postal_code: { + label: 'Postal Code', + description: "The user's postal code.", + type: 'string' + }, + hash_data: { + label: 'Hash Data?', + description: + 'Should Segment hash the user’s data before sending it to Google? Set this to false if you are already sending hashed data.', + type: 'boolean', + default: true + }, + crm_id: { + label: 'CRM ID', + description: 'Advertiser-assigned user ID for Customer Match upload.', + type: 'string' + }, + mobile_advertising_id: { + label: 'Mobile Advertising ID', + description: 'Mobile device ID (advertising ID/IDFA).', + type: 'string', + default: { + '@path': '$.context.device.advertisingId' + } + }, + app_id: { + label: 'App ID', + description: 'A string that uniquely identifies a mobile application from which the data was collected.', + type: 'string' + }, + ad_user_data_consent_state: { + label: 'Ad User Data Consent State', + description: + 'This represents consent for ad user data.For more information on consent, refer to [Google Ads API Consent](https://developers.google.com/google-ads/api/rest/reference/rest/v15/Consent).', + type: 'string', + choices: [ + { label: 'GRANTED', value: 'GRANTED' }, + { label: 'DENIED', value: 'DENIED' }, + { label: 'UNSPECIFIED', value: 'UNSPECIFIED' } + ], + required: true + }, + ad_personalization_consent_state: { + label: 'Ad Personalization Consent State', + type: 'string', + description: + 'This represents consent for ad personalization. This can only be set for OfflineUserDataJobService and UserDataService.For more information on consent, refer to [Google Ads API Consent](https://developers.google.com/google-ads/api/rest/reference/rest/v15/Consent).', + choices: [ + { label: 'GRANTED', value: 'GRANTED' }, + { label: 'DENIED', value: 'DENIED' }, + { label: 'UNSPECIFIED', value: 'UNSPECIFIED' } + ], + required: true + }, + external_audience_id: { + label: 'External Audience ID', + description: 'The ID of the List that users will be synced to.', + type: 'string', + default: { + '@path': '$.context.personas.external_audience_id' + }, + unsafe_hidden: true + }, + enable_batching: { + label: 'Enable Batching', + description: 'Enable batching for the request.', + type: 'boolean', + default: true, + unsafe_hidden: true + }, + event_name: { + label: 'Event Name', + description: 'The name of the current Segment event.', + type: 'string', + default: { + '@path': '$.event' + }, + required: true, + readOnly: true + } + }, + perform: (request, data) => { + // Make your partner api request here! + return request('https://example.com', { + method: 'post', + json: data.payload + }) + } +} + +export default action From 647c98dded2da755e4b631e4cd1d0811e71fab34 Mon Sep 17 00:00:00 2001 From: Maryam Sharif Date: Tue, 2 Jul 2024 10:49:13 -0700 Subject: [PATCH 02/15] Create and get audience functions --- .../google-enhanced-conversions/functions.ts | 78 +++++++++++++++++++ .../generated-types.ts | 8 ++ .../google-enhanced-conversions/index.ts | 44 ++++++++++- 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index 327cfe746a..9c07c6eab4 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -30,6 +30,15 @@ export class GoogleAdsError extends HTTPError { } } +export interface CreateAudienceInput { + audienceName: string + settings: { + customerId?: string + conversionTrackingId?: string + } + audienceSettings?: unknown +} + export function formatCustomVariables( customVariables: object, customVariableIdsResults: Array @@ -185,3 +194,72 @@ export const commonHashedEmailValidation = (email: string): string => { return String(hash(email)) } + +export async function createGoogleAudience( + request: RequestClient, + input: CreateAudienceInput, + statsContext?: StatsContext +) { + const statsClient = statsContext?.statsClient + const statsTags = statsContext?.tags + const json = { + operations: [ + { + create: { + crmBasedUserList: { + uploadKeyType: (input.audienceSettings as { external_id_type: string }).external_id_type + }, + membershipLifeSpan: '10000', // In days. 10000 is interpreted as 'unlimited'. + name: `${input.audienceName}` + } + } + ] + } + + const response = await request(`https://googleads.googleapis.com/v16/customers/${input.settings.customerId}:mutate`, { + method: 'post', + json + }) + + // Successful response body looks like: + // {"results": [{ "resourceName": "customers//userLists/" }]} + const name = (response.data as any).results[0].resourceName + if (!name) { + statsClient?.incr('createAudience.error', 1, statsTags) + throw new IntegrationError('Failed to receive a created customer list id.', 'INVALID_RESPONSE', 400) + } + + statsClient?.incr('createAudience.success', 1, statsTags) + return name.split('/')[3] +} + +export async function getGoogleAudience( + request: RequestClient, + settings: any, + externalId: string, + statsContext?: StatsContext +) { + const statsClient = statsContext?.statsClient + const statsTags = statsContext?.tags + const json = { + query: `SELECT user_list.id, user_list.name FROM user_list where user_list.id = '${externalId}'` + } + + const response = await request( + `https://googleads.googleapis.com/v16/customers/${settings.customerId}/googleAds:search`, + { + method: 'post', + json + } + ) + + const id = (response.data as any).results[0].userList.id + + if (!id) { + statsClient?.incr('getAudience.error', 1, statsTags) + throw new IntegrationError('Failed to receive a customer list.', 'INVALID_RESPONSE', 400) + } + + statsClient?.incr('getAudience.success', 1, statsTags) + return id +} diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/generated-types.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/generated-types.ts index 71572a6401..4f5bd5f4d5 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/generated-types.ts @@ -10,3 +10,11 @@ export interface Settings { */ customerId?: string } +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface AudienceSettings { + /** + * Customer match upload key types. + */ + external_id_type: string +} diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts index d9b0b430b2..4843da7aac 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts @@ -1,9 +1,10 @@ -import { DestinationDefinition } from '@segment/actions-core' +import { AudienceDestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' import postConversion from './postConversion' import uploadCallConversion from './uploadCallConversion' import uploadClickConversion from './uploadClickConversion' import uploadConversionAdjustment from './uploadConversionAdjustment' +import { createGoogleAudience, getGoogleAudience } from './functions' import userList from './userList' @@ -20,7 +21,7 @@ interface UserInfoResponse { } */ -const destination: DestinationDefinition = { +const destination: AudienceDestinationDefinition = { // NOTE: We need to match the name with the creation name in DB. // This is not the value used in the UI. name: 'Google Ads Conversions', @@ -73,6 +74,45 @@ const destination: DestinationDefinition = { } } }, + audienceFields: { + external_id_type: { + type: 'string', + label: 'External ID Type', + description: 'Customer match upload key types.', + required: true, + choices: [ + { label: 'CONTACT_INFO', value: 'CONTACT_INFO' }, + { label: 'CRM_ID', value: 'CRM_ID' }, + { label: 'MOBILE_ADVERTISING_ID', value: 'MOBILE_ADVERTISING_ID' } + ] + } + }, + audienceConfig: { + mode: { + type: 'synced', // Indicates that the audience is synced on some schedule; update as necessary + full_audience_sync: false // If true, we send the entire audience. If false, we just send the delta. + }, + async createAudience(request, createAudienceInput) { + const userListId = await createGoogleAudience(request, createAudienceInput, createAudienceInput.statsContext) + + return { + externalId: userListId + } + }, + + async getAudience(request, getAudienceInput) { + const userListId = await getGoogleAudience( + request, + getAudienceInput.settings, + getAudienceInput.externalId, + getAudienceInput.statsContext + ) + + return { + externalId: userListId + } + } + }, actions: { postConversion, uploadClickConversion, From 2801959de367cdb02c3c4da51370b60302e653c2 Mon Sep 17 00:00:00 2001 From: Maryam Sharif Date: Thu, 11 Jul 2024 12:06:08 -0700 Subject: [PATCH 03/15] perform methods --- .../google-enhanced-conversions/functions.ts | 178 +++++++++++++++++- .../userList/index.ts | 12 +- 2 files changed, 177 insertions(+), 13 deletions(-) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index 9c07c6eab4..b2e9842046 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -18,11 +18,16 @@ import { StatsContext } from '@segment/actions-core/destination-kit' import { Features } from '@segment/actions-core/mapping-kit' import { fullFormats } from 'ajv-formats/dist/formats' import { HTTPError } from '@segment/actions-core' +import type { Payload as UserListPayload } from './userList/generated-types' +import * as crypto from 'crypto' -export const API_VERSION = 'v15' -export const CANARY_API_VERSION = 'v15' +export const API_VERSION = 'v16' +export const CANARY_API_VERSION = 'v16' export const FLAGON_NAME = 'google-enhanced-canary-version' +export interface AudienceSettings { + external_id_type: string +} export class GoogleAdsError extends HTTPError { response: Response & { status: string @@ -216,10 +221,13 @@ export async function createGoogleAudience( ] } - const response = await request(`https://googleads.googleapis.com/v16/customers/${input.settings.customerId}:mutate`, { - method: 'post', - json - }) + const response = await request( + `https://googleads.googleapis.com/${API_VERSION}/customers/${input.settings.customerId}:mutate`, + { + method: 'post', + json + } + ) // Successful response body looks like: // {"results": [{ "resourceName": "customers//userLists/" }]} @@ -246,7 +254,7 @@ export async function getGoogleAudience( } const response = await request( - `https://googleads.googleapis.com/v16/customers/${settings.customerId}/googleAds:search`, + `https://googleads.googleapis.com/${API_VERSION}/customers/${settings.customerId}/googleAds:search`, { method: 'post', json @@ -263,3 +271,159 @@ export async function getGoogleAudience( statsClient?.incr('getAudience.success', 1, statsTags) return id } + +const formatEmail = (email: string, hash_data?: boolean): string => { + if (!hash_data) { + return email + } + const googleDomain = new RegExp('^(gmail|googlemail).s*', 'g') + let normalizedEmail = email.toLowerCase().trim() + const emailParts = normalizedEmail.split('@') + if (emailParts.length > 1 && emailParts[1].match(googleDomain)) { + emailParts[0] = emailParts[0].replace('.', '') + normalizedEmail = `${emailParts[0]}@${emailParts[1]}` + } + + return crypto.createHash('sha256').update(normalizedEmail).digest('hex') +} + +function formatToE164(phoneNumber: string, defaultCountryCode: string): string { + // Remove any non-numeric characters + const numericPhoneNumber = phoneNumber.replace(/\D/g, '') + + // Check if the phone number starts with the country code + let formattedPhoneNumber = numericPhoneNumber + if (!numericPhoneNumber.startsWith(defaultCountryCode)) { + formattedPhoneNumber = defaultCountryCode + numericPhoneNumber + } + + // Ensure the formatted phone number starts with '+' + if (!formattedPhoneNumber.startsWith('+')) { + formattedPhoneNumber = '+' + formattedPhoneNumber + } + + return formattedPhoneNumber +} + +const formatPhone = (phone: string, hash_data?: boolean): string => { + if (!hash_data) { + return phone + } + const formattedPhone = formatToE164(phone, '1') + return crypto.createHash('sha256').update(formattedPhone).digest('hex') +} + +const extractUserIdentifiers = (payloads: UserListPayload[], audienceSettings: AudienceSettings) => { + const removeUserIdentifiers = [] + const addUserIdentifiers = [] + + // Map user data to Google Ads API format + const identifierFunctions: { [key: string]: (payload: UserListPayload) => any } = { + MOBILE_ADVERTISING_ID: (payload: UserListPayload) => ({ + mobileId: payload.mobile_advertising_id?.trim() + }), + CRM_ID: (payload: UserListPayload) => ({ + thirdPartyUserId: payload.crm_id?.trim() + }), + CONTACT_INFO: (payload: UserListPayload) => { + const identifiers = [] + if (payload.email) { + identifiers.push({ + hashedEmail: formatEmail(payload.email, payload.hash_data) + }) + } + if (payload.phone) { + identifiers.push({ + hashedPhoneNumber: formatPhone(payload.phone, payload.hash_data) + }) + } + return identifiers + } + } + + // Map user data to Google Ads API format + for (const payload of payloads) { + if (payload.event_name == 'Audience Entered') { + addUserIdentifiers.push(identifierFunctions[audienceSettings.external_id_type](payload)) + } else if (payload.event_name == 'Audience Exited') { + removeUserIdentifiers.push(identifierFunctions[audienceSettings.external_id_type](payload)) + } + } + return [{ remove: { userIdentifiers: removeUserIdentifiers } }, { add: { userIdentifiers: addUserIdentifiers } }] +} + +const createOfflineUserJob = async (request: RequestClient, payload: UserListPayload, settings: any) => { + const url = `https://googleads.googleapis.com/${API_VERSION}/customers/${settings.customerId}/offlineUserDataJobs:create` + + const json = { + job: { + type: 'CUSTOMER_MATCH_USER_LIST', + customerMatchUserListMetadata: { + userList: `customers/${settings.customerId}/userLists/${payload.external_audience_id}` + } + } + } + + const response = await request(url, { + method: 'post', + json + }) + + return (response.data as any).results[0].resourceName +} + +const addOperations = async (request: RequestClient, userIdentifiers: any, resourceName: string) => { + const url = `https://googleads.googleapis.com/${API_VERSION}/${resourceName}:addOperations` + + const json = { + operations: userIdentifiers, + enable_warnings: true + } + + const response = await request(url, { + method: 'post', + json + }) + + return response.data +} + +const runOfflineUserJob = async (request: RequestClient, resourceName: string) => { + const url = `https://googleads.googleapis.com/${API_VERSION}/${resourceName}:run` + + const response = await request(url, { + method: 'post' + }) + + return response.data +} + +export const handleUpdate = async ( + request: RequestClient, + settings: any, + audienceSettings: any, + payloads: UserListPayload[], + statsContext: StatsContext | undefined +) => { + const statsClient = statsContext?.statsClient + const statsTags = statsContext?.tags + + // Format the user data for Google Ads API + const userIdentifiers = extractUserIdentifiers(payloads, audienceSettings) + + // Create an offline user data job + + const resourceName = await createOfflineUserJob(request, payloads[0], settings) + + // Add operations to the offline user data job + + await addOperations(request, userIdentifiers, resourceName) + + // Run the offline user data job + + const executedJob = await runOfflineUserJob(request, resourceName) + + statsClient?.incr('success.offlineUpdateAudience', 1, statsTags) + + return executedJob +} diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts index 910d4798fc..f4479f35d5 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts @@ -1,6 +1,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' +import { handleUpdate } from '../functions' const action: ActionDefinition = { title: 'Customer Match User List', @@ -141,12 +142,11 @@ const action: ActionDefinition = { readOnly: true } }, - perform: (request, data) => { - // Make your partner api request here! - return request('https://example.com', { - method: 'post', - json: data.payload - }) + perform: async (request, { settings, audienceSettings, payload, statsContext }) => { + return await handleUpdate(request, settings, audienceSettings, [payload], statsContext) + }, + performBatch: async (request, { settings, audienceSettings, payload, statsContext }) => { + return await handleUpdate(request, settings, audienceSettings, payload, statsContext) } } From 0c2f4f2f92dc15f35e053b83d82baa63dce95c78 Mon Sep 17 00:00:00 2001 From: Maryam Sharif Date: Thu, 11 Jul 2024 16:34:46 -0700 Subject: [PATCH 04/15] Add dev token to requests --- .../google-enhanced-conversions/functions.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index b2e9842046..6279150d58 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -225,6 +225,9 @@ export async function createGoogleAudience( `https://googleads.googleapis.com/${API_VERSION}/customers/${input.settings.customerId}:mutate`, { method: 'post', + headers: { + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` + }, json } ) @@ -257,6 +260,9 @@ export async function getGoogleAudience( `https://googleads.googleapis.com/${API_VERSION}/customers/${settings.customerId}/googleAds:search`, { method: 'post', + headers: { + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` + }, json } ) @@ -366,6 +372,9 @@ const createOfflineUserJob = async (request: RequestClient, payload: UserListPay const response = await request(url, { method: 'post', + headers: { + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` + }, json }) @@ -382,6 +391,9 @@ const addOperations = async (request: RequestClient, userIdentifiers: any, resou const response = await request(url, { method: 'post', + headers: { + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` + }, json }) @@ -392,7 +404,10 @@ const runOfflineUserJob = async (request: RequestClient, resourceName: string) = const url = `https://googleads.googleapis.com/${API_VERSION}/${resourceName}:run` const response = await request(url, { - method: 'post' + method: 'post', + headers: { + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` + } }) return response.data From 3036e2bac3221c2b6379f73a41003fb78106d2c4 Mon Sep 17 00:00:00 2001 From: Maryam Sharif Date: Fri, 12 Jul 2024 12:56:47 -0700 Subject: [PATCH 05/15] Better error handling --- .../google-enhanced-conversions/functions.ts | 110 ++++++++++++------ 1 file changed, 73 insertions(+), 37 deletions(-) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index 6279150d58..39b26b5d94 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -95,7 +95,7 @@ export async function getCustomVariables( method: 'post', headers: { authorization: `Bearer ${auth?.accessToken}`, - 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` + 'developer-token': `jswOXXIc50JI8nAuUGWVRg` }, json: { query: `SELECT conversion_custom_variable.id, conversion_custom_variable.name FROM conversion_custom_variable` @@ -358,7 +358,12 @@ const extractUserIdentifiers = (payloads: UserListPayload[], audienceSettings: A return [{ remove: { userIdentifiers: removeUserIdentifiers } }, { add: { userIdentifiers: addUserIdentifiers } }] } -const createOfflineUserJob = async (request: RequestClient, payload: UserListPayload, settings: any) => { +const createOfflineUserJob = async ( + request: RequestClient, + payload: UserListPayload, + settings: any, + statsContext: StatsContext | undefined +) => { const url = `https://googleads.googleapis.com/${API_VERSION}/customers/${settings.customerId}/offlineUserDataJobs:create` const json = { @@ -370,18 +375,33 @@ const createOfflineUserJob = async (request: RequestClient, payload: UserListPay } } - const response = await request(url, { - method: 'post', - headers: { - 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` - }, - json - }) + try { + const response = await request(url, { + method: 'post', + headers: { + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` + }, + json + }) - return (response.data as any).results[0].resourceName + return (response.data as any).results[0].resourceName + } catch (error) { + statsContext?.statsClient?.incr('error.createJob', 1, statsContext?.tags) + console.log(error) + throw new IntegrationError( + (error as GoogleAdsError).response?.statusText, + 'INVALID_RESPONSE', + (error as GoogleAdsError).response?.status + ) + } } -const addOperations = async (request: RequestClient, userIdentifiers: any, resourceName: string) => { +const addOperations = async ( + request: RequestClient, + userIdentifiers: any, + resourceName: string, + statsContext: StatsContext | undefined +) => { const url = `https://googleads.googleapis.com/${API_VERSION}/${resourceName}:addOperations` const json = { @@ -389,28 +409,50 @@ const addOperations = async (request: RequestClient, userIdentifiers: any, resou enable_warnings: true } - const response = await request(url, { - method: 'post', - headers: { - 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` - }, - json - }) + try { + const response = await request(url, { + method: 'post', + headers: { + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` + }, + json + }) - return response.data + return response.data + } catch (error) { + statsContext?.statsClient?.incr('error.addOperations', 1, statsContext?.tags) + throw new IntegrationError( + (error as GoogleAdsError).response?.statusText, + 'INVALID_RESPONSE', + (error as GoogleAdsError).response?.status + ) + } } -const runOfflineUserJob = async (request: RequestClient, resourceName: string) => { +const runOfflineUserJob = async ( + request: RequestClient, + resourceName: string, + statsContext: StatsContext | undefined +) => { const url = `https://googleads.googleapis.com/${API_VERSION}/${resourceName}:run` - const response = await request(url, { - method: 'post', - headers: { - 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` - } - }) + try { + const response = await request(url, { + method: 'post', + headers: { + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` + } + }) - return response.data + return response.data + } catch (error) { + statsContext?.statsClient?.incr('error.runJob', 1, statsContext?.tags) + throw new IntegrationError( + (error as GoogleAdsError).response?.statusText, + 'INVALID_RESPONSE', + (error as GoogleAdsError).response?.status + ) + } } export const handleUpdate = async ( @@ -420,25 +462,19 @@ export const handleUpdate = async ( payloads: UserListPayload[], statsContext: StatsContext | undefined ) => { - const statsClient = statsContext?.statsClient - const statsTags = statsContext?.tags - // Format the user data for Google Ads API const userIdentifiers = extractUserIdentifiers(payloads, audienceSettings) // Create an offline user data job - - const resourceName = await createOfflineUserJob(request, payloads[0], settings) + const resourceName = await createOfflineUserJob(request, payloads[0], settings, statsContext) // Add operations to the offline user data job - - await addOperations(request, userIdentifiers, resourceName) + await addOperations(request, userIdentifiers, resourceName, statsContext) // Run the offline user data job + const executedJob = await runOfflineUserJob(request, resourceName, statsContext) - const executedJob = await runOfflineUserJob(request, resourceName) - - statsClient?.incr('success.offlineUpdateAudience', 1, statsTags) + statsContext?.statsClient?.incr('success.offlineUpdateAudience', 1, statsContext?.tags) return executedJob } From 27d629d95da12ca8b99904c232e9be77d9b3b071 Mon Sep 17 00:00:00 2001 From: Maryam Sharif Date: Fri, 12 Jul 2024 18:30:58 -0700 Subject: [PATCH 06/15] Add other contact fields --- .../google-enhanced-conversions/functions.ts | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index 39b26b5d94..b105f7e499 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -322,7 +322,6 @@ const formatPhone = (phone: string, hash_data?: boolean): string => { const extractUserIdentifiers = (payloads: UserListPayload[], audienceSettings: AudienceSettings) => { const removeUserIdentifiers = [] const addUserIdentifiers = [] - // Map user data to Google Ads API format const identifierFunctions: { [key: string]: (payload: UserListPayload) => any } = { MOBILE_ADVERTISING_ID: (payload: UserListPayload) => ({ @@ -332,21 +331,23 @@ const extractUserIdentifiers = (payloads: UserListPayload[], audienceSettings: A thirdPartyUserId: payload.crm_id?.trim() }), CONTACT_INFO: (payload: UserListPayload) => { - const identifiers = [] - if (payload.email) { - identifiers.push({ - hashedEmail: formatEmail(payload.email, payload.hash_data) - }) - } - if (payload.phone) { - identifiers.push({ - hashedPhoneNumber: formatPhone(payload.phone, payload.hash_data) - }) + const identifiers = { + hashedEmail: formatEmail(payload.email ?? '', payload.hash_data), + hashedPhoneNumber: formatPhone(payload.phone ?? '', payload.hash_data), + hashedFirstName: crypto + .createHash('sha256') + .update(payload.first_name ?? '') + .digest('hex'), + hashedLastName: crypto + .createHash('sha256') + .update(payload.last_name ?? '') + .digest('hex'), + countryCode: payload.country_code, + postalCode: payload.postal_code } return identifiers } } - // Map user data to Google Ads API format for (const payload of payloads) { if (payload.event_name == 'Audience Entered') { @@ -355,7 +356,7 @@ const extractUserIdentifiers = (payloads: UserListPayload[], audienceSettings: A removeUserIdentifiers.push(identifierFunctions[audienceSettings.external_id_type](payload)) } } - return [{ remove: { userIdentifiers: removeUserIdentifiers } }, { add: { userIdentifiers: addUserIdentifiers } }] + return [addUserIdentifiers, removeUserIdentifiers] } const createOfflineUserJob = async ( @@ -383,8 +384,7 @@ const createOfflineUserJob = async ( }, json }) - - return (response.data as any).results[0].resourceName + return (response.data as any).resourceName } catch (error) { statsContext?.statsClient?.incr('error.createJob', 1, statsContext?.tags) console.log(error) @@ -463,13 +463,19 @@ export const handleUpdate = async ( statsContext: StatsContext | undefined ) => { // Format the user data for Google Ads API - const userIdentifiers = extractUserIdentifiers(payloads, audienceSettings) + const [adduserIdentifiers, removeUserIdentifiers] = extractUserIdentifiers(payloads, audienceSettings) // Create an offline user data job const resourceName = await createOfflineUserJob(request, payloads[0], settings, statsContext) // Add operations to the offline user data job - await addOperations(request, userIdentifiers, resourceName, statsContext) + if (adduserIdentifiers.length > 0) { + await addOperations(request, [{ create: { userIdentifiers: adduserIdentifiers } }], resourceName, statsContext) + } + + if (removeUserIdentifiers.length > 0) { + await addOperations(request, [{ remove: { userIdentifiers: removeUserIdentifiers } }], resourceName, statsContext) + } // Run the offline user data job const executedJob = await runOfflineUserJob(request, resourceName, statsContext) From fb11cca6ea7f5a1b3f9e2cc69e97de3e5c76ad78 Mon Sep 17 00:00:00 2001 From: Maryam Sharif Date: Fri, 12 Jul 2024 19:02:44 -0700 Subject: [PATCH 07/15] Fix addressInfo --- .../google-enhanced-conversions/functions.ts | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index b105f7e499..2c6bbcef15 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -331,19 +331,32 @@ const extractUserIdentifiers = (payloads: UserListPayload[], audienceSettings: A thirdPartyUserId: payload.crm_id?.trim() }), CONTACT_INFO: (payload: UserListPayload) => { - const identifiers = { - hashedEmail: formatEmail(payload.email ?? '', payload.hash_data), - hashedPhoneNumber: formatPhone(payload.phone ?? '', payload.hash_data), - hashedFirstName: crypto - .createHash('sha256') - .update(payload.first_name ?? '') - .digest('hex'), - hashedLastName: crypto - .createHash('sha256') - .update(payload.last_name ?? '') - .digest('hex'), - countryCode: payload.country_code, - postalCode: payload.postal_code + const identifiers = [] + if (payload.email) { + identifiers.push({ + hashedEmail: formatEmail(payload.email, payload.hash_data) + }) + } + if (payload.phone) { + identifiers.push({ + hashedPhoneNumber: formatPhone(payload.phone, payload.hash_data) + }) + } + if (payload.first_name || payload.last_name || payload.country_code || payload.postal_code) { + identifiers.push({ + addressInfo: { + hashedFirstName: crypto + .createHash('sha256') + .update(payload.first_name ?? '') + .digest('hex'), + hashedLastName: crypto + .createHash('sha256') + .update(payload.last_name ?? '') + .digest('hex'), + countryCode: payload.country_code ?? '', + postalCode: payload.postal_code ?? '' + } + }) } return identifiers } From a1374d1b6dd92c00da1e84e043638d9e62d44020 Mon Sep 17 00:00:00 2001 From: Maryam Sharif Date: Fri, 12 Jul 2024 19:34:52 -0700 Subject: [PATCH 08/15] Add smartHash function --- packages/core/src/hashing-utils.ts | 13 +++++++++++++ packages/core/src/index.ts | 1 + .../google-enhanced-conversions/functions.ts | 16 +++++----------- 3 files changed, 19 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/hashing-utils.ts diff --git a/packages/core/src/hashing-utils.ts b/packages/core/src/hashing-utils.ts new file mode 100644 index 0000000000..d019a7b521 --- /dev/null +++ b/packages/core/src/hashing-utils.ts @@ -0,0 +1,13 @@ +/** + * Checks if value is already hashed with sha256 to avoid double hashing + */ +import * as crypto from 'crypto' + +const sha256HashedRegex = /^[a-f0-9]{64}$/i + +export function sha256SmartHash(value: string): string { + if (sha256HashedRegex.test(value)) { + return value + } + return crypto.createHash('sha256').update(value).digest('hex') +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e89318c0bf..23c89ce497 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -43,6 +43,7 @@ export { export { get } from './get' export { omit } from './omit' export { removeUndefined } from './remove-undefined' +export { sha256SmartHash } from './hashing-utils' export { time, duration } from './time' export { realTypeOf, isObject, isArray, isString } from './real-type-of' diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index 2c6bbcef15..fa96c95298 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -19,7 +19,7 @@ import { Features } from '@segment/actions-core/mapping-kit' import { fullFormats } from 'ajv-formats/dist/formats' import { HTTPError } from '@segment/actions-core' import type { Payload as UserListPayload } from './userList/generated-types' -import * as crypto from 'crypto' +import { sha256SmartHash } from '@segment/actions-core' export const API_VERSION = 'v16' export const CANARY_API_VERSION = 'v16' @@ -290,7 +290,7 @@ const formatEmail = (email: string, hash_data?: boolean): string => { normalizedEmail = `${emailParts[0]}@${emailParts[1]}` } - return crypto.createHash('sha256').update(normalizedEmail).digest('hex') + return sha256SmartHash(normalizedEmail) } function formatToE164(phoneNumber: string, defaultCountryCode: string): string { @@ -316,7 +316,7 @@ const formatPhone = (phone: string, hash_data?: boolean): string => { return phone } const formattedPhone = formatToE164(phone, '1') - return crypto.createHash('sha256').update(formattedPhone).digest('hex') + return sha256SmartHash(formattedPhone) } const extractUserIdentifiers = (payloads: UserListPayload[], audienceSettings: AudienceSettings) => { @@ -345,14 +345,8 @@ const extractUserIdentifiers = (payloads: UserListPayload[], audienceSettings: A if (payload.first_name || payload.last_name || payload.country_code || payload.postal_code) { identifiers.push({ addressInfo: { - hashedFirstName: crypto - .createHash('sha256') - .update(payload.first_name ?? '') - .digest('hex'), - hashedLastName: crypto - .createHash('sha256') - .update(payload.last_name ?? '') - .digest('hex'), + hashedFirstName: sha256SmartHash(payload.first_name ?? ''), + hashedLastName: sha256SmartHash(payload.last_name ?? ''), countryCode: payload.country_code ?? '', postalCode: payload.postal_code ?? '' } From ce66218a4999985de0ed589a41bd0327ee124455 Mon Sep 17 00:00:00 2001 From: Maryam Sharif Date: Mon, 15 Jul 2024 16:14:37 -0700 Subject: [PATCH 09/15] Move app_id to audience setting --- .../google-enhanced-conversions/functions.ts | 21 ++++++++++++++++--- .../generated-types.ts | 4 ++++ .../google-enhanced-conversions/index.ts | 20 +++++++++++++----- .../userList/generated-types.ts | 8 ------- .../userList/index.ts | 12 ----------- 5 files changed, 37 insertions(+), 28 deletions(-) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index fa96c95298..7815014ec7 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -28,6 +28,13 @@ export const FLAGON_NAME = 'google-enhanced-canary-version' export interface AudienceSettings { external_id_type: string } +export interface GoogleAdsResonse { + resourceName?: string +} +export interface CreateGoogleAudienceResponse { + resourceName?: string + results: Array<{ resourceName: string }> +} export class GoogleAdsError extends HTTPError { response: Response & { status: string @@ -41,7 +48,10 @@ export interface CreateAudienceInput { customerId?: string conversionTrackingId?: string } - audienceSettings?: unknown + audienceSettings: { + external_id_type: string + app_id?: string + } } export function formatCustomVariables( @@ -205,6 +215,10 @@ export async function createGoogleAudience( input: CreateAudienceInput, statsContext?: StatsContext ) { + if (input.audienceSettings.external_id_type === 'MOBILE_ADVERTISING_ID' && !input.audienceSettings.app_id) { + throw new PayloadValidationError('App ID is required when external ID type is mobile advertising ID.') + } + const statsClient = statsContext?.statsClient const statsTags = statsContext?.tags const json = { @@ -212,7 +226,8 @@ export async function createGoogleAudience( { create: { crmBasedUserList: { - uploadKeyType: (input.audienceSettings as { external_id_type: string }).external_id_type + uploadKeyType: input.audienceSettings.external_id_type, + appId: input.audienceSettings.app_id }, membershipLifeSpan: '10000', // In days. 10000 is interpreted as 'unlimited'. name: `${input.audienceName}` @@ -234,7 +249,7 @@ export async function createGoogleAudience( // Successful response body looks like: // {"results": [{ "resourceName": "customers//userLists/" }]} - const name = (response.data as any).results[0].resourceName + const name = (response.data as CreateGoogleAudienceResponse).results[0].resourceName if (!name) { statsClient?.incr('createAudience.error', 1, statsTags) throw new IntegrationError('Failed to receive a created customer list id.', 'INVALID_RESPONSE', 400) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/generated-types.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/generated-types.ts index 4f5bd5f4d5..1b557e8874 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/generated-types.ts @@ -17,4 +17,8 @@ export interface AudienceSettings { * Customer match upload key types. */ external_id_type: string + /** + * A string that uniquely identifies a mobile application from which the data was collected. Required if external ID type is mobile advertising ID + */ + app_id?: string } diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts index 4843da7aac..a9806ff572 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts @@ -4,7 +4,7 @@ import postConversion from './postConversion' import uploadCallConversion from './uploadCallConversion' import uploadClickConversion from './uploadClickConversion' import uploadConversionAdjustment from './uploadConversionAdjustment' -import { createGoogleAudience, getGoogleAudience } from './functions' +import { CreateAudienceInput, createGoogleAudience, getGoogleAudience } from './functions' import userList from './userList' @@ -81,10 +81,16 @@ const destination: AudienceDestinationDefinition = { description: 'Customer match upload key types.', required: true, choices: [ - { label: 'CONTACT_INFO', value: 'CONTACT_INFO' }, - { label: 'CRM_ID', value: 'CRM_ID' }, - { label: 'MOBILE_ADVERTISING_ID', value: 'MOBILE_ADVERTISING_ID' } + { label: 'CONTACT INFO', value: 'CONTACT_INFO' }, + { label: 'CRM ID', value: 'CRM_ID' }, + { label: 'MOBILE ADVERTISING ID', value: 'MOBILE_ADVERTISING_ID' } ] + }, + app_id: { + label: 'App ID', + description: + 'A string that uniquely identifies a mobile application from which the data was collected. Required if external ID type is mobile advertising ID', + type: 'string' } }, audienceConfig: { @@ -93,7 +99,11 @@ const destination: AudienceDestinationDefinition = { full_audience_sync: false // If true, we send the entire audience. If false, we just send the delta. }, async createAudience(request, createAudienceInput) { - const userListId = await createGoogleAudience(request, createAudienceInput, createAudienceInput.statsContext) + const userListId = await createGoogleAudience( + request, + createAudienceInput as CreateAudienceInput, + createAudienceInput.statsContext + ) return { externalId: userListId diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts index c51a7eb800..3ecc96e465 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts @@ -25,10 +25,6 @@ export interface Payload { * The user's postal code. */ postal_code?: string - /** - * Should Segment hash the user’s data before sending it to Google? Set this to false if you are already sending hashed data. - */ - hash_data?: boolean /** * Advertiser-assigned user ID for Customer Match upload. */ @@ -37,10 +33,6 @@ export interface Payload { * Mobile device ID (advertising ID/IDFA). */ mobile_advertising_id?: string - /** - * A string that uniquely identifies a mobile application from which the data was collected. - */ - app_id?: string /** * This represents consent for ad user data.For more information on consent, refer to [Google Ads API Consent](https://developers.google.com/google-ads/api/rest/reference/rest/v15/Consent). */ diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts index f4479f35d5..396663a491 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts @@ -66,13 +66,6 @@ const action: ActionDefinition = { description: "The user's postal code.", type: 'string' }, - hash_data: { - label: 'Hash Data?', - description: - 'Should Segment hash the user’s data before sending it to Google? Set this to false if you are already sending hashed data.', - type: 'boolean', - default: true - }, crm_id: { label: 'CRM ID', description: 'Advertiser-assigned user ID for Customer Match upload.', @@ -86,11 +79,6 @@ const action: ActionDefinition = { '@path': '$.context.device.advertisingId' } }, - app_id: { - label: 'App ID', - description: 'A string that uniquely identifies a mobile application from which the data was collected.', - type: 'string' - }, ad_user_data_consent_state: { label: 'Ad User Data Consent State', description: From ed8c8928d16ba0d93753c3adf90e43d66e14098c Mon Sep 17 00:00:00 2001 From: Maryam Sharif Date: Mon, 15 Jul 2024 17:10:34 -0700 Subject: [PATCH 10/15] Add retlOnMappingSave hook --- .../google-enhanced-conversions/functions.ts | 4 +- .../userList/generated-types.ts | 34 ++++++ .../userList/index.ts | 109 +++++++++++++++++- 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index 7815014ec7..c2560beade 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -349,12 +349,12 @@ const extractUserIdentifiers = (payloads: UserListPayload[], audienceSettings: A const identifiers = [] if (payload.email) { identifiers.push({ - hashedEmail: formatEmail(payload.email, payload.hash_data) + hashedEmail: formatEmail(payload.email) }) } if (payload.phone) { identifiers.push({ - hashedPhoneNumber: formatPhone(payload.phone, payload.hash_data) + hashedPhoneNumber: formatPhone(payload.phone) }) } if (payload.first_name || payload.last_name || payload.country_code || payload.postal_code) { diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts index 3ecc96e465..7cf7e68e9c 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts @@ -54,3 +54,37 @@ export interface Payload { */ event_name: string } +// Generated bundle for hooks. DO NOT MODIFY IT BY HAND. + +export interface HookBundle { + retlOnMappingSave: { + inputs?: { + /** + * The ID of an existing Google list that you would like to sync users to. If you provide this, we will not create a new list. + */ + list_id?: string + /** + * The name of the Google list that you would like to create. + */ + list_name?: string + /** + * Customer match upload key types. + */ + external_id_type: string + /** + * A string that uniquely identifies a mobile application from which the data was collected. Required if external ID type is mobile advertising ID + */ + app_id?: string + } + outputs?: { + /** + * The ID of the created Google Customer Match User list that users will be synced to. + */ + id?: string + /** + * The name of the created Google Customer Match User list that users will be synced to. + */ + name?: string + } + } +} diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts index 396663a491..038872707a 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts @@ -1,7 +1,8 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { handleUpdate } from '../functions' +import { createGoogleAudience, getGoogleAudience, handleUpdate } from '../functions' +import { IntegrationError } from '@segment/actions-core' const action: ActionDefinition = { title: 'Customer Match User List', @@ -130,6 +131,112 @@ const action: ActionDefinition = { readOnly: true } }, + hooks: { + retlOnMappingSave: { + label: 'Connect to a Google Customer Match User List', + description: 'When saving this mapping, we will create a list in Google using the fields you provide.', + inputFields: { + list_id: { + type: 'string', + label: 'Existing List ID', + description: + 'The ID of an existing Google list that you would like to sync users to. If you provide this, we will not create a new list.', + required: false + }, + list_name: { + type: 'string', + label: 'List Name', + description: 'The name of the Google list that you would like to create.', + required: false + }, + external_id_type: { + type: 'string', + label: 'External ID Type', + description: 'Customer match upload key types.', + required: true, + choices: [ + { label: 'CONTACT INFO', value: 'CONTACT_INFO' }, + { label: 'CRM ID', value: 'CRM_ID' }, + { label: 'MOBILE ADVERTISING ID', value: 'MOBILE_ADVERTISING_ID' } + ] + }, + app_id: { + label: 'App ID', + description: + 'A string that uniquely identifies a mobile application from which the data was collected. Required if external ID type is mobile advertising ID', + type: 'string', + depends_on: { + conditions: [ + { + fieldKey: 'external_id_type', + operator: 'is', + value: 'MOBILE_ADVERTISING_ID' + } + ] + } + } + }, + outputTypes: { + id: { + type: 'string', + label: 'ID', + description: 'The ID of the created Google Customer Match User list that users will be synced to.', + required: false + }, + name: { + type: 'string', + label: 'List Name', + description: 'The name of the created Google Customer Match User list that users will be synced to.', + required: false + } + }, + performHook: async (request, { settings, hookInputs, statsContext }) => { + if (hookInputs.list_id) { + try { + return getGoogleAudience(request, settings, hookInputs.list_id) + } catch (e) { + const message = (e as IntegrationError).message || JSON.stringify(e) || 'Failed to get list' + const code = (e as IntegrationError).code || 'GET_LIST_FAILURE' + return { + error: { + message, + code + } + } + } + } + + try { + const input = { + audienceName: hookInputs.list_name, + settings: settings, + audienceSettings: { + external_id_type: hookInputs.external_id_type, + app_id: hookInputs.app_id + } + } + const listId = await createGoogleAudience(request, input, statsContext) + + return { + successMessage: `List '${hookInputs.list_name}' (id: ${listId}) created successfully!`, + savedData: { + id: listId, + name: hookInputs.list_name + } + } + } catch (e) { + const message = (e as IntegrationError).message || JSON.stringify(e) || 'Failed to create list' + const code = (e as IntegrationError).code || 'CREATE_LIST_FAILURE' + return { + error: { + message, + code + } + } + } + } + } + }, perform: async (request, { settings, audienceSettings, payload, statsContext }) => { return await handleUpdate(request, settings, audienceSettings, [payload], statsContext) }, From 05112c3619e74dea1ceb8fa977840d97520c55eb Mon Sep 17 00:00:00 2001 From: Maryam Sharif Date: Mon, 15 Jul 2024 17:42:45 -0700 Subject: [PATCH 11/15] Fixes --- .../google-enhanced-conversions/functions.ts | 23 ++++++++++++------- .../userList/index.ts | 23 +++++++++++++++---- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index c2560beade..09191a542e 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -261,7 +261,7 @@ export async function createGoogleAudience( export async function getGoogleAudience( request: RequestClient, - settings: any, + settings: CreateAudienceInput['settings'], externalId: string, statsContext?: StatsContext ) { @@ -384,16 +384,22 @@ const extractUserIdentifiers = (payloads: UserListPayload[], audienceSettings: A const createOfflineUserJob = async ( request: RequestClient, payload: UserListPayload, - settings: any, - statsContext: StatsContext | undefined + settings: CreateAudienceInput['settings'], + hookListId?: string, + statsContext?: StatsContext | undefined ) => { const url = `https://googleads.googleapis.com/${API_VERSION}/customers/${settings.customerId}/offlineUserDataJobs:create` + let external_audience_id = payload.external_audience_id + if (hookListId) { + external_audience_id = hookListId + } + const json = { job: { type: 'CUSTOMER_MATCH_USER_LIST', customerMatchUserListMetadata: { - userList: `customers/${settings.customerId}/userLists/${payload.external_audience_id}` + userList: `customers/${settings.customerId}/userLists/${external_audience_id}` } } } @@ -420,7 +426,7 @@ const createOfflineUserJob = async ( const addOperations = async ( request: RequestClient, - userIdentifiers: any, + userIdentifiers: [{}], resourceName: string, statsContext: StatsContext | undefined ) => { @@ -479,16 +485,17 @@ const runOfflineUserJob = async ( export const handleUpdate = async ( request: RequestClient, - settings: any, - audienceSettings: any, + settings: CreateAudienceInput['settings'], + audienceSettings: CreateAudienceInput['audienceSettings'], payloads: UserListPayload[], + hookOutputs: string, statsContext: StatsContext | undefined ) => { // Format the user data for Google Ads API const [adduserIdentifiers, removeUserIdentifiers] = extractUserIdentifiers(payloads, audienceSettings) // Create an offline user data job - const resourceName = await createOfflineUserJob(request, payloads[0], settings, statsContext) + const resourceName = await createOfflineUserJob(request, payloads[0], settings, hookOutputs, statsContext) // Add operations to the offline user data job if (adduserIdentifiers.length > 0) { diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts index 038872707a..b98af20386 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts @@ -237,11 +237,26 @@ const action: ActionDefinition = { } } }, - perform: async (request, { settings, audienceSettings, payload, statsContext }) => { - return await handleUpdate(request, settings, audienceSettings, [payload], statsContext) + perform: async (request, { settings, audienceSettings, payload, hookOutputs, statsContext }) => { + hookOutputs?.retlOnMappingSave?.outputs.id + return await handleUpdate( + request, + settings, + audienceSettings, + [payload], + hookOutputs?.retlOnMappingSave?.outputs.id, + statsContext + ) }, - performBatch: async (request, { settings, audienceSettings, payload, statsContext }) => { - return await handleUpdate(request, settings, audienceSettings, payload, statsContext) + performBatch: async (request, { settings, audienceSettings, payload, hookOutputs, statsContext }) => { + return await handleUpdate( + request, + settings, + audienceSettings, + payload, + hookOutputs?.retlOnMappingSave?.outputs.id, + statsContext + ) } } From 593fd4d62c07a00147fe1a2f2bfea969dea86ae3 Mon Sep 17 00:00:00 2001 From: Maryam Sharif Date: Tue, 16 Jul 2024 10:39:12 -0700 Subject: [PATCH 12/15] Patch browser build fail --- packages/browser-destinations/webpack.config.js | 3 ++- .../src/destinations/google-enhanced-conversions/functions.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/browser-destinations/webpack.config.js b/packages/browser-destinations/webpack.config.js index 7c70996b82..b3c9b1e43f 100644 --- a/packages/browser-destinations/webpack.config.js +++ b/packages/browser-destinations/webpack.config.js @@ -104,7 +104,8 @@ const unobfuscatedOutput = { mainFields: ['exports', 'module', 'browser', 'main'], extensions: ['.ts', '.js'], fallback: { - vm: require.resolve('vm-browserify') + vm: require.resolve('vm-browserify'), + crypto: false } }, devServer: { diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index 09191a542e..37e59d3d2d 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -105,7 +105,7 @@ export async function getCustomVariables( method: 'post', headers: { authorization: `Bearer ${auth?.accessToken}`, - 'developer-token': `jswOXXIc50JI8nAuUGWVRg` + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` }, json: { query: `SELECT conversion_custom_variable.id, conversion_custom_variable.name FROM conversion_custom_variable` From 8ca37e77d63e19fdb8356b677e1cfa4f08eb182f Mon Sep 17 00:00:00 2001 From: Maryam Sharif Date: Tue, 16 Jul 2024 17:07:15 -0700 Subject: [PATCH 13/15] add dynamic field --- .../google-enhanced-conversions/functions.ts | 32 ++++++++++++++++--- .../google-enhanced-conversions/index.ts | 12 ++++++- .../userList/generated-types.ts | 2 +- .../userList/index.ts | 14 +++++--- 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index 37e59d3d2d..5b0da7a5d7 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -210,10 +210,31 @@ export const commonHashedEmailValidation = (email: string): string => { return String(hash(email)) } +export async function getListIds(request: RequestClient, settings: CreateAudienceInput['settings'], auth?: any) { + const json = { + query: `SELECT user_list.id, user_list.name FROM user_list` + } + + const response = await request( + `https://googleads.googleapis.com/${API_VERSION}/customers/${settings.customerId}/googleAds:search`, + { + method: 'post', + headers: { + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}`, + authorization: `Bearer ${auth?.accessToken}` + }, + json + } + ) + + return (response.data as any).results +} + export async function createGoogleAudience( request: RequestClient, input: CreateAudienceInput, - statsContext?: StatsContext + statsContext?: StatsContext, + auth?: any ) { if (input.audienceSettings.external_id_type === 'MOBILE_ADVERTISING_ID' && !input.audienceSettings.app_id) { throw new PayloadValidationError('App ID is required when external ID type is mobile advertising ID.') @@ -241,7 +262,8 @@ export async function createGoogleAudience( { method: 'post', headers: { - 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}`, + authorization: `Bearer ${auth?.accessToken}` }, json } @@ -263,7 +285,8 @@ export async function getGoogleAudience( request: RequestClient, settings: CreateAudienceInput['settings'], externalId: string, - statsContext?: StatsContext + statsContext?: StatsContext, + auth?: any ) { const statsClient = statsContext?.statsClient const statsTags = statsContext?.tags @@ -276,7 +299,8 @@ export async function getGoogleAudience( { method: 'post', headers: { - 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}`, + authorization: `Bearer ${auth?.accessToken}` }, json } diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts index a9806ff572..2ece378be0 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts @@ -90,7 +90,17 @@ const destination: AudienceDestinationDefinition = { label: 'App ID', description: 'A string that uniquely identifies a mobile application from which the data was collected. Required if external ID type is mobile advertising ID', - type: 'string' + type: 'string', + depends_on: { + match: 'all', + conditions: [ + { + fieldKey: 'external_id_type', + operator: 'is', + value: 'MOBILE_ADVERTISING_ID' + } + ] + } } }, audienceConfig: { diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts index 7cf7e68e9c..a427b7c31f 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts @@ -70,7 +70,7 @@ export interface HookBundle { /** * Customer match upload key types. */ - external_id_type: string + external_id_type?: string /** * A string that uniquely identifies a mobile application from which the data was collected. Required if external ID type is mobile advertising ID */ diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts index b98af20386..2a2f84eb5a 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts @@ -1,7 +1,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { createGoogleAudience, getGoogleAudience, handleUpdate } from '../functions' +import { createGoogleAudience, getGoogleAudience, getListIds, handleUpdate } from '../functions' import { IntegrationError } from '@segment/actions-core' const action: ActionDefinition = { @@ -141,7 +141,10 @@ const action: ActionDefinition = { label: 'Existing List ID', description: 'The ID of an existing Google list that you would like to sync users to. If you provide this, we will not create a new list.', - required: false + required: false, + dynamic: async (request, { settings, auth }) => { + return await getListIds(request, settings, auth) + } }, list_name: { type: 'string', @@ -153,7 +156,7 @@ const action: ActionDefinition = { type: 'string', label: 'External ID Type', description: 'Customer match upload key types.', - required: true, + required: false, choices: [ { label: 'CONTACT INFO', value: 'CONTACT_INFO' }, { label: 'CRM ID', value: 'CRM_ID' }, @@ -166,6 +169,7 @@ const action: ActionDefinition = { 'A string that uniquely identifies a mobile application from which the data was collected. Required if external ID type is mobile advertising ID', type: 'string', depends_on: { + match: 'all', conditions: [ { fieldKey: 'external_id_type', @@ -190,7 +194,7 @@ const action: ActionDefinition = { required: false } }, - performHook: async (request, { settings, hookInputs, statsContext }) => { + performHook: async (request, { auth, settings, hookInputs, statsContext }) => { if (hookInputs.list_id) { try { return getGoogleAudience(request, settings, hookInputs.list_id) @@ -215,7 +219,7 @@ const action: ActionDefinition = { app_id: hookInputs.app_id } } - const listId = await createGoogleAudience(request, input, statsContext) + const listId = await createGoogleAudience(request, input, statsContext, auth) return { successMessage: `List '${hookInputs.list_name}' (id: ${listId}) created successfully!`, From 0d665eb83ae6f93cd4704fd5f728d64ca6df8de8 Mon Sep 17 00:00:00 2001 From: Maryam Sharif Date: Wed, 17 Jul 2024 13:48:49 -0700 Subject: [PATCH 14/15] Fixes --- .../google-enhanced-conversions/functions.ts | 71 +++++++++---------- .../google-enhanced-conversions/index.ts | 3 +- .../google-enhanced-conversions/types.ts | 34 +++++++++ 3 files changed, 71 insertions(+), 37 deletions(-) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index 5b0da7a5d7..ef44dd2ee2 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -5,7 +5,12 @@ import { QueryResponse, ConversionActionId, ConversionActionResponse, - CustomVariableInterface + CustomVariableInterface, + CreateAudienceInput, + CreateGoogleAudienceResponse, + AudienceSettings, + UserListResponse, + UserList } from './types' import { ModifiedResponse, @@ -25,16 +30,6 @@ export const API_VERSION = 'v16' export const CANARY_API_VERSION = 'v16' export const FLAGON_NAME = 'google-enhanced-canary-version' -export interface AudienceSettings { - external_id_type: string -} -export interface GoogleAdsResonse { - resourceName?: string -} -export interface CreateGoogleAudienceResponse { - resourceName?: string - results: Array<{ resourceName: string }> -} export class GoogleAdsError extends HTTPError { response: Response & { status: string @@ -42,18 +37,6 @@ export class GoogleAdsError extends HTTPError { } } -export interface CreateAudienceInput { - audienceName: string - settings: { - customerId?: string - conversionTrackingId?: string - } - audienceSettings: { - external_id_type: string - app_id?: string - } -} - export function formatCustomVariables( customVariables: object, customVariableIdsResults: Array @@ -215,19 +198,35 @@ export async function getListIds(request: RequestClient, settings: CreateAudienc query: `SELECT user_list.id, user_list.name FROM user_list` } - const response = await request( - `https://googleads.googleapis.com/${API_VERSION}/customers/${settings.customerId}/googleAds:search`, - { - method: 'post', - headers: { - 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}`, - authorization: `Bearer ${auth?.accessToken}` - }, - json - } - ) + try { + const response: ModifiedResponse = await request( + `https://googleads.googleapis.com/${API_VERSION}/customers/${settings.customerId}/googleAds:search`, + { + method: 'post', + headers: { + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}`, + authorization: `Bearer ${auth?.accessToken}` + }, + json + } + ) - return (response.data as any).results + const choices = response.data.results.map((input: UserList) => { + return { value: input.userList.id, label: input.userList.name } + }) + return { + choices + } + } catch (err) { + return { + choices: [], + nextPage: '', + error: { + message: (err as GoogleAdsError).response?.statusText ?? 'Unknown error', + code: (err as GoogleAdsError).response?.status + '' ?? '500' + } + } + } } export async function createGoogleAudience( @@ -258,7 +257,7 @@ export async function createGoogleAudience( } const response = await request( - `https://googleads.googleapis.com/${API_VERSION}/customers/${input.settings.customerId}:mutate`, + `https://googleads.googleapis.com/${API_VERSION}/customers/${input.settings.customerId}/userLists:mutate`, { method: 'post', headers: { diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts index 2ece378be0..14fe72605a 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts @@ -4,7 +4,8 @@ import postConversion from './postConversion' import uploadCallConversion from './uploadCallConversion' import uploadClickConversion from './uploadClickConversion' import uploadConversionAdjustment from './uploadConversionAdjustment' -import { CreateAudienceInput, createGoogleAudience, getGoogleAudience } from './functions' +import { CreateAudienceInput } from './types' +import { createGoogleAudience, getGoogleAudience } from './functions' import userList from './userList' diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/types.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/types.ts index a5f00c41f8..b6406d638d 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/types.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/types.ts @@ -119,3 +119,37 @@ export interface PartialErrorResponse { } results: {}[] } + +export interface UserList { + userList: { + resourceName: string + id: string + name: string + } +} + +export interface UserListResponse { + results: Array + fieldMask: string +} + +export interface CreateAudienceInput { + audienceName: string + settings: { + customerId?: string + conversionTrackingId?: string + } + audienceSettings: { + external_id_type: string + app_id?: string + } +} + +export interface CreateGoogleAudienceResponse { + resourceName?: string + results: Array<{ resourceName: string }> +} + +export interface AudienceSettings { + external_id_type: string +} From 282469c78ce51835c4fca4c9da48675c1ab2f82b Mon Sep 17 00:00:00 2001 From: Maryam Sharif Date: Thu, 18 Jul 2024 16:52:37 -0700 Subject: [PATCH 15/15] Add syncMode --- .../google-enhanced-conversions/functions.ts | 9 +++++---- .../google-enhanced-conversions/userList/index.ts | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index ef44dd2ee2..07f8c06e1a 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -357,7 +357,7 @@ const formatPhone = (phone: string, hash_data?: boolean): string => { return sha256SmartHash(formattedPhone) } -const extractUserIdentifiers = (payloads: UserListPayload[], audienceSettings: AudienceSettings) => { +const extractUserIdentifiers = (payloads: UserListPayload[], audienceSettings: AudienceSettings, syncMode?: string) => { const removeUserIdentifiers = [] const addUserIdentifiers = [] // Map user data to Google Ads API format @@ -395,9 +395,9 @@ const extractUserIdentifiers = (payloads: UserListPayload[], audienceSettings: A } // Map user data to Google Ads API format for (const payload of payloads) { - if (payload.event_name == 'Audience Entered') { + if (payload.event_name == 'Audience Entered' || syncMode == 'add') { addUserIdentifiers.push(identifierFunctions[audienceSettings.external_id_type](payload)) - } else if (payload.event_name == 'Audience Exited') { + } else if (payload.event_name == 'Audience Exited' || syncMode == 'delete') { removeUserIdentifiers.push(identifierFunctions[audienceSettings.external_id_type](payload)) } } @@ -512,7 +512,8 @@ export const handleUpdate = async ( audienceSettings: CreateAudienceInput['audienceSettings'], payloads: UserListPayload[], hookOutputs: string, - statsContext: StatsContext | undefined + syncMode?: string, + statsContext?: StatsContext ) => { // Format the user data for Google Ads API const [adduserIdentifiers, removeUserIdentifiers] = extractUserIdentifiers(payloads, audienceSettings) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts index 2a2f84eb5a..2b285b4694 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts @@ -8,6 +8,15 @@ const action: ActionDefinition = { title: 'Customer Match User List', description: 'Sync a Segment Engage Audience into a Google Customer Match User List.', defaultSubscription: 'event = "Audience Entered" or event = "Audience Exited"', + syncMode: { + description: 'Define how the records will be synced from RETL to Google', + label: 'How to sync records', + default: 'add', + choices: [ + { label: 'Adds users to the connected Google Customer Match User List', value: 'add' }, + { label: 'Remove users from the connected Google Customer Match User List', value: 'delete' } + ] + }, fields: { first_name: { label: 'First Name', @@ -241,7 +250,7 @@ const action: ActionDefinition = { } } }, - perform: async (request, { settings, audienceSettings, payload, hookOutputs, statsContext }) => { + perform: async (request, { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode }) => { hookOutputs?.retlOnMappingSave?.outputs.id return await handleUpdate( request, @@ -249,16 +258,18 @@ const action: ActionDefinition = { audienceSettings, [payload], hookOutputs?.retlOnMappingSave?.outputs.id, + syncMode, statsContext ) }, - performBatch: async (request, { settings, audienceSettings, payload, hookOutputs, statsContext }) => { + performBatch: async (request, { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode }) => { return await handleUpdate( request, settings, audienceSettings, payload, hookOutputs?.retlOnMappingSave?.outputs.id, + syncMode, statsContext ) }