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/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/__tests__/userList.test.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList.test.ts new file mode 100644 index 0000000000..04e211d53c --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList.test.ts @@ -0,0 +1,75 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import GoogleEnhancedConversions from '../index' +import { API_VERSION } from '../functions' + +const testDestination = createTestIntegration(GoogleEnhancedConversions) +const timestamp = new Date('Thu Jun 10 2021 11:08:04 GMT-0700 (Pacific Daylight Time)').toISOString() +const customerId = '1234' + +describe('GoogleEnhancedConversions', () => { + describe('userList', () => { + it('sends an event with default mappings', async () => { + const event = createTestEvent({ + timestamp, + event: 'Audience Entered', + properties: { + gclid: '54321', + email: 'test@gmail.com', + orderId: '1234', + phone: '1234567890', + firstName: 'Jane', + lastName: 'Doe', + currency: 'USD', + value: '123', + address: { + street: '123 Street SW', + city: 'San Diego', + state: 'CA', + postalCode: '982004' + } + } + }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + external_audience_id: '1234', + retlOnMappingSave: { + outputs: { + id: '1234', + name: 'Test List', + external_id_type: 'CONTACT_INFO' + } + } + }, + useDefaultMappings: true, + settings: { + customerId + } + }) + + expect(responses.length).toEqual(3) + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"job\\":{\\"type\\":\\"CUSTOMER_MATCH_USER_LIST\\",\\"customerMatchUserListMetadata\\":{\\"userList\\":\\"customers/1234/userLists/1234\\",\\"consent\\":{\\"adUserData\\":\\"GRANTED\\",\\"adPersonalization\\":\\"GRANTED\\"}}}}"` + ) + expect(responses[1].options.body).toMatchInlineSnapshot( + `"{\\"operations\\":[{\\"create\\":{\\"userIdentifiers\\":[{\\"hashedEmail\\":\\"87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674\\"},{\\"hashedPhoneNumber\\":\\"422ce82c6fc1724ac878042f7d055653ab5e983d186e616826a72d4384b68af8\\"},{\\"addressInfo\\":{\\"hashedFirstName\\":\\"4f23798d92708359b734a18172c9c864f1d48044a754115a0d4b843bca3a5332\\",\\"hashedLastName\\":\\"fd53ef835b15485572a6e82cf470dcb41fd218ae5751ab7531c956a2a6bcd3c7\\",\\"countryCode\\":\\"\\",\\"postalCode\\":\\"\\"}}]}}],\\"enable_warnings\\":true}"` + ) + }) + }) +}) 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..57ba25b786 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,11 @@ import { QueryResponse, ConversionActionId, ConversionActionResponse, - CustomVariableInterface + CustomVariableInterface, + CreateAudienceInput, + CreateGoogleAudienceResponse, + UserListResponse, + UserList } from './types' import { ModifiedResponse, @@ -18,6 +22,9 @@ 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 { sha256SmartHash } from '@segment/actions-core' +import { RefreshTokenResponse } from '.' export const API_VERSION = 'v15' export const CANARY_API_VERSION = 'v15' @@ -185,3 +192,389 @@ 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` + } + + 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 + } + ) + + 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( + request: RequestClient, + input: CreateAudienceInput, + auth: CreateAudienceInput['settings']['oauth'], + 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.') + } + + if ( + !auth?.refresh_token || + !process.env.GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID || + !process.env.GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET + ) { + throw new PayloadValidationError('Oauth credentials missing.') + } + + const res = await request('https://www.googleapis.com/oauth2/v4/token', { + method: 'POST', + body: new URLSearchParams({ + refresh_token: auth.refresh_token, + client_id: process.env.GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID, + client_secret: process.env.GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET, + grant_type: 'refresh_token' + }) + }) + + const accessToken = res.data.access_token + + const statsClient = statsContext?.statsClient + const statsTags = statsContext?.tags + const json = { + operations: [ + { + create: { + crmBasedUserList: { + uploadKeyType: input.audienceSettings.external_id_type, + appId: input.audienceSettings.app_id + }, + membershipLifeSpan: '10000', // In days. 10000 is interpreted as 'unlimited'. + name: `${input.audienceName}` + } + } + ] + } + + const response = await request( + `https://googleads.googleapis.com/${API_VERSION}/customers/${input.settings.customerId}/userLists:mutate`, + { + method: 'post', + headers: { + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}`, + authorization: `Bearer ${accessToken}` + }, + json + } + ) + + // Successful response body looks like: + // {"results": [{ "resourceName": "customers//userLists/" }]} + 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) + } + + statsClient?.incr('createAudience.success', 1, statsTags) + return name.split('/')[3] +} + +export async function getGoogleAudience( + request: RequestClient, + settings: CreateAudienceInput['settings'], + externalId: string, + auth: CreateAudienceInput['settings']['oauth'], + statsContext?: StatsContext +) { + if ( + !auth?.refresh_token || + !process.env.GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID || + !process.env.GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET + ) { + throw new PayloadValidationError('Oauth credentials missing.') + } + + const res = await request('https://www.googleapis.com/oauth2/v4/token', { + method: 'POST', + body: new URLSearchParams({ + refresh_token: auth.refresh_token, + client_id: process.env.GOOGLE_ENHANCED_CONVERSIONS_CLIENT_ID, + client_secret: process.env.GOOGLE_ENHANCED_CONVERSIONS_CLIENT_SECRET, + grant_type: 'refresh_token' + }) + }) + + const accessToken = res.data.access_token + 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/${API_VERSION}/customers/${settings.customerId}/googleAds:search`, + { + method: 'post', + headers: { + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}`, + authorization: `Bearer ${accessToken}` + }, + 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 response.data as UserListResponse +} + +const formatEmail = (email: string): string => { + 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 sha256SmartHash(normalizedEmail) +} + +// Standardize phone number to E.164 format, This format represents a phone number as a number up to fifteen digits +// in length starting with a + sign, for example, +12125650000 or +442070313000. +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): string => { + const formattedPhone = formatToE164(phone, '1') + return sha256SmartHash(formattedPhone) +} + +const extractUserIdentifiers = (payloads: UserListPayload[], idType: string, syncMode?: string) => { + 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) + }) + } + if (payload.phone) { + identifiers.push({ + hashedPhoneNumber: formatPhone(payload.phone) + }) + } + if (payload.first_name || payload.last_name || payload.country_code || payload.postal_code) { + identifiers.push({ + addressInfo: { + hashedFirstName: sha256SmartHash(payload.first_name ?? ''), + hashedLastName: sha256SmartHash(payload.last_name ?? ''), + 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' || syncMode == 'add') { + addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) + } else if (payload.event_name == 'Audience Exited' || syncMode == 'delete') { + removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) + } + } + return [addUserIdentifiers, removeUserIdentifiers] +} + +const createOfflineUserJob = async ( + request: RequestClient, + payload: UserListPayload, + 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/${external_audience_id}`, + consent: { + adUserData: payload.ad_user_data_consent_state, + adPersonalization: payload.ad_personalization_consent_state + } + } + } + } + + try { + const response = await request(url, { + method: 'post', + headers: { + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` + }, + json + }) + return (response.data as any).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, + statsContext: StatsContext | undefined +) => { + const url = `https://googleads.googleapis.com/${API_VERSION}/${resourceName}:addOperations` + + const json = { + operations: userIdentifiers, + enable_warnings: true + } + + try { + const response = await request(url, { + method: 'post', + headers: { + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` + }, + json + }) + + 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, + statsContext: StatsContext | undefined +) => { + const url = `https://googleads.googleapis.com/${API_VERSION}/${resourceName}:run` + + try { + const response = await request(url, { + method: 'post', + headers: { + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` + } + }) + + 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 ( + request: RequestClient, + settings: CreateAudienceInput['settings'], + audienceSettings: CreateAudienceInput['audienceSettings'], + payloads: UserListPayload[], + hookListId: string, + hookListType: string, + syncMode?: string, + statsContext?: StatsContext +) => { + const id_type = hookListType ?? audienceSettings.external_id_type + // Format the user data for Google Ads API + const [adduserIdentifiers, removeUserIdentifiers] = extractUserIdentifiers(payloads, id_type, syncMode) + + // Create an offline user data job + const resourceName = await createOfflineUserJob(request, payloads[0], settings, hookListId, statsContext) + + // Add operations to the offline user data job + if (adduserIdentifiers.length > 0) { + await addOperations(request, adduserIdentifiers, resourceName, statsContext) + } + + if (removeUserIdentifiers.length > 0) { + await addOperations(request, removeUserIdentifiers, resourceName, statsContext) + } + + // Run the offline user data job + const executedJob = await runOfflineUserJob(request, resourceName, statsContext) + + statsContext?.statsClient?.incr('success.offlineUpdateAudience', 1, statsContext?.tags) + + return executedJob +} 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..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 @@ -10,3 +10,15 @@ 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 + /** + * 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 f1e5571d13..eeecc7e55b 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts @@ -1,11 +1,15 @@ -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 { CreateAudienceInput, GetAudienceInput, UserListResponse } from './types' +import { createGoogleAudience, getGoogleAudience } from './functions' -interface RefreshTokenResponse { +import userList from './userList' + +export interface RefreshTokenResponse { access_token: string scope: string expires_in: number @@ -18,7 +22,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', @@ -71,11 +75,74 @@ 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' } + ] + }, + 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: { + match: 'all', + conditions: [ + { + fieldKey: 'external_id_type', + operator: 'is', + 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: CreateAudienceInput) { + const auth = createAudienceInput.settings.oauth + const userListId = await createGoogleAudience( + request, + createAudienceInput, + auth, + createAudienceInput.statsContext + ) + + return { + externalId: userListId + } + }, + + async getAudience(request, getAudienceInput: GetAudienceInput) { + const response: UserListResponse = await getGoogleAudience( + request, + getAudienceInput.settings, + getAudienceInput.externalId, + getAudienceInput.settings.oauth, + getAudienceInput.statsContext + ) + + return { + externalId: response.results[0].userList.id + } + } + }, actions: { postConversion, uploadClickConversion, uploadCallConversion, - uploadConversionAdjustment + uploadConversionAdjustment, + 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..97e8fa74a4 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/types.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/types.ts @@ -1,3 +1,5 @@ +import { StatsContext } from '@segment/actions-core/destination-kit' + export interface CartItemInterface { productId?: string quantity?: number @@ -119,3 +121,57 @@ 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 + oauth?: { + refresh_token?: string + } + } + audienceSettings: { + external_id_type: string + app_id?: string + } + statsContext?: StatsContext +} + +export interface GetAudienceInput { + externalId: string + settings: { + customerId?: string + conversionTrackingId?: string + oauth?: { + refresh_token?: string + } + } + audienceSettings: { + external_id_type: string + app_id?: string + } + statsContext?: StatsContext +} + +export interface CreateGoogleAudienceResponse { + resourceName?: string + results: Array<{ resourceName: string }> +} + +export interface AudienceSettings { + external_id_type: string +} 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..702124a702 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts @@ -0,0 +1,98 @@ +// 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 + /** + * Advertiser-assigned user ID for Customer Match upload. + */ + crm_id?: string + /** + * Mobile device ID (advertising ID/IDFA). + */ + mobile_advertising_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 number of records to send in each batch. + */ + batch_size?: number + /** + * The name of the current Segment event. + */ + 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 Google Customer Match User list that users will be synced to. + */ + id?: string + /** + * The name of the Google Customer Match User list that users will be synced to. + */ + name?: string + /** + * Customer match upload key types. + */ + external_id_type?: 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..1ba7b00e8a --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts @@ -0,0 +1,306 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { createGoogleAudience, getGoogleAudience, getListIds, handleUpdate } from '../functions' +import { IntegrationError } from '@segment/actions-core' +import { UserListResponse } from '../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"', + 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', + 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' + }, + 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' + } + }, + 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 + }, + batch_size: { + label: 'Batch Size', + description: 'The number of records to send in each batch.', + type: 'integer', + default: 10000, + 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 + } + }, + 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, + dynamic: async (request, { settings, auth }) => { + return await getListIds(request, settings, auth) + } + }, + 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, + default: 'CONTACT_INFO', + 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: { + match: 'all', + conditions: [ + { + fieldKey: 'external_id_type', + operator: 'is', + value: 'MOBILE_ADVERTISING_ID' + } + ] + } + } + }, + outputTypes: { + id: { + type: 'string', + label: 'ID', + description: 'The ID of the Google Customer Match User list that users will be synced to.', + required: false + }, + name: { + type: 'string', + label: 'List Name', + description: 'The name of the Google Customer Match User list that users will be synced to.', + required: false + }, + external_id_type: { + type: 'string', + label: 'External ID Type', + description: 'Customer match upload key types.', + required: false + } + }, + performHook: async (request, { auth, settings, hookInputs, statsContext }) => { + if (hookInputs.list_id) { + try { + const response: UserListResponse = await getGoogleAudience(request, settings, hookInputs.list_id, { + refresh_token: auth?.refreshToken + }) + return { + successMessage: `Using existing list '${response.results[0].userList.id}' (id: ${hookInputs.list_id})`, + savedData: { + id: hookInputs.list_id, + name: response.results[0].userList.name, + external_id_type: hookInputs.external_id_type + } + } + } 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, { refresh_token: auth?.refreshToken }, statsContext) + + return { + successMessage: `List '${hookInputs.list_name}' (id: ${listId}) created successfully!`, + savedData: { + id: listId, + name: hookInputs.list_name, + external_id_type: hookInputs.external_id_type + } + } + } 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, hookOutputs, statsContext, syncMode }) => { + hookOutputs?.retlOnMappingSave?.outputs.id + return await handleUpdate( + request, + settings, + audienceSettings, + [payload], + hookOutputs?.retlOnMappingSave?.outputs.id, + hookOutputs?.retlOnMappingSave?.outputs.external_id_type, + syncMode, + statsContext + ) + }, + performBatch: async (request, { settings, audienceSettings, payload, hookOutputs, statsContext, syncMode }) => { + return await handleUpdate( + request, + settings, + audienceSettings, + payload, + hookOutputs?.retlOnMappingSave?.outputs.id, + hookOutputs?.retlOnMappingSave?.outputs.external_id_type, + syncMode, + statsContext + ) + } +} + +export default action