From c41e0aa54d728940c926e42efb76dd291214cd3c Mon Sep 17 00:00:00 2001 From: Arjun Bhandage Date: Wed, 4 Jun 2025 17:12:57 +0530 Subject: [PATCH 1/5] initial commit --- .../__tests__/snapshot.test.ts | 24 +- .../google-data-manager/constants.ts | 8 + .../google-data-manager/errors.ts | 66 +++++ .../google-data-manager/generated-types.ts | 12 + .../destinations/google-data-manager/index.ts | 185 +++++++++++--- .../ingest/__tests__/snapshot.test.ts | 38 +-- .../google-data-manager/shared.ts | 230 ++++++++++++++++++ .../destinations/google-data-manager/types.ts | 23 ++ 8 files changed, 510 insertions(+), 76 deletions(-) create mode 100644 packages/destination-actions/src/destinations/google-data-manager/constants.ts create mode 100644 packages/destination-actions/src/destinations/google-data-manager/errors.ts create mode 100644 packages/destination-actions/src/destinations/google-data-manager/shared.ts create mode 100644 packages/destination-actions/src/destinations/google-data-manager/types.ts diff --git a/packages/destination-actions/src/destinations/google-data-manager/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/google-data-manager/__tests__/snapshot.test.ts index a3d6a8fc2f2..43ea9e724eb 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/__tests__/snapshot.test.ts @@ -13,9 +13,9 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { const action = destination.actions[actionSlug] const [eventData, settingsData] = generateTestData(seedName, destination, action, true) - nock(/.*!/).persist().get(/.*!/).reply(200) - nock(/.*!/).persist().post(/.*!/).reply(200) - nock(/.*!/).persist().put(/.*!/).reply(200) + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) const event = createTestEvent({ properties: eventData @@ -28,12 +28,6 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { auth: undefined }) - // Defensive: handle case where no request is made - if (!responses[0] || !responses[0].request) { - expect(responses).toMatchSnapshot() - return - } - const request = responses[0].request const rawBody = await request.text() @@ -53,9 +47,9 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { const action = destination.actions[actionSlug] const [eventData, settingsData] = generateTestData(seedName, destination, action, false) - nock(/.*!/).persist().get(/.*!/).reply(200) - nock(/.*!/).persist().post(/.*!/).reply(200) - nock(/.*!/).persist().put(/.*!/).reply(200) + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) const event = createTestEvent({ properties: eventData @@ -68,12 +62,6 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { auth: undefined }) - // Defensive: handle case where no request is made - if (!responses[0] || !responses[0].request) { - expect(responses).toMatchSnapshot() - return - } - const request = responses[0].request const rawBody = await request.text() diff --git a/packages/destination-actions/src/destinations/google-data-manager/constants.ts b/packages/destination-actions/src/destinations/google-data-manager/constants.ts new file mode 100644 index 00000000000..bb1194eb18e --- /dev/null +++ b/packages/destination-actions/src/destinations/google-data-manager/constants.ts @@ -0,0 +1,8 @@ +export const GOOGLE_API_VERSION = 'v2' +// accountType and advertiserID are used as markers to be replaced in the code. DO NOT REMOVE THEM. +export const BASE_URL = `https://audiencepartner.googleapis.com/${GOOGLE_API_VERSION}/products/accountType/customers/advertiserID/` +export const CREATE_AUDIENCE_URL = `${BASE_URL}userLists:mutate` +export const GET_AUDIENCE_URL = `${BASE_URL}audiencePartner:searchStream` +export const OAUTH_URL = 'https://accounts.google.com/o/oauth2/token' +export const USER_UPLOAD_ENDPOINT = 'https://cm.g.doubleclick.net/upload?nid=segment' +export const SEGMENT_DMP_ID = '1663649500' diff --git a/packages/destination-actions/src/destinations/google-data-manager/errors.ts b/packages/destination-actions/src/destinations/google-data-manager/errors.ts new file mode 100644 index 00000000000..bf14076faf2 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-data-manager/errors.ts @@ -0,0 +1,66 @@ +import { ErrorCodes, IntegrationError, InvalidAuthenticationError } from '@segment/actions-core' +import { StatsContext } from '@segment/actions-core/destination-kit' +import { GoogleAPIError } from './types' + +const isGoogleAPIError = (error: unknown): error is GoogleAPIError => { + if (typeof error === 'object' && error !== null) { + const e = error as GoogleAPIError + // Not using any forces us to check for all the properties we need. + return ( + typeof e.response === 'object' && + e.response !== null && + typeof e.response.data === 'object' && + e.response.data !== null && + typeof e.response.data.error === 'object' && + e.response.data.error !== null + ) + } + return false +} + +// This method follows the retry logic defined in IntegrationError in the actions-core package +export const handleRequestError = (error: unknown, statsName: string, statsContext: StatsContext | undefined) => { + const { statsClient, tags: statsTags } = statsContext || {} + + if (!isGoogleAPIError(error)) { + if (!error) { + statsTags?.push('error:unknown') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + return new IntegrationError('Unknown error', 'UNKNOWN_ERROR', 500) + } + } + + const gError = error as GoogleAPIError + const code = gError.response?.status + + // @ts-ignore - Errors can be objects or arrays of objects. This will work for both. + const message = gError.response?.data?.error?.message || gError.response?.data?.[0]?.error?.message + + if (code === 401) { + statsTags?.push('error:invalid-authentication') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + return new InvalidAuthenticationError(message, ErrorCodes.INVALID_AUTHENTICATION) + } + + if (code === 403) { + statsTags?.push('error:forbidden') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + return new IntegrationError(message, 'FORBIDDEN', 403) + } + + if (code === 501) { + statsTags?.push('error:integration-error') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + return new IntegrationError(message, 'INTEGRATION_ERROR', 501) + } + + if (code === 408 || code === 423 || code === 429 || code >= 500) { + statsTags?.push('error:retryable-error') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + return new IntegrationError(message, 'RETRYABLE_ERROR', code) + } + + statsTags?.push('error:generic-error') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + return new IntegrationError(message, 'INTEGRATION_ERROR', code) +} diff --git a/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts b/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts index 4ab2786ec60..b36920d3397 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts @@ -1,3 +1,15 @@ // Generated file. DO NOT MODIFY IT BY HAND. export interface Settings {} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface AudienceSettings { + /** + * The ID of your advertiser, used throughout Display & Video 360. Use this ID when you contact Display & Video 360 support to help our teams locate your specific account. + */ + advertiserId: string + /** + * The type of the advertiser account you have linked to this Display & Video 360 destination. + */ + accountType: string +} diff --git a/packages/destination-actions/src/destinations/google-data-manager/index.ts b/packages/destination-actions/src/destinations/google-data-manager/index.ts index bfefd6f670d..bdc76977133 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/index.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/index.ts @@ -1,9 +1,10 @@ -import type { AudienceDestinationDefinition } from '@segment/actions-core' -import type { Settings } from './generated-types' +import { AudienceDestinationDefinition, IntegrationError } from '@segment/actions-core' +import type { AudienceSettings, Settings } from './generated-types' import ingest from './ingest' - -import remove from './remove' +import { buildHeaders, getAuthSettings, getAuthToken } from '../display-video-360/shared' +import { CREATE_AUDIENCE_URL, GET_AUDIENCE_URL } from '../display-video-360/constants' +import { handleRequestError } from '../display-video-360/errors' export interface RefreshTokenResponse { access_token: string @@ -12,8 +13,7 @@ export interface RefreshTokenResponse { token_type: string } -// For an example audience destination, refer to webhook-audiences. The Readme section is under 'Audience Support' -const destination: AudienceDestinationDefinition = { +const destination: AudienceDestinationDefinition = { name: 'Google Data Manager', slug: 'actions-google-data-manager', mode: 'cloud', @@ -28,7 +28,7 @@ const destination: AudienceDestinationDefinition = { }, refreshAccessToken: async (request, { auth }) => { // Return a request that refreshes the access_token if the API supports it - const res = await request('https://www.example.com/oauth/refresh', { + const res = await request('https://www.googleapis.com/oauth2/v4/token', { method: 'POST', body: new URLSearchParams({ refresh_token: auth.refreshToken, @@ -49,36 +49,165 @@ const destination: AudienceDestinationDefinition = { } }, - audienceFields: {}, + onDelete: async (_request) => { + // Return a request that performs a GDPR delete for the provided Segment userId or anonymousId + // provided in the payload. If your destination does not support GDPR deletion you should not + // implement this function and should remove it completely. + }, + actions: { + ingest + }, + presets: [], 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. + type: 'synced', + full_audience_sync: false }, + async createAudience(request, createAudienceInput) { + const { audienceName, audienceSettings, statsContext } = createAudienceInput + const { accountType } = audienceSettings || {} + const advertiserId = audienceSettings?.advertiserId.trim() + const { statsClient, tags: statsTags } = statsContext || {} + const statsName = 'createAudience' + statsTags?.push(`slug:${destination.slug}`) + statsClient?.incr(`${statsName}.call`, 1, statsTags) + + const authSettings = getAuthSettings() + + if (!audienceName) { + statsTags?.push('error:missing-settings') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + throw new IntegrationError('Missing audience name value', 'MISSING_REQUIRED_FIELD', 400) + } - // Get/Create are optional and only needed if you need to create an audience before sending events/users. - createAudience: async (_request, _createAudienceInput) => { - // Create an audience through the destination's API - // Segment will save this externalId for subsequent calls; the externalId is used to keep track of the audience in our database - return { externalId: '' } + if (!advertiserId) { + statsTags?.push('error:missing-settings') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + throw new IntegrationError('Missing advertiser ID value', 'MISSING_REQUIRED_FIELD', 400) + } + + if (!accountType) { + statsTags?.push('error:missing-settings') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + throw new IntegrationError('Missing account type value', 'MISSING_REQUIRED_FIELD', 400) + } + + const listTypeMap = { basicUserList: {}, type: 'REMARKETING', membershipStatus: 'OPEN' } + const partnerCreateAudienceUrl = CREATE_AUDIENCE_URL.replace('advertiserID', advertiserId).replace( + 'accountType', + accountType + ) + + let response + try { + const authToken = await getAuthToken(request, authSettings) + response = await request(partnerCreateAudienceUrl, { + headers: buildHeaders(createAudienceInput.audienceSettings, authToken), + method: 'POST', + json: { + operations: [ + { + create: { + ...listTypeMap, + name: audienceName, + description: 'Created by Segment', + membershipLifeSpan: '540' + } + } + ] + } + }) + + const r = await response?.json() + statsClient?.incr(`${statsName}.success`, 1, statsTags) + + return { + externalId: r['results'][0]['resourceName'] + } + } catch (error) { + throw handleRequestError(error, statsName, statsContext) + } }, + async getAudience(request, getAudienceInput) { + const { statsContext, audienceSettings } = getAudienceInput + const { statsClient, tags: statsTags } = statsContext || {} + const { accountType } = audienceSettings || {} + const advertiserId = audienceSettings?.advertiserId.trim() + const statsName = 'getAudience' + statsTags?.push(`slug:${destination.slug}`) + statsClient?.incr(`${statsName}.call`, 1, statsTags) - getAudience: async (_request, _getAudienceInput) => { - // Right now, `getAudience` will mostly serve as a check to ensure the audience still exists in the destination - return { externalId: '' } - } - }, + const authSettings = getAuthSettings() - onDelete: async (_request) => { - // Return a request that performs a GDPR delete for the provided Segment userId or anonymousId - // provided in the payload. If your destination does not support GDPR deletion you should not - // implement this function and should remove it completely. - }, + if (!advertiserId) { + statsTags?.push('error:missing-settings') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + throw new IntegrationError('Missing required advertiser ID value', 'MISSING_REQUIRED_FIELD', 400) + } - actions: { - ingest, - remove + if (!accountType) { + statsTags?.push('error:missing-settings') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + throw new IntegrationError('Missing account type value', 'MISSING_REQUIRED_FIELD', 400) + } + + const advertiserGetAudienceUrl = GET_AUDIENCE_URL.replace('advertiserID', advertiserId).replace( + 'accountType', + accountType + ) + + try { + const authToken = await getAuthToken(request, authSettings) + const response = await request(advertiserGetAudienceUrl, { + headers: buildHeaders(audienceSettings, authToken), + method: 'POST', + json: { + query: `SELECT user_list.name, user_list.description, user_list.membership_status, user_list.match_rate_percentage FROM user_list WHERE user_list.resource_name = "${getAudienceInput.externalId}"` + } + }) + + const r = await response.json() + + const externalId = r[0]?.results[0]?.userList?.resourceName + + if (externalId !== getAudienceInput.externalId) { + statsClient?.incr(`${statsName}.error`, 1, statsTags) + throw new IntegrationError( + "Unable to verify ownership over audience. Segment Audience ID doesn't match Googles Audience ID.", + 'INVALID_REQUEST_DATA', + 400 + ) + } + + statsClient?.incr(`${statsName}.success`, 1, statsTags) + return { + externalId: externalId + } + } catch (error) { + throw handleRequestError(error, statsName, statsContext) + } + } + }, + audienceFields: { + advertiserId: { + type: 'string', + label: 'Advertiser ID', + required: true, + description: + 'The ID of your advertiser, used throughout Display & Video 360. Use this ID when you contact Display & Video 360 support to help our teams locate your specific account.' + }, + accountType: { + type: 'string', + label: 'Account Type', + description: 'The type of the advertiser account you have linked to this Display & Video 360 destination.', + required: true, + choices: [ + { label: 'Advertiser', value: 'DISPLAY_VIDEO_ADVERTISER' }, + { label: 'Partner', value: 'DISPLAY_VIDEO_PARTNER' }, + { label: 'Publisher', value: 'GOOGLE_AD_MANAGER' } + ] + } } } diff --git a/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/snapshot.test.ts index 3f9eb8bcfeb..4c51415c023 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/snapshot.test.ts @@ -13,9 +13,9 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const action = destination.actions[actionSlug] const [eventData, settingsData] = generateTestData(seedName, destination, action, true) - nock(/.*!/).persist().get(/.*!/).reply(200) - nock(/.*!/).persist().post(/.*!/).reply(200) - nock(/.*!/).persist().put(/.*!/).reply(200) + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) const event = createTestEvent({ properties: eventData @@ -28,19 +28,8 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac auth: undefined }) - // Defensive: handle case where no request is made - if (!responses[0] || !responses[0].request) { - expect(responses).toMatchSnapshot() - return - } - const request = responses[0].request - let rawBody = '' - try { - rawBody = await request.text() - } catch (e) { - rawBody = '' - } + const rawBody = await request.text() try { const json = JSON.parse(rawBody) @@ -57,9 +46,9 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const action = destination.actions[actionSlug] const [eventData, settingsData] = generateTestData(seedName, destination, action, false) - nock(/.*!/).persist().get(/.*!/).reply(200) - nock(/.*!/).persist().post(/.*!/).reply(200) - nock(/.*!/).persist().put(/.*!/).reply(200) + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) const event = createTestEvent({ properties: eventData @@ -72,19 +61,8 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac auth: undefined }) - // Defensive: handle case where no request is made - if (!responses[0] || !responses[0].request) { - expect(responses).toMatchSnapshot() - return - } - const request = responses[0].request - let rawBody = '' - try { - rawBody = await request.text() - } catch (e) { - rawBody = '' - } + const rawBody = await request.text() try { const json = JSON.parse(rawBody) diff --git a/packages/destination-actions/src/destinations/google-data-manager/shared.ts b/packages/destination-actions/src/destinations/google-data-manager/shared.ts new file mode 100644 index 00000000000..87a46ecce61 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-data-manager/shared.ts @@ -0,0 +1,230 @@ +import { IntegrationError, RequestClient, StatsContext, HTTPError } from '@segment/actions-core' +import { OAUTH_URL, USER_UPLOAD_ENDPOINT, SEGMENT_DMP_ID } from './constants' +import type { RefreshTokenResponse } from './types' +import { create, fromBinary, toBinary } from '@bufbuild/protobuf' + +import { + UserIdType, + UpdateUsersDataRequest, + UpdateUsersDataRequestSchema, + UserDataOperationSchema, + UpdateUsersDataResponse, + ErrorCode, + UpdateUsersDataResponseSchema +} from './proto/protofile' + +import { ListOperation, UpdateHandlerPayload, UserOperation } from './types' +import type { AudienceSettings } from './generated-types' + +type DV360AuthCredentials = { refresh_token: string; access_token: string; client_id: string; client_secret: string } + +export const getAuthSettings = (): DV360AuthCredentials => { + return { + refresh_token: process.env.ACTIONS_DISPLAY_VIDEO_360_REFRESH_TOKEN, + client_id: process.env.ACTIONS_DISPLAY_VIDEO_360_CLIENT_ID, + client_secret: process.env.ACTIONS_DISPLAY_VIDEO_360_CLIENT_SECRET + } as DV360AuthCredentials +} + +// Use the refresh token to get a new access token. +// Refresh tokens, Client_id and secret are long-lived and belong to the DMP. +// Given the short expiration time of access tokens, we need to refresh them periodically. +export const getAuthToken = async (request: RequestClient, settings: DV360AuthCredentials) => { + if (!settings.refresh_token) { + throw new IntegrationError('Refresh token is missing', 'INVALID_REQUEST_DATA', 400) + } + + const { data } = await request(OAUTH_URL, { + method: 'POST', + body: new URLSearchParams({ + refresh_token: settings.refresh_token, + client_id: settings.client_id, + client_secret: settings.client_secret, + grant_type: 'refresh_token' + }) + }) + + return data.access_token +} + +export const buildHeaders = (audienceSettings: AudienceSettings | undefined, accessToken: string) => { + if (!audienceSettings || !accessToken) { + throw new IntegrationError('Bad Request', 'INVALID_REQUEST_DATA', 400) + } + + return { + // @ts-ignore - TS doesn't know about the oauth property + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Login-Customer-Id': `products/DATA_PARTNER/customers/${SEGMENT_DMP_ID}`, + 'Linked-Customer-Id': `products/${audienceSettings.accountType}/customers/${audienceSettings?.advertiserId}` + } +} + +export const assembleRawOps = (payload: UpdateHandlerPayload, operation: ListOperation): UserOperation[] => { + const rawOperations = [] + const audienceId = parseInt(payload.external_audience_id.split('/').pop() || '-1') + const isDelete = operation === 'remove' + + if (payload.google_gid) { + rawOperations.push({ + UserId: payload.google_gid, + UserIdType: UserIdType.GOOGLE_USER_ID, + UserListId: audienceId, + Delete: isDelete + }) + } + + if (payload.mobile_advertising_id) { + const isIDFA = payload.mobile_advertising_id.includes('-') + + rawOperations.push({ + UserId: payload.mobile_advertising_id, + UserIdType: isIDFA ? UserIdType.IDFA : UserIdType.ANDROID_ADVERTISING_ID, + UserListId: audienceId, + Delete: isDelete + }) + } + + if (payload.partner_provided_id) { + rawOperations.push({ + UserId: payload.partner_provided_id, + UserIdType: UserIdType.PARTNER_PROVIDED_ID, + UserListId: audienceId, + Delete: isDelete + }) + } + + return rawOperations +} + +const handleErrorCode = ( + errorCodeString: string, + r: UpdateUsersDataResponse, + statsName: string, + statsContext: StatsContext | undefined +) => { + if (errorCodeString === 'PARTIAL_SUCCESS') { + statsContext?.statsClient.incr(`${statsName}.error.PARTIAL_SUCCESS`, 1, statsContext?.tags) + r.errors?.forEach((e) => { + if (e.errorCode) { + statsContext?.statsClient.incr(`${statsName}.error.${ErrorCode[e.errorCode]}`, 1, statsContext?.tags) + } + }) + } else { + statsContext?.statsClient.incr(`${statsName}.error.${errorCodeString}`, 1, statsContext?.tags) + } +} + +export const bulkUploaderResponseHandler = async ( + response: Response, + statsName: string, + statsContext: StatsContext | undefined +) => { + if (!response || !response.body) { + throw new IntegrationError(`Something went wrong unpacking the protobuf response`, 'INVALID_REQUEST_DATA', 400) + } + + const buffer = await response.arrayBuffer() + const protobufResponse = Buffer.from(buffer) + + const r = fromBinary(UpdateUsersDataResponseSchema, protobufResponse) + const errorCode = r.status + const errorCodeString = ErrorCode[errorCode] || 'UNKNOWN_ERROR' + + if (errorCodeString === 'NO_ERROR' || response.status === 200) { + statsContext?.statsClient.incr(`${statsName}.success`, 1, statsContext?.tags) + } else { + handleErrorCode(errorCodeString, r, statsName, statsContext) + // Only internal errors shall be retried as they imply a temporary issue. + // The rest of the errors are permanent and shall be discarded. + // This emulates the legacy behavior of the DV360 destination. + if (errorCode === ErrorCode.INTERNAL_ERROR) { + statsContext?.statsClient.incr(`${statsName}.error.INTERNAL_ERROR`, 1, statsContext?.tags) + throw new IntegrationError('Bulk Uploader Internal Error', 'INTERNAL_SERVER_ERROR', 500) + } + } +} + +// To interact with the bulk uploader, we need to create a protobuf object as defined in the proto file. +// This method takes the raw payload and creates the protobuf object. +export const createUpdateRequest = ( + payload: UpdateHandlerPayload[], + operation: 'add' | 'remove' +): UpdateUsersDataRequest => { + const updateRequest = create(UpdateUsersDataRequestSchema, {}) + + payload.forEach((p) => { + const rawOps = assembleRawOps(p, operation) + + // Every ID will generate an operation. + // That means that if google_gid, mobile_advertising_id, and anonymous_id are all present, we will create 3 operations. + // This emulates the legacy behavior of the DV360 destination. + rawOps.forEach((rawOp) => { + const op = create(UserDataOperationSchema, { + userId: rawOp.UserId, + userIdType: rawOp.UserIdType, + userListId: BigInt(rawOp.UserListId), + delete: rawOp.Delete + }) + + if (!op) { + throw new Error('Unable to create UserDataOperation') + } + + updateRequest.ops.push(op) + }) + }) + + // Backed by deletion and suppression features in Segment. + updateRequest.processConsent = true + + return updateRequest +} + +export const sendUpdateRequest = async ( + request: RequestClient, + updateRequest: UpdateUsersDataRequest, + statsName: string, + statsContext: StatsContext | undefined +) => { + const binaryOperation = toBinary(UpdateUsersDataRequestSchema, updateRequest) + + try { + const response = await request(USER_UPLOAD_ENDPOINT, { + headers: { 'Content-Type': 'application/octet-stream' }, + body: binaryOperation, + method: 'POST' + }) + + await bulkUploaderResponseHandler(response, statsName, statsContext) + } catch (error) { + if ((error as HTTPError).response?.status === 500) { + throw new IntegrationError(error.response?.message ?? (error as HTTPError).message, 'INTERNAL_SERVER_ERROR', 500) + } + + await bulkUploaderResponseHandler((error as HTTPError).response, statsName, statsContext) + } +} + +export const handleUpdate = async ( + request: RequestClient, + payload: UpdateHandlerPayload[], + operation: 'add' | 'remove', + statsContext: StatsContext | undefined +) => { + const statsName = operation === 'add' ? 'addToAudience' : 'removeFromAudience' + statsContext?.statsClient?.incr(`${statsName}.call`, 1, statsContext?.tags) + + const updateRequest = createUpdateRequest(payload, operation) + + if (updateRequest.ops.length !== 0) { + await sendUpdateRequest(request, updateRequest, statsName, statsContext) + } else { + statsContext?.statsClient.incr(`${statsName}.discard`, 1, statsContext?.tags) + } + + return { + status: 200 + } +} diff --git a/packages/destination-actions/src/destinations/google-data-manager/types.ts b/packages/destination-actions/src/destinations/google-data-manager/types.ts new file mode 100644 index 00000000000..309bc4b055a --- /dev/null +++ b/packages/destination-actions/src/destinations/google-data-manager/types.ts @@ -0,0 +1,23 @@ +export interface RefreshTokenResponse { + access_token: string +} + +export interface GoogleAPIError { + response: { + status: number + data: { + error: { + message: string + } + } + } +} + +export type UserOperation = { + UserId: string + UserIdType: number + UserListId: number + Delete: boolean +} + +export type ListOperation = 'add' | 'remove' From e61911b3dbdedab18dcde9137af9723c169e1c81 Mon Sep 17 00:00:00 2001 From: Arjun Bhandage Date: Tue, 10 Jun 2025 15:57:12 +0530 Subject: [PATCH 2/5] add fields --- .../google-data-manager/generated-types.ts | 15 +- .../destinations/google-data-manager/index.ts | 76 ++++++++-- .../ingest/generated-types.ts | 39 ++++- .../google-data-manager/ingest/index.ts | 141 ++++++++++++++++-- .../google-data-manager/shared.ts | 52 +------ .../destinations/google-data-manager/types.ts | 3 + 6 files changed, 260 insertions(+), 66 deletions(-) diff --git a/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts b/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts index b36920d3397..7c7d5e71fee 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts @@ -1,6 +1,19 @@ // Generated file. DO NOT MODIFY IT BY HAND. -export interface Settings {} +export interface Settings { + /** + * The ID of the operating account, used throughout Google Data Manager. Use this ID when you contact Google support to help our teams locate your specific account. + */ + operatingAccountId: string + /** + * The product for which you want to create or manage audiences. + */ + product: string + /** + * The ID of the product destination, used to identify the specific destination for audience management. + */ + productDestinationId: string +} // Generated file. DO NOT MODIFY IT BY HAND. export interface AudienceSettings { diff --git a/packages/destination-actions/src/destinations/google-data-manager/index.ts b/packages/destination-actions/src/destinations/google-data-manager/index.ts index bdc76977133..2896b6e04bc 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/index.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/index.ts @@ -2,9 +2,9 @@ import { AudienceDestinationDefinition, IntegrationError } from '@segment/action import type { AudienceSettings, Settings } from './generated-types' import ingest from './ingest' -import { buildHeaders, getAuthSettings, getAuthToken } from '../display-video-360/shared' -import { CREATE_AUDIENCE_URL, GET_AUDIENCE_URL } from '../display-video-360/constants' -import { handleRequestError } from '../display-video-360/errors' +import { buildHeaders, getAuthSettings, getAuthToken } from './shared' +import { CREATE_AUDIENCE_URL, GET_AUDIENCE_URL } from './constants' +import { handleRequestError } from './errors' export interface RefreshTokenResponse { access_token: string @@ -20,11 +20,70 @@ const destination: AudienceDestinationDefinition = { authentication: { scheme: 'oauth2', - fields: {}, - testAuthentication: (_request) => { - // Return a request that tests/validates the user's credentials. - // If you do not have a way to validate the authentication fields safely, - // you can remove the `testAuthentication` function, though discouraged. + fields: { + /*destinations: { + label: 'Destinations', + description: 'List of destinations to which the audience will be synced. Each destination must have a unique combination of operatingAccountId, product, and productDestinationId.', + type: 'object' as FieldType, + multiple: true, + // defaultObjectUI: 'arrayeditor', + // additionalProperties: true, + // required: CREATE_OPERATION, + // depends_on: CREATE_OPERATION, + properties: { + operatingAccountId: { + label: 'Operating Account ID', + description: + 'The ID of the operating account, used throughout Google Data Manager. Use this ID when you contact Google support to help our teams locate your specific account.', + type: 'string', + required: true + }, + product: { + label: 'Product', + description: 'The product for which you want to create or manage audiences.', + type: 'string', + multiple: true, + required: true, + choices: [ + { label: 'Google Ads', value: 'GOOGLE_ADS' }, + { label: 'Display & Video 360', value: 'DISPLAY_VIDEO_360' }, + ] + }, + productDestinationId: { + label: 'Product Destination ID', + description: + 'The ID of the product destination, used to identify the specific destination for audience management.', + type: 'string', + required: true + } + } + },*/ + }, + testAuthentication: async (request, { auth }) => { + // Call the Google Audience Partner API to test authentication + const accessToken = auth?.accessToken + if (!accessToken) { + throw new Error('Missing access token for authentication test.') + } + const response = await request( + 'https://audiencepartner.googleapis.com/v2/products/DATA_PARTNER/customers/8283492941/audiencePartner:searchStream', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + 'login-customer-id': 'products/DATA_PARTNER/customers/8283492941' + }, + body: JSON.stringify({ + query: 'SELECT product_link.display_video_advertiser.display_video_advertiser FROM product_link ' + }) + } + ) + // If the API returns a 2xx response, authentication is valid + if (response.status < 200 || response.status >= 300) { + throw new Error('Authentication failed: ' + response.statusText) + } + return response }, refreshAccessToken: async (request, { auth }) => { // Return a request that refreshes the access_token if the API supports it @@ -58,7 +117,6 @@ const destination: AudienceDestinationDefinition = { actions: { ingest }, - presets: [], audienceConfig: { mode: { type: 'synced', diff --git a/packages/destination-actions/src/destinations/google-data-manager/ingest/generated-types.ts b/packages/destination-actions/src/destinations/google-data-manager/ingest/generated-types.ts index 944d22b0857..dce97e54558 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/ingest/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/ingest/generated-types.ts @@ -1,3 +1,40 @@ // Generated file. DO NOT MODIFY IT BY HAND. -export interface Payload {} +export interface Payload { + /** + * The email address of the audience member. + */ + emailAddress: string + /** + * The phone number of the audience member. + */ + phoneNumber?: string + /** + * The given name (first name) of the audience member. + */ + givenName?: string + /** + * The family name (last name) of the audience member. + */ + familyName?: string + /** + * The region code (e.g., country code) of the audience member. + */ + regionCode?: string + /** + * The postal code of the audience member. + */ + postalCode?: string + /** + * A number value representing the Amazon audience identifier. This is the identifier that is returned during audience creation. + */ + audienceId: string + /** + * When enabled,segment will send data in batching + */ + enable_batching: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size?: number +} diff --git a/packages/destination-actions/src/destinations/google-data-manager/ingest/index.ts b/packages/destination-actions/src/destinations/google-data-manager/ingest/index.ts index ed2d1e00cc8..7fa8ae5ec02 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/ingest/index.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/ingest/index.ts @@ -1,19 +1,138 @@ -import type { ActionDefinition } from '@segment/actions-core' +import { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' const action: ActionDefinition = { title: 'Ingest', - description: 'Ingest data into Google Data Manager.', - fields: {}, - perform: async (_request, _data) => { - // Make your partner api request here! - // return request('https://example.com', { - // method: 'post', - // json: data.payload - // }) - return + description: 'Uploads a list of AudienceMember resources to the provided Destination.', + defaultSubscription: 'event = "Audience Entered" or event = "Audience Exited"', + fields: { + destinations: { + label: 'Destinations', + description: + 'List of destinations to which the audience will be synced. Each destination must have a unique combination of operatingAccountId, product, and productDestinationId.', + type: 'object' as FieldType, + multiple: true, + // defaultObjectUI: 'arrayeditor', + // additionalProperties: true, + // required: CREATE_OPERATION, + // depends_on: CREATE_OPERATION, + properties: { + operatingAccountId: { + label: 'Operating Account ID', + description: + 'The ID of the operating account, used throughout Google Data Manager. Use this ID when you contact Google support to help our teams locate your specific account.', + type: 'string', + required: true + }, + product: { + label: 'Product', + description: 'The product for which you want to create or manage audiences.', + type: 'string', + multiple: true, + required: true, + choices: [ + { label: 'Google Ads', value: 'GOOGLE_ADS' }, + { label: 'Display & Video 360', value: 'DISPLAY_VIDEO_360' } + ] + }, + productDestinationId: { + label: 'Product Destination ID', + description: + 'The ID of the product destination, used to identify the specific destination for audience management.', + type: 'string', + required: true + }, + emailAddress: { + label: 'Email Address', + description: 'The email address of the audience member.', + type: 'string', + required: true, + default: { '@path': '$.traits.email' } + }, + phoneNumber: { + label: 'Phone Number', + description: 'The phone number of the audience member.', + type: 'string', + required: false, + default: { '@path': '$.traits.phone' } + }, + givenName: { + label: 'Given Name', + description: 'The given name (first name) of the audience member.', + type: 'string', + required: false, + default: { '@path': '$.traits.firstName' } + }, + familyName: { + label: 'Family Name', + description: 'The family name (last name) of the audience member.', + type: 'string', + required: false, + default: { '@path': '$.traits.lastName' } + }, + regionCode: { + label: 'Region Code', + description: 'The region code (e.g., country code) of the audience member.', + type: 'string', + required: false, + default: { '@path': '$.traits.countryCode' } + }, + postalCode: { + label: 'Postal Code', + description: 'The postal code of the audience member.', + type: 'string', + required: false, + default: { '@path': '$.traits.postalCode' } + }, + audienceId: { + label: 'Audience ID', + type: 'string', + required: true, + unsafe_hidden: true, + description: + 'A number value representing the Amazon audience identifier. This is the identifier that is returned during audience creation.', + default: { + '@path': '$.context.personas.external_audience_id' + } + }, + enable_batching: { + label: 'Enable Batching', + description: 'When enabled,segment will send data in batching', + type: 'boolean', + required: true, + default: true + }, + batch_size: { + label: 'Batch Size', + description: 'Maximum number of events to include in each batch. Actual batch sizes may be lower.', + type: 'number', + unsafe_hidden: true, + required: false, + default: 10000 + } + }, + + perform: async (request, data) => { + const { payload } = data + const projectId = 'test-project-id' // Replace it with actual project ID from settings or environment + if (!projectId) { + throw new Error('Missing Google Cloud project ID.') + } + return request('https://datamanager.googleapis.com/v1/audienceMembers:ingest', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-User-Project': projectId, + Accept: 'application/json' + }, + json: payload + }) + }, + performBatch: async (_request, _data) => { + return + } + } } } - export default action diff --git a/packages/destination-actions/src/destinations/google-data-manager/shared.ts b/packages/destination-actions/src/destinations/google-data-manager/shared.ts index 87a46ecce61..6405dee5d6a 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/shared.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/shared.ts @@ -1,19 +1,16 @@ -import { IntegrationError, RequestClient, StatsContext, HTTPError } from '@segment/actions-core' -import { OAUTH_URL, USER_UPLOAD_ENDPOINT, SEGMENT_DMP_ID } from './constants' +import { HTTPError, IntegrationError, RequestClient, StatsContext } from '@segment/actions-core' +import { OAUTH_URL, SEGMENT_DMP_ID, USER_UPLOAD_ENDPOINT } from './constants' import type { RefreshTokenResponse } from './types' -import { create, fromBinary, toBinary } from '@bufbuild/protobuf' +import { ListOperation, UpdateHandlerPayload, UserOperation } from './types' import { - UserIdType, + ErrorCode, UpdateUsersDataRequest, UpdateUsersDataRequestSchema, - UserDataOperationSchema, UpdateUsersDataResponse, - ErrorCode, - UpdateUsersDataResponseSchema + UpdateUsersDataResponseSchema, + UserDataOperationSchema } from './proto/protofile' - -import { ListOperation, UpdateHandlerPayload, UserOperation } from './types' import type { AudienceSettings } from './generated-types' type DV360AuthCredentials = { refresh_token: string; access_token: string; client_id: string; client_secret: string } @@ -61,41 +58,8 @@ export const buildHeaders = (audienceSettings: AudienceSettings | undefined, acc } } -export const assembleRawOps = (payload: UpdateHandlerPayload, operation: ListOperation): UserOperation[] => { - const rawOperations = [] - const audienceId = parseInt(payload.external_audience_id.split('/').pop() || '-1') - const isDelete = operation === 'remove' - - if (payload.google_gid) { - rawOperations.push({ - UserId: payload.google_gid, - UserIdType: UserIdType.GOOGLE_USER_ID, - UserListId: audienceId, - Delete: isDelete - }) - } - - if (payload.mobile_advertising_id) { - const isIDFA = payload.mobile_advertising_id.includes('-') - - rawOperations.push({ - UserId: payload.mobile_advertising_id, - UserIdType: isIDFA ? UserIdType.IDFA : UserIdType.ANDROID_ADVERTISING_ID, - UserListId: audienceId, - Delete: isDelete - }) - } - - if (payload.partner_provided_id) { - rawOperations.push({ - UserId: payload.partner_provided_id, - UserIdType: UserIdType.PARTNER_PROVIDED_ID, - UserListId: audienceId, - Delete: isDelete - }) - } - - return rawOperations +export const assembleRawOps = (_payload: UpdateHandlerPayload, _operation: ListOperation): UserOperation[] => { + return [] } const handleErrorCode = ( diff --git a/packages/destination-actions/src/destinations/google-data-manager/types.ts b/packages/destination-actions/src/destinations/google-data-manager/types.ts index 309bc4b055a..df03bb74447 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/types.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/types.ts @@ -1,3 +1,5 @@ +import type { Payload as AddToAudiencePayload } from './ingest/generated-types' + export interface RefreshTokenResponse { access_token: string } @@ -21,3 +23,4 @@ export type UserOperation = { } export type ListOperation = 'add' | 'remove' +export type UpdateHandlerPayload = AddToAudiencePayload From 31ad3330f4acc5a03d29303632f9b0064e925572 Mon Sep 17 00:00:00 2001 From: Arjun Bhandage Date: Tue, 17 Jun 2025 18:25:11 +0530 Subject: [PATCH 3/5] add authentication and other fields --- .../__snapshots__/snapshot.test.ts.snap | 72 ++- .../__tests__/snapshot.test.ts | 4 +- .../google-data-manager/constants.ts | 3 +- .../google-data-manager/generated-types.ts | 26 +- .../destinations/google-data-manager/index.ts | 114 ++-- .../__snapshots__/snapshot.test.ts.snap | 68 ++- .../ingest/__tests__/index.test.ts | 54 +- .../ingest/__tests__/snapshot.test.ts | 4 +- .../google-data-manager/ingest/index.ts | 245 ++++---- .../proto/bulk_upload.proto | 97 ++++ .../google-data-manager/proto/protofile.ts | 531 ++++++++++++++++++ .../google-data-manager/shared.ts | 5 +- 12 files changed, 1000 insertions(+), 223 deletions(-) create mode 100644 packages/destination-actions/src/destinations/google-data-manager/proto/bulk_upload.proto create mode 100644 packages/destination-actions/src/destinations/google-data-manager/proto/protofile.ts diff --git a/packages/destination-actions/src/destinations/google-data-manager/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/google-data-manager/__tests__/__snapshots__/snapshot.test.ts.snap index ddd0efa2d17..e06bf991ef4 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/google-data-manager/__tests__/__snapshots__/snapshot.test.ts.snap @@ -1,9 +1,69 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Testing snapshot for actions-google-data-manager destination: ingest action - all fields 1`] = `Array []`; +exports[`Testing snapshot for actions-google-data-manager destination: ingest action - all fields 1`] = ` +Object { + "audienceMembers": Array [ + Object { + "consent": Object { + "adPersonalization": "CONSENT_GRANTED", + "adUserData": "CONSENT_GRANTED", + }, + "userData": Object { + "userIdentifiers": Array [ + Object { + "address": Object { + "familyName": "ZWTB^0A]@#Be(", + "givenName": "ZWTB^0A]@#Be(", + "postalCode": "ZWTB^0A]@#Be(", + "regionCode": "ZWTB^0A]@#Be(", + }, + "emailAddress": "ZWTB^0A]@#Be(", + "phoneNumber": "ZWTB^0A]@#Be(", + }, + ], + }, + }, + ], + "destinations": Array [ + Object { + "loginAccount": Object { + "accountId": "8172370552", + "product": "DATA_PARTNER", + }, + "operatingAccount": Object {}, + }, + ], + "encoding": "BASE64", +} +`; -exports[`Testing snapshot for actions-google-data-manager destination: ingest action - required fields 1`] = `Array []`; - -exports[`Testing snapshot for actions-google-data-manager destination: remove action - all fields 1`] = `Array []`; - -exports[`Testing snapshot for actions-google-data-manager destination: remove action - required fields 1`] = `Array []`; +exports[`Testing snapshot for actions-google-data-manager destination: ingest action - required fields 1`] = ` +Object { + "audienceMembers": Array [ + Object { + "consent": Object { + "adPersonalization": "CONSENT_GRANTED", + "adUserData": "CONSENT_GRANTED", + }, + "userData": Object { + "userIdentifiers": Array [ + Object { + "address": Object {}, + "emailAddress": "ZWTB^0A]@#Be(", + }, + ], + }, + }, + ], + "destinations": Array [ + Object { + "loginAccount": Object { + "accountId": "8172370552", + "product": "DATA_PARTNER", + }, + "operatingAccount": Object {}, + }, + ], + "encoding": "BASE64", +} +`; diff --git a/packages/destination-actions/src/destinations/google-data-manager/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/google-data-manager/__tests__/snapshot.test.ts index 43ea9e724eb..f74ee4cfa28 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/__tests__/snapshot.test.ts @@ -25,7 +25,7 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { event: event, mapping: event.properties, settings: settingsData, - auth: undefined + auth: { accessToken: 'test-access-token', refreshToken: 'test-refresh-token' } }) const request = responses[0].request @@ -59,7 +59,7 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { event: event, mapping: event.properties, settings: settingsData, - auth: undefined + auth: { accessToken: 'test-access-token', refreshToken: 'test-refresh-token' } }) const request = responses[0].request diff --git a/packages/destination-actions/src/destinations/google-data-manager/constants.ts b/packages/destination-actions/src/destinations/google-data-manager/constants.ts index bb1194eb18e..a9138d410cd 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/constants.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/constants.ts @@ -1,6 +1,7 @@ export const GOOGLE_API_VERSION = 'v2' // accountType and advertiserID are used as markers to be replaced in the code. DO NOT REMOVE THEM. -export const BASE_URL = `https://audiencepartner.googleapis.com/${GOOGLE_API_VERSION}/products/accountType/customers/advertiserID/` +// export const BASE_URL = `https://audiencepartner.googleapis.com/${GOOGLE_API_VERSION}/products/accountType/customers/advertiserID/` +export const BASE_URL = `https://audiencepartner.googleapis.com/${GOOGLE_API_VERSION}/products/GOOGLE_ADS/customers/advertiserID/` //TODO export const CREATE_AUDIENCE_URL = `${BASE_URL}userLists:mutate` export const GET_AUDIENCE_URL = `${BASE_URL}audiencePartner:searchStream` export const OAUTH_URL = 'https://accounts.google.com/o/oauth2/token' diff --git a/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts b/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts index 7c7d5e71fee..86eb33bd625 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts @@ -2,27 +2,27 @@ export interface Settings { /** - * The ID of the operating account, used throughout Google Data Manager. Use this ID when you contact Google support to help our teams locate your specific account. - */ - operatingAccountId: string - /** - * The product for which you want to create or manage audiences. + * The product link to use for audience management. This should be in the format `products/DATA_PARTNER/customers/8283492941`. */ - product: string - /** - * The ID of the product destination, used to identify the specific destination for audience management. - */ - productDestinationId: string + productLink: string } // Generated file. DO NOT MODIFY IT BY HAND. export interface AudienceSettings { /** - * The ID of your advertiser, used throughout Display & Video 360. Use this ID when you contact Display & Video 360 support to help our teams locate your specific account. + * The ID of the advertiser in Google Data Manager. This is used to identify the specific advertiser for audience management. */ advertiserId: string /** - * The type of the advertiser account you have linked to this Display & Video 360 destination. + * The ID of the operating account, used throughout Google Data Manager. Use this ID when you contact Google support to help our teams locate your specific account. + */ + operatingAccountId: string + /** + * The product for which you want to create or manage audiences. */ - accountType: string + product: string[] + /** + * The ID of the product destination, used to identify the specific destination for audience management. + */ + productDestinationId: string } diff --git a/packages/destination-actions/src/destinations/google-data-manager/index.ts b/packages/destination-actions/src/destinations/google-data-manager/index.ts index 2896b6e04bc..7397d2b6b66 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/index.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/index.ts @@ -21,59 +21,34 @@ const destination: AudienceDestinationDefinition = { authentication: { scheme: 'oauth2', fields: { - /*destinations: { - label: 'Destinations', - description: 'List of destinations to which the audience will be synced. Each destination must have a unique combination of operatingAccountId, product, and productDestinationId.', - type: 'object' as FieldType, - multiple: true, - // defaultObjectUI: 'arrayeditor', - // additionalProperties: true, - // required: CREATE_OPERATION, - // depends_on: CREATE_OPERATION, - properties: { - operatingAccountId: { - label: 'Operating Account ID', - description: - 'The ID of the operating account, used throughout Google Data Manager. Use this ID when you contact Google support to help our teams locate your specific account.', - type: 'string', - required: true - }, - product: { - label: 'Product', - description: 'The product for which you want to create or manage audiences.', - type: 'string', - multiple: true, - required: true, - choices: [ - { label: 'Google Ads', value: 'GOOGLE_ADS' }, - { label: 'Display & Video 360', value: 'DISPLAY_VIDEO_360' }, - ] - }, - productDestinationId: { - label: 'Product Destination ID', - description: - 'The ID of the product destination, used to identify the specific destination for audience management.', - type: 'string', - required: true - } - } - },*/ + productLink: { + type: 'string', + label: 'Product Link', + description: + 'The product link to use for audience management. This should be in the format `products/DATA_PARTNER/customers/8283492941`.', + required: true + } }, - testAuthentication: async (request, { auth }) => { + testAuthentication: async (request, { auth, settings }) => { // Call the Google Audience Partner API to test authentication - const accessToken = auth?.accessToken + const accessToken = auth.accessToken if (!accessToken) { throw new Error('Missing access token for authentication test.') } const response = await request( - 'https://audiencepartner.googleapis.com/v2/products/DATA_PARTNER/customers/8283492941/audiencePartner:searchStream', + 'https://audiencepartner.googleapis.com/v2/productLink/audiencePartner:searchStream'.replace( + 'productLink', + settings.productLink + ), { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, - 'login-customer-id': 'products/DATA_PARTNER/customers/8283492941' + 'login-customer-id': settings.productLink }, + + // TODO: Test query to validate authentication to different products body: JSON.stringify({ query: 'SELECT product_link.display_video_advertiser.display_video_advertiser FROM product_link ' }) @@ -124,7 +99,7 @@ const destination: AudienceDestinationDefinition = { }, async createAudience(request, createAudienceInput) { const { audienceName, audienceSettings, statsContext } = createAudienceInput - const { accountType } = audienceSettings || {} + // const { accountType } = audienceSettings || {} const advertiserId = audienceSettings?.advertiserId.trim() const { statsClient, tags: statsTags } = statsContext || {} const statsName = 'createAudience' @@ -145,7 +120,7 @@ const destination: AudienceDestinationDefinition = { throw new IntegrationError('Missing advertiser ID value', 'MISSING_REQUIRED_FIELD', 400) } - if (!accountType) { + /* if (!accountType) { statsTags?.push('error:missing-settings') statsClient?.incr(`${statsName}.error`, 1, statsTags) throw new IntegrationError('Missing account type value', 'MISSING_REQUIRED_FIELD', 400) @@ -155,8 +130,10 @@ const destination: AudienceDestinationDefinition = { const partnerCreateAudienceUrl = CREATE_AUDIENCE_URL.replace('advertiserID', advertiserId).replace( 'accountType', accountType - ) - + )*/ + // TODO: Multiple calls to different endpoints for different products + const partnerCreateAudienceUrl = CREATE_AUDIENCE_URL.replace('advertiserID', advertiserId) + const listTypeMap = { basicUserList: {}, type: 'REMARKETING', membershipStatus: 'OPEN' } let response try { const authToken = await getAuthToken(request, authSettings) @@ -190,7 +167,7 @@ const destination: AudienceDestinationDefinition = { async getAudience(request, getAudienceInput) { const { statsContext, audienceSettings } = getAudienceInput const { statsClient, tags: statsTags } = statsContext || {} - const { accountType } = audienceSettings || {} + // const { accountType } = audienceSettings || {} const advertiserId = audienceSettings?.advertiserId.trim() const statsName = 'getAudience' statsTags?.push(`slug:${destination.slug}`) @@ -204,7 +181,7 @@ const destination: AudienceDestinationDefinition = { throw new IntegrationError('Missing required advertiser ID value', 'MISSING_REQUIRED_FIELD', 400) } - if (!accountType) { + /*if (!accountType) { statsTags?.push('error:missing-settings') statsClient?.incr(`${statsName}.error`, 1, statsTags) throw new IntegrationError('Missing account type value', 'MISSING_REQUIRED_FIELD', 400) @@ -213,15 +190,20 @@ const destination: AudienceDestinationDefinition = { const advertiserGetAudienceUrl = GET_AUDIENCE_URL.replace('advertiserID', advertiserId).replace( 'accountType', accountType - ) - + )*/ + const advertiserGetAudienceUrl = GET_AUDIENCE_URL.replace('advertiserID', advertiserId) try { const authToken = await getAuthToken(request, authSettings) const response = await request(advertiserGetAudienceUrl, { headers: buildHeaders(audienceSettings, authToken), method: 'POST', json: { - query: `SELECT user_list.name, user_list.description, user_list.membership_status, user_list.match_rate_percentage FROM user_list WHERE user_list.resource_name = "${getAudienceInput.externalId}"` + query: `SELECT user_list.name, + user_list.description, + user_list.membership_status, + user_list.match_rate_percentage + FROM user_list + WHERE user_list.resource_name = "${getAudienceInput.externalId}"` } }) @@ -249,22 +231,36 @@ const destination: AudienceDestinationDefinition = { }, audienceFields: { advertiserId: { - type: 'string', label: 'Advertiser ID', - required: true, description: - 'The ID of your advertiser, used throughout Display & Video 360. Use this ID when you contact Display & Video 360 support to help our teams locate your specific account.' + 'The ID of the advertiser in Google Data Manager. This is used to identify the specific advertiser for audience management.', + type: 'string', + required: true + }, + operatingAccountId: { + label: 'Operating Account ID', + description: + 'The ID of the operating account, used throughout Google Data Manager. Use this ID when you contact Google support to help our teams locate your specific account.', + type: 'string', + required: true }, - accountType: { + product: { + label: 'Product', + description: 'The product for which you want to create or manage audiences.', type: 'string', - label: 'Account Type', - description: 'The type of the advertiser account you have linked to this Display & Video 360 destination.', + multiple: true, required: true, choices: [ - { label: 'Advertiser', value: 'DISPLAY_VIDEO_ADVERTISER' }, - { label: 'Partner', value: 'DISPLAY_VIDEO_PARTNER' }, - { label: 'Publisher', value: 'GOOGLE_AD_MANAGER' } + { label: 'Google Ads', value: 'GOOGLE_ADS' }, + { label: 'Display & Video 360', value: 'DISPLAY_VIDEO_360' } ] + }, + productDestinationId: { + label: 'Product Destination ID', + description: + 'The ID of the product destination, used to identify the specific destination for audience management.', + type: 'string', + required: true } } } diff --git a/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/__snapshots__/snapshot.test.ts.snap index 3076e28491e..2ef795e71c0 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/__snapshots__/snapshot.test.ts.snap @@ -1,5 +1,69 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Testing snapshot for GoogleDataManager's ingest destination action: all fields 1`] = `Array []`; +exports[`Testing snapshot for GoogleDataManager's ingest destination action: all fields 1`] = ` +Object { + "audienceMembers": Array [ + Object { + "consent": Object { + "adPersonalization": "CONSENT_GRANTED", + "adUserData": "CONSENT_GRANTED", + }, + "userData": Object { + "userIdentifiers": Array [ + Object { + "address": Object { + "familyName": "b#TgBtvsSl", + "givenName": "b#TgBtvsSl", + "postalCode": "b#TgBtvsSl", + "regionCode": "b#TgBtvsSl", + }, + "emailAddress": "b#TgBtvsSl", + "phoneNumber": "b#TgBtvsSl", + }, + ], + }, + }, + ], + "destinations": Array [ + Object { + "loginAccount": Object { + "accountId": "8172370552", + "product": "DATA_PARTNER", + }, + "operatingAccount": Object {}, + }, + ], + "encoding": "BASE64", +} +`; -exports[`Testing snapshot for GoogleDataManager's ingest destination action: required fields 1`] = `Array []`; +exports[`Testing snapshot for GoogleDataManager's ingest destination action: required fields 1`] = ` +Object { + "audienceMembers": Array [ + Object { + "consent": Object { + "adPersonalization": "CONSENT_GRANTED", + "adUserData": "CONSENT_GRANTED", + }, + "userData": Object { + "userIdentifiers": Array [ + Object { + "address": Object {}, + "emailAddress": "b#TgBtvsSl", + }, + ], + }, + }, + ], + "destinations": Array [ + Object { + "loginAccount": Object { + "accountId": "8172370552", + "product": "DATA_PARTNER", + }, + "operatingAccount": Object {}, + }, + ], + "encoding": "BASE64", +} +`; diff --git a/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/index.test.ts b/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/index.test.ts index d1d07294b8e..84fe4e82159 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/index.test.ts @@ -1,21 +1,45 @@ -import { createTestIntegration } from '@segment/actions-core' -import Destination from '../../index' - -const testDestination = createTestIntegration(Destination) +import nock from 'nock' +import ingest from '../index' describe('GoogleDataManager.ingest', () => { - it('should be defined', () => { - expect(testDestination).toBeDefined() - expect(testDestination.actions.ingest).toBeDefined() + afterEach(() => { + nock.cleanAll() }) - it('should call perform without error', async () => { - const action = testDestination.actions.ingest - const perform = action.definition.perform - expect(typeof perform).toBe('function') - // Since there are no fields, we can call with empty data - await expect(perform({} as any, { payload: {}, settings: {} } as any)).resolves.toBeUndefined() - }) + it('should send a valid payload to the ingest endpoint', async () => { + const payload = { + emailAddress: 'test@test.com', + audienceId: 'test', + enable_batching: true + } - // TODO: Add more tests when the action is implemented + const mockRequest = jest.fn().mockResolvedValue({ status: 200 }) + const executeInput = { + payload: payload, + audienceSettings: { + advertiserId: '8142508276', + product: 'GOOGLE_ADS', + productDestinationId: '9001371543' + }, + settings: { productLink: 'products/DATA_PARTNER/customers/8172370552' }, + auth: { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token' + } + } + const response = await ingest.perform(mockRequest, executeInput) + expect(response.status).toBe(200) + expect(mockRequest).toHaveBeenCalledWith( + 'https://datamanager.googleapis.com/v1/audienceMembers:ingest', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + Authorization: 'Bearer test-access-token', + Accept: 'application/json' + }) + // TODO: check the outbound payload structure + }) + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/snapshot.test.ts index 4c51415c023..f23ad8d823e 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/ingest/__tests__/snapshot.test.ts @@ -25,7 +25,7 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac event: event, mapping: event.properties, settings: settingsData, - auth: undefined + auth: { accessToken: 'test-access-token', refreshToken: 'test-refresh-token' } }) const request = responses[0].request @@ -58,7 +58,7 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac event: event, mapping: event.properties, settings: settingsData, - auth: undefined + auth: { accessToken: 'test-access-token', refreshToken: 'test-refresh-token' } }) const request = responses[0].request diff --git a/packages/destination-actions/src/destinations/google-data-manager/ingest/index.ts b/packages/destination-actions/src/destinations/google-data-manager/ingest/index.ts index 7fa8ae5ec02..ae3b1a95091 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/ingest/index.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/ingest/index.ts @@ -7,132 +7,135 @@ const action: ActionDefinition = { description: 'Uploads a list of AudienceMember resources to the provided Destination.', defaultSubscription: 'event = "Audience Entered" or event = "Audience Exited"', fields: { - destinations: { - label: 'Destinations', + emailAddress: { + label: 'Email Address', + description: 'The email address of the audience member.', + type: 'string', + required: true, + default: { '@path': '$.traits.email' } + }, + phoneNumber: { + label: 'Phone Number', + description: 'The phone number of the audience member.', + type: 'string', + required: false, + default: { '@path': '$.traits.phone' } + }, + givenName: { + label: 'Given Name', + description: 'The given name (first name) of the audience member.', + type: 'string', + required: false, + default: { '@path': '$.traits.firstName' } + }, + familyName: { + label: 'Family Name', + description: 'The family name (last name) of the audience member.', + type: 'string', + required: false, + default: { '@path': '$.traits.lastName' } + }, + regionCode: { + label: 'Region Code', + description: 'The region code (e.g., country code) of the audience member.', + type: 'string', + required: false, + default: { '@path': '$.traits.countryCode' } + }, + postalCode: { + label: 'Postal Code', + description: 'The postal code of the audience member.', + type: 'string', + required: false, + default: { '@path': '$.traits.postalCode' } + }, + audienceId: { + label: 'Audience ID', + type: 'string', + required: true, + unsafe_hidden: true, description: - 'List of destinations to which the audience will be synced. Each destination must have a unique combination of operatingAccountId, product, and productDestinationId.', - type: 'object' as FieldType, - multiple: true, - // defaultObjectUI: 'arrayeditor', - // additionalProperties: true, - // required: CREATE_OPERATION, - // depends_on: CREATE_OPERATION, - properties: { - operatingAccountId: { - label: 'Operating Account ID', - description: - 'The ID of the operating account, used throughout Google Data Manager. Use this ID when you contact Google support to help our teams locate your specific account.', - type: 'string', - required: true - }, - product: { - label: 'Product', - description: 'The product for which you want to create or manage audiences.', - type: 'string', - multiple: true, - required: true, - choices: [ - { label: 'Google Ads', value: 'GOOGLE_ADS' }, - { label: 'Display & Video 360', value: 'DISPLAY_VIDEO_360' } - ] - }, - productDestinationId: { - label: 'Product Destination ID', - description: - 'The ID of the product destination, used to identify the specific destination for audience management.', - type: 'string', - required: true - }, - emailAddress: { - label: 'Email Address', - description: 'The email address of the audience member.', - type: 'string', - required: true, - default: { '@path': '$.traits.email' } - }, - phoneNumber: { - label: 'Phone Number', - description: 'The phone number of the audience member.', - type: 'string', - required: false, - default: { '@path': '$.traits.phone' } - }, - givenName: { - label: 'Given Name', - description: 'The given name (first name) of the audience member.', - type: 'string', - required: false, - default: { '@path': '$.traits.firstName' } - }, - familyName: { - label: 'Family Name', - description: 'The family name (last name) of the audience member.', - type: 'string', - required: false, - default: { '@path': '$.traits.lastName' } - }, - regionCode: { - label: 'Region Code', - description: 'The region code (e.g., country code) of the audience member.', - type: 'string', - required: false, - default: { '@path': '$.traits.countryCode' } - }, - postalCode: { - label: 'Postal Code', - description: 'The postal code of the audience member.', - type: 'string', - required: false, - default: { '@path': '$.traits.postalCode' } - }, - audienceId: { - label: 'Audience ID', - type: 'string', - required: true, - unsafe_hidden: true, - description: - 'A number value representing the Amazon audience identifier. This is the identifier that is returned during audience creation.', - default: { - '@path': '$.context.personas.external_audience_id' - } - }, - enable_batching: { - label: 'Enable Batching', - description: 'When enabled,segment will send data in batching', - type: 'boolean', - required: true, - default: true - }, - batch_size: { - label: 'Batch Size', - description: 'Maximum number of events to include in each batch. Actual batch sizes may be lower.', - type: 'number', - unsafe_hidden: true, - required: false, - default: 10000 - } - }, + 'A number value representing the Amazon audience identifier. This is the identifier that is returned during audience creation.', + default: { + '@path': '$.context.personas.external_audience_id' + } + }, + enable_batching: { + label: 'Enable Batching', + description: 'When enabled,segment will send data in batching', + type: 'boolean', + required: true, + default: true, + unsafe_hidden: true + }, + batch_size: { + label: 'Batch Size', + description: 'Maximum number of events to include in each batch. Actual batch sizes may be lower.', + type: 'number', + unsafe_hidden: true, + required: false, + default: 10000 + } + }, - perform: async (request, data) => { - const { payload } = data - const projectId = 'test-project-id' // Replace it with actual project ID from settings or environment - if (!projectId) { - throw new Error('Missing Google Cloud project ID.') + perform: async (request, data) => { + const { payload, audienceSettings, auth } = data + // You should get the access token from the request context or settings + const accessToken = auth?.accessToken || '' + if (!accessToken) { + throw new Error('Missing access token.') + } + // Example static data for demonstration; replace with actual payload mapping as needed + const body = { + audienceMembers: [ + { + consent: { + adUserData: 'CONSENT_GRANTED', + adPersonalization: 'CONSENT_GRANTED' + }, + userData: { + userIdentifiers: [ + { + emailAddress: payload.emailAddress, + phoneNumber: payload.phoneNumber, + address: { + givenName: payload.givenName, + familyName: payload.familyName, + regionCode: payload.regionCode, + postalCode: payload.postalCode + } + } + ] + } } - return request('https://datamanager.googleapis.com/v1/audienceMembers:ingest', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Goog-User-Project': projectId, - Accept: 'application/json' + ], + destinations: [ + { + operatingAccount: { + accountId: audienceSettings.advertiserId, // customer id + product: audienceSettings.product // TODO: add multiple entries for different products }, - json: payload - }) - }, - performBatch: async (_request, _data) => { - return - } + loginAccount: { + accountId: '8172370552', // segment id + product: 'DATA_PARTNER' + }, + productDestinationId: audienceSettings.productDestinationId + } + ], + encoding: 'BASE64' } + return request('https://datamanager.googleapis.com/v1/audienceMembers:ingest', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, // this is segment auth token + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + json: body + }) + }, + performBatch: async (_request, _data) => { + return } } export default action diff --git a/packages/destination-actions/src/destinations/google-data-manager/proto/bulk_upload.proto b/packages/destination-actions/src/destinations/google-data-manager/proto/bulk_upload.proto new file mode 100644 index 00000000000..8254754e025 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-data-manager/proto/bulk_upload.proto @@ -0,0 +1,97 @@ +syntax = "proto3"; + +package google.ads.datamanager.v1; + +option go_package = "google.golang.org/genproto/googleapis/ads/datamanager/v1;datamanager"; +option java_multiple_files = true; +option java_outer_classname = "UserDataProto"; +option java_package = "com.google.ads.datamanager.v1"; + +// Operation to add or remove a user from user lists. +message UserDataOperation { + // The user identifier. + string user_id = 1; + // The type of user identifier. + UserIdType user_id_type = 2; + // The user list IDs to add or remove the user from. + repeated int64 user_list_ids = 3; + // The timestamp when the user was added to the list (seconds since epoch). + repeated int64 time_added_to_user_list = 4; + // Whether to remove the user from the list. + bool remove = 5; + // Whether the user has opted out. + bool opt_out = 6; + // The data source ID. + uint32 data_source_id = 7; +} + +// Request to update users in user lists. +message UpdateUsersDataRequest { + repeated UserDataOperation operations = 1; + bool send_notifications = 2; + bool process_consent = 3; +} + +// Error information for a user data operation. +message ErrorInfo { + repeated int64 user_list_ids = 1; + string user_id = 2; + UserIdType user_id_type = 3; + ErrorCode error_code = 4; +} + +// Notification information for a user data operation. +message NotificationInfo { + string user_id = 1; + NotificationCode notification_code = 2; +} + +// Response for updating users in user lists. +message UpdateUsersDataResponse { + ErrorCode status = 1; + repeated ErrorInfo errors = 2; + repeated NotificationInfo notifications = 3; + NotificationStatus notification_status = 4; +} + +enum UserIdType { + USER_ID_TYPE_UNSPECIFIED = 0; + GOOGLE_USER_ID = 1; + IDFA = 2; + ANDROID_ADVERTISING_ID = 3; + RIDA = 4; + AFAI = 5; + MSAID = 6; + GENERIC_DEVICE_ID = 7; + PARTNER_PROVIDED_ID = 8; +} + +enum NotificationCode { + NOTIFICATION_CODE_UNSPECIFIED = 0; + INACTIVE_COOKIE = 1; +} + +enum NotificationStatus { + NOTIFICATION_STATUS_UNSPECIFIED = 0; + NO_NOTIFICATION = 1; + NOTIFICATIONS_OMITTED = 2; +} + +enum ErrorCode { + ERROR_CODE_UNSPECIFIED = 0; + NO_ERROR = 1; + PARTIAL_SUCCESS = 2; + PERMISSION_DENIED = 3; + BAD_DATA = 4; + BAD_COOKIE = 5; + BAD_ATTRIBUTE_ID = 6; + BAD_NETWORK_ID = 7; + REQUEST_TOO_BIG = 8; + EMPTY_REQUEST = 9; + INTERNAL_ERROR = 10; + BAD_DATA_SOURCE_ID = 11; + BAD_TIMESTAMP = 12; + MISSING_CONSENT_WILL_BE_DROPPED = 13; + MISSING_CONSENT = 14; + UNKNOWN_ID = 15; +} diff --git a/packages/destination-actions/src/destinations/google-data-manager/proto/protofile.ts b/packages/destination-actions/src/destinations/google-data-manager/proto/protofile.ts new file mode 100644 index 00000000000..e3b8ffa97c7 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-data-manager/proto/protofile.ts @@ -0,0 +1,531 @@ +// @generated by protoc-gen-es v2.2.3 with parameter "target=ts" +// @generated from file bulk_upload.proto (syntax proto2) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from '@bufbuild/protobuf/codegenv1' +import { enumDesc, fileDesc, messageDesc } from '@bufbuild/protobuf/codegenv1' +import type { Message } from '@bufbuild/protobuf' + +/** + * Describes the file bulk_upload.proto. + */ +export const file_bulk_upload: GenFile = + /*@__PURE__*/ + fileDesc('xxxxx') + +/** + * Update data for a single user. + * + * @generated from message UserDataOperation + */ +export type UserDataOperation = Message<'UserDataOperation'> & { + /** + * User id. The type is determined by the user_id_type field. + * + * Must always be present. Specifies which user this operation applies to. + * + * @generated from field: optional string user_id = 1 [default = ""]; + */ + userId: string + + /** + * The type of the user id. + * + * @generated from field: optional UserIdType user_id_type = 14 [default = GOOGLE_USER_ID]; + */ + userIdType: UserIdType + + /** + * The id of the userlist. This can be retrieved from the AdX UI for AdX + * customers, the AdWords API for non-AdX customers, or through your Technical + * Account Manager. + * + * @generated from field: optional int64 user_list_id = 4 [default = 0]; + */ + userListId: bigint + + /** + * Optional time (seconds since the epoch) when the user performed an action + * causing them to be added to the list. Using the default value of 0 + * indicates that the current time on the server should be used. + * + * @generated from field: optional int64 time_added_to_user_list = 5 [default = 0]; + */ + timeAddedToUserList: bigint + + /** + * Same as time_added_to_user_list but with finer grained time resolution, in + * microseconds. If both timestamps are specified, + * time_added_to_user_list_in_usec will be used. + * + * @generated from field: optional int64 time_added_to_user_list_in_usec = 8 [default = 0]; + */ + timeAddedToUserListInUsec: bigint + + /** + * Set to true if the operation is a deletion. + * + * @generated from field: optional bool delete = 6 [default = false]; + */ + delete: boolean + + /** + * Set true if the user opted out from being targeted. + * + * @generated from field: optional bool opt_out = 12 [default = false]; + */ + optOut: boolean + + /** + * An id indicating the data source which contributed this membership. The id + * is required to be in the range of 1 to 1000 and any ids greater than this + * will result in an error of type BAD_DATA_SOURCE_ID. These ids don't have + * any semantics for Google and may be used as labels for reporting purposes. + * + * @generated from field: optional int32 data_source_id = 7 [default = 0]; + */ + dataSourceId: number +} + +/** + * Describes the message UserDataOperation. + * Use `create(UserDataOperationSchema)` to create a new message. + */ +export const UserDataOperationSchema: GenMessage = /*@__PURE__*/ messageDesc(file_bulk_upload, 0) + +/** + * This protocol buffer is used to update user data. It is sent as the payload + * of an HTTPS POST request with the Content-Type header set to + * "application/octet-stream" (preferrably Content-Encoding: gzip). + * + * @generated from message UpdateUsersDataRequest + */ +export type UpdateUsersDataRequest = Message<'UpdateUsersDataRequest'> & { + /** + * Multiple operations over user attributes or user lists. + * + * @generated from field: repeated UserDataOperation ops = 1; + */ + ops: UserDataOperation[] + + /** + * If true, request sending notifications about the given users in the + * response. Note that in some circumstances notifications may not be sent + * even if requested. In this case the notification_status field of the + * response will be set to NOTIFICATIONS_OMITTED. + * + * @generated from field: optional bool send_notifications = 2 [default = false]; + */ + sendNotifications: boolean + + /** + * Partners using the Bulk Upload API must indicate that they have the proper + * legal basis to share user data with Google for Bulk Upload purposes using + * the process_consent parameter. This requirement applies to all Bulk Upload + * requests. + * + * For user data that requires end-user consent as + * required by Google's EU User Consent Policy + * (see https://www.google.com/about/company/user-consent-policy/) or + * by other local laws, partners are required to obtain + * end-user consent and indicate gathered consent + * by setting process_consent=True. + * + * For user data which is not subject to end-user consent requirements, + * partners are required to indicate that consent is not + * required by setting process_consent=True. + * + * Requests without `process_consent=True` will be filtered. + * + * @generated from field: optional bool process_consent = 3 [default = false]; + */ + processConsent: boolean +} + +/** + * Describes the message UpdateUsersDataRequest. + * Use `create(UpdateUsersDataRequestSchema)` to create a new message. + */ +export const UpdateUsersDataRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_bulk_upload, 1) + +/** + * Information about an error with an individual user operation. + * + * @generated from message ErrorInfo + */ +export type ErrorInfo = Message<'ErrorInfo'> & { + /** + * The user_list_id in the request which caused problems. This may be empty + * if the problem was with a particular user id. + * + * @generated from field: optional int64 user_list_id = 2 [default = 0]; + */ + userListId: bigint + + /** + * The user_id which caused problems. This may be empty if other data was bad + * regardless of a cookie. + * + * @generated from field: optional string user_id = 3 [default = ""]; + */ + userId: string + + /** + * The type of the user ID. + * + * @generated from field: optional UserIdType user_id_type = 7 [default = GOOGLE_USER_ID]; + */ + userIdType: UserIdType + + /** + * @generated from field: optional ErrorCode error_code = 4; + */ + errorCode: ErrorCode +} + +/** + * Describes the message ErrorInfo. + * Use `create(ErrorInfoSchema)` to create a new message. + */ +export const ErrorInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_bulk_upload, 2) + +/** + * Per user notification information. + * + * @generated from message NotificationInfo + */ +export type NotificationInfo = Message<'NotificationInfo'> & { + /** + * The user_id for which the notification applies. One of the user_ids sent + * in a UserDataOperation. + * + * @generated from field: optional string user_id = 1 [default = ""]; + */ + userId: string + + /** + * @generated from field: optional NotificationCode notification_code = 2; + */ + notificationCode: NotificationCode +} + +/** + * Describes the message NotificationInfo. + * Use `create(NotificationInfoSchema)` to create a new message. + */ +export const NotificationInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_bulk_upload, 3) + +/** + * Response to the UpdateUsersDataRequest. Sent in HTTP response to the + * original POST request, with the Content-Type header set to + * "application/octet-stream". The HTTP response status is either 200 (no + * errors) or 400, in which case the protocol buffer will provide error details. + * + * @generated from message UpdateUsersDataResponse + */ +export type UpdateUsersDataResponse = Message<'UpdateUsersDataResponse'> & { + /** + * When status == PARTIAL_SUCCESS, some (not all) of the operations failed and + * the "errors" field has details on the types and number of errors + * encountered. When status == NO_ERROR, all the data was imported + * successfully. When status > PARTIAL_SUCCESS no data was imported. + * + * @generated from field: optional ErrorCode status = 1; + */ + status: ErrorCode + + /** + * Each operation that failed is reported as a separate error here when + * status == PARTIAL_SUCCESS. + * + * @generated from field: repeated ErrorInfo errors = 2; + */ + errors: ErrorInfo[] + + /** + * Useful, non-error, information about the user ids in the request. Each + * NotificationInfo provides information about a single user id. Only sent if + * UpdateUsersDataRequest.send_notifications is set to true. + * + * @generated from field: repeated NotificationInfo notifications = 3; + */ + notifications: NotificationInfo[] + + /** + * Indicates why a notification has not been sent. + * + * @generated from field: optional NotificationStatus notification_status = 4; + */ + notificationStatus: NotificationStatus +} + +/** + * Describes the message UpdateUsersDataResponse. + * Use `create(UpdateUsersDataResponseSchema)` to create a new message. + */ +export const UpdateUsersDataResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_bulk_upload, 4) + +/** + * The type of identifier being uploaded. + * + * @generated from enum UserIdType + */ +export enum UserIdType { + /** + * A user identifier received through the cookie matching service. + * + * @generated from enum value: GOOGLE_USER_ID = 0; + */ + GOOGLE_USER_ID = 0, + + /** + * iOS Advertising ID. + * + * @generated from enum value: IDFA = 1; + */ + IDFA = 1, + + /** + * Android Advertising ID. + * + * @generated from enum value: ANDROID_ADVERTISING_ID = 2; + */ + ANDROID_ADVERTISING_ID = 2, + + /** + * Roku ID. + * + * @generated from enum value: RIDA = 5; + */ + RIDA = 5, + + /** + * Amazon Fire TV ID. + * + * @generated from enum value: AFAI = 6; + */ + AFAI = 6, + + /** + * XBOX/Microsoft ID. + * + * @generated from enum value: MSAI = 7; + */ + MSAI = 7, + + /** + * A "generic" category for any UUID formatted device provided ID. + * Allows partner uploads without needing to select a specific, + * pre-existing Device ID type. + * + * @generated from enum value: GENERIC_DEVICE_ID = 9; + */ + GENERIC_DEVICE_ID = 9, + + /** + * Partner provided ID. User identifier in partner's namespace. + * If the partner has sent the partner user identifier during cookie matching, + * then Google will be able to store user list membership associated with + * the partner's user identifier. + * See cookie matching documentation: + * https://developers.google.com/authorized-buyers/rtb/cookie-guide + * + * @generated from enum value: PARTNER_PROVIDED_ID = 4; + */ + PARTNER_PROVIDED_ID = 4 +} + +/** + * Describes the enum UserIdType. + */ +export const UserIdTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_bulk_upload, 0) + +/** + * Notification code. + * + * @generated from enum NotificationCode + */ +export enum NotificationCode { + /** + * A cookie is considered inactive if Google has not seen any activity related + * to the cookie in several days. + * + * @generated from enum value: INACTIVE_COOKIE = 0; + */ + INACTIVE_COOKIE = 0 +} + +/** + * Describes the enum NotificationCode. + */ +export const NotificationCodeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_bulk_upload, 1) + +/** + * Notification status code. + * + * @generated from enum NotificationStatus + */ +export enum NotificationStatus { + /** + * No need to send notifications for this request. + * + * @generated from enum value: NO_NOTIFICATION = 0; + */ + NO_NOTIFICATION = 0, + + /** + * Google decided to not send notifications, even though there were + * notifications to send. + * + * @generated from enum value: NOTIFICATIONS_OMITTED = 1; + */ + NOTIFICATIONS_OMITTED = 1 +} + +/** + * Describes the enum NotificationStatus. + */ +export const NotificationStatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_bulk_upload, 2) + +/** + * Response error codes. + * + * @generated from enum ErrorCode + */ +export enum ErrorCode { + /** + * @generated from enum value: NO_ERROR = 0; + */ + NO_ERROR = 0, + + /** + * Some of the user data operations failed. See comments in the + * UpdateUserDataResponse + * + * @generated from enum value: PARTIAL_SUCCESS = 1; + */ + PARTIAL_SUCCESS = 1, + + /** + * Provided network_id cannot add data to attribute_id or non-HTTPS. + * + * @generated from enum value: PERMISSION_DENIED = 2; + */ + PERMISSION_DENIED = 2, + + /** + * Cannot parse payload. + * + * @generated from enum value: BAD_DATA = 3; + */ + BAD_DATA = 3, + + /** + * Cannot decode provided cookie. + * + * @generated from enum value: BAD_COOKIE = 4; + */ + BAD_COOKIE = 4, + + /** + * Invalid or closed user_list_id. + * + * @generated from enum value: BAD_ATTRIBUTE_ID = 5; + */ + BAD_ATTRIBUTE_ID = 5, + + /** + * An invalid nid parameter was provided in the request. + * + * @generated from enum value: BAD_NETWORK_ID = 7; + */ + BAD_NETWORK_ID = 7, + + /** + * Request payload size over allowed limit. + * + * @generated from enum value: REQUEST_TOO_BIG = 8; + */ + REQUEST_TOO_BIG = 8, + + /** + * No UserDataOperation messages in UpdateUsersDataRequest. + * + * @generated from enum value: EMPTY_REQUEST = 9; + */ + EMPTY_REQUEST = 9, + + /** + * The server could not process the request due to an internal error. Retrying + * the same request later is suggested. + * + * @generated from enum value: INTERNAL_ERROR = 10; + */ + INTERNAL_ERROR = 10, + + /** + * Bad data_source_id -- most likely out of range from [1, 1000]. + * + * @generated from enum value: BAD_DATA_SOURCE_ID = 11; + */ + BAD_DATA_SOURCE_ID = 11, + + /** + * The timestamp is a past/future time that is too far from current time. + * + * @generated from enum value: BAD_TIMESTAMP = 12; + */ + BAD_TIMESTAMP = 12, + + /** + * Partners using the Bulk Upload API must indicate that they have the proper + * legal basis to share user data with Google for Bulk Upload purposes using + * the process_consent parameter. This requirement applies to all Bulk Upload + * requests. + * + * For user data that requires end-user consent as + * required by Google's EU User Consent Policy + * (see https://www.google.com/about/company/user-consent-policy/) or + * by other local laws, partners are required to obtain + * end-user consent and indicate gathered consent + * by setting process_consent=True. + * + * For user data which is not subject to end-user consent requirements, + * partners are required to indicate that consent is not + * required by setting process_consent=True. + * + * Requests where `process_consent` is missing will be filtered and + * return the following error: + * + * @generated from enum value: MISSING_CONSENT_WILL_BE_DROPPED = 22; + */ + MISSING_CONSENT_WILL_BE_DROPPED = 22, + + /** + * Requests where `process_consent` is set to `false` will be filtered and + * return the following error: + * + * @generated from enum value: MISSING_CONSENT = 23; + */ + MISSING_CONSENT = 23, + + /** + * Missing internal mapping. + * If operation is PARTNER_PROVIDED_ID, then this error means our mapping + * table does not contain corresponding google user id. This mapping is + * recorded during Cookie Matching. + * For other operations, then it may be internal error. + * + * @generated from enum value: UNKNOWN_ID = 21; + */ + UNKNOWN_ID = 21 +} + +/** + * Describes the enum ErrorCode. + */ +export const ErrorCodeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_bulk_upload, 3) diff --git a/packages/destination-actions/src/destinations/google-data-manager/shared.ts b/packages/destination-actions/src/destinations/google-data-manager/shared.ts index 6405dee5d6a..7a84a6d3ce2 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/shared.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/shared.ts @@ -12,6 +12,7 @@ import { UserDataOperationSchema } from './proto/protofile' import type { AudienceSettings } from './generated-types' +import { create, fromBinary, toBinary } from '@bufbuild/protobuf' type DV360AuthCredentials = { refresh_token: string; access_token: string; client_id: string; client_secret: string } @@ -53,8 +54,8 @@ export const buildHeaders = (audienceSettings: AudienceSettings | undefined, acc // @ts-ignore - TS doesn't know about the oauth property Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', - 'Login-Customer-Id': `products/DATA_PARTNER/customers/${SEGMENT_DMP_ID}`, - 'Linked-Customer-Id': `products/${audienceSettings.accountType}/customers/${audienceSettings?.advertiserId}` + 'Login-Customer-Id': `products/DATA_PARTNER/customers/${SEGMENT_DMP_ID}` + // 'Linked-Customer-Id': `products/${audienceSettings.accountType}/customers/${audienceSettings?.advertiserId}` } } From e8a1cd60e95977473c07af686d6103a25e1b9564 Mon Sep 17 00:00:00 2001 From: Arjun Bhandage Date: Wed, 18 Jun 2025 12:53:21 +0530 Subject: [PATCH 4/5] remove excess code and proto --- .../__tests__/index.test.ts | 59 +- .../google-data-manager/constants.ts | 3 +- .../destinations/google-data-manager/index.ts | 6 +- .../proto/bulk_upload.proto | 97 ---- .../google-data-manager/proto/protofile.ts | 531 ------------------ .../__snapshots__/snapshot.test.ts.snap | 5 - .../remove/__tests__/index.test.ts | 20 - .../remove/__tests__/snapshot.test.ts | 97 ---- .../remove/generated-types.ts | 3 - .../google-data-manager/remove/index.ts | 19 - .../google-data-manager/shared.ts | 171 +----- 11 files changed, 25 insertions(+), 986 deletions(-) delete mode 100644 packages/destination-actions/src/destinations/google-data-manager/proto/bulk_upload.proto delete mode 100644 packages/destination-actions/src/destinations/google-data-manager/proto/protofile.ts delete mode 100644 packages/destination-actions/src/destinations/google-data-manager/remove/__tests__/__snapshots__/snapshot.test.ts.snap delete mode 100644 packages/destination-actions/src/destinations/google-data-manager/remove/__tests__/index.test.ts delete mode 100644 packages/destination-actions/src/destinations/google-data-manager/remove/__tests__/snapshot.test.ts delete mode 100644 packages/destination-actions/src/destinations/google-data-manager/remove/generated-types.ts delete mode 100644 packages/destination-actions/src/destinations/google-data-manager/remove/index.ts diff --git a/packages/destination-actions/src/destinations/google-data-manager/__tests__/index.test.ts b/packages/destination-actions/src/destinations/google-data-manager/__tests__/index.test.ts index fe239d0de9b..feec6d2cb2e 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/__tests__/index.test.ts @@ -1,58 +1,11 @@ -import destination from '../index' +import nock from 'nock' -describe('Google Data Manager Destination', () => { - describe('authentication', () => { - it('should have oauth2 scheme', () => { - expect(destination.authentication?.scheme).toBe('oauth2') - }) - - it('should have a refreshAccessToken function', () => { - expect(typeof (destination.authentication && (destination.authentication as any).refreshAccessToken)).toBe( - 'function' - ) - }) - - it('refreshAccessToken should return accessToken from response', async () => { - const mockRequest = jest.fn().mockResolvedValue({ data: { access_token: 'abc123' } }) - // @ts-expect-error: refreshAccessToken is not on all auth types - const result = await destination.authentication?.refreshAccessToken?.(mockRequest, { - auth: { refreshToken: 'r', clientId: 'c', clientSecret: 's' } - }) - expect(result).toEqual({ accessToken: 'abc123' }) - }) - }) - - describe('extendRequest', () => { - it('should return headers with Bearer token', () => { - const auth = { accessToken: 'token123', refreshToken: 'r', clientId: 'c', clientSecret: 's' } - const req = destination.extendRequest?.({ auth }) - expect(req?.headers?.authorization).toBe('Bearer token123') - }) - }) - - describe('audienceConfig', () => { - it('should have mode type synced and full_audience_sync false', () => { - expect(destination.audienceConfig.mode.type).toBe('synced') - // @ts-expect-error: full_audience_sync may not exist on all mode types - expect(destination.audienceConfig.mode.full_audience_sync).toBe(false) - }) - - it('createAudience should return an object with externalId', async () => { - // @ts-expect-error: createAudience may not exist on all configs - const result = await destination.audienceConfig.createAudience?.({}, {}) - expect(result).toHaveProperty('externalId') - }) - - it('getAudience should return an object with externalId', async () => { - // @ts-expect-error: getAudience may not exist on all configs - const result = await destination.audienceConfig.getAudience?.({}, {}) - expect(result).toHaveProperty('externalId') - }) +describe('Google Data Manager - testAuthentication', () => { + afterEach(() => { + nock.cleanAll() }) - describe('onDelete', () => { - it('should be a function', () => { - expect(typeof destination.onDelete).toBe('function') - }) + it('should succeed with valid access token and productLink', async () => { + return }) }) diff --git a/packages/destination-actions/src/destinations/google-data-manager/constants.ts b/packages/destination-actions/src/destinations/google-data-manager/constants.ts index a9138d410cd..b922d0dac8b 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/constants.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/constants.ts @@ -5,5 +5,4 @@ export const BASE_URL = `https://audiencepartner.googleapis.com/${GOOGLE_API_VER export const CREATE_AUDIENCE_URL = `${BASE_URL}userLists:mutate` export const GET_AUDIENCE_URL = `${BASE_URL}audiencePartner:searchStream` export const OAUTH_URL = 'https://accounts.google.com/o/oauth2/token' -export const USER_UPLOAD_ENDPOINT = 'https://cm.g.doubleclick.net/upload?nid=segment' -export const SEGMENT_DMP_ID = '1663649500' +export const SEGMENT_DMP_ID = '8152223833' diff --git a/packages/destination-actions/src/destinations/google-data-manager/index.ts b/packages/destination-actions/src/destinations/google-data-manager/index.ts index 7397d2b6b66..429f073d3cd 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/index.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/index.ts @@ -50,7 +50,7 @@ const destination: AudienceDestinationDefinition = { // TODO: Test query to validate authentication to different products body: JSON.stringify({ - query: 'SELECT product_link.display_video_advertiser.display_video_advertiser FROM product_link ' + query: 'SELECT product_link.display_video_advertiser.display_video_advertiser FROM product_link' }) } ) @@ -138,7 +138,7 @@ const destination: AudienceDestinationDefinition = { try { const authToken = await getAuthToken(request, authSettings) response = await request(partnerCreateAudienceUrl, { - headers: buildHeaders(createAudienceInput.audienceSettings, authToken), + headers: buildHeaders(createAudienceInput.audienceSettings, createAudienceInput.settings, authToken), method: 'POST', json: { operations: [ @@ -195,7 +195,7 @@ const destination: AudienceDestinationDefinition = { try { const authToken = await getAuthToken(request, authSettings) const response = await request(advertiserGetAudienceUrl, { - headers: buildHeaders(audienceSettings, authToken), + headers: buildHeaders(audienceSettings, getAudienceInput.settings, authToken), method: 'POST', json: { query: `SELECT user_list.name, diff --git a/packages/destination-actions/src/destinations/google-data-manager/proto/bulk_upload.proto b/packages/destination-actions/src/destinations/google-data-manager/proto/bulk_upload.proto deleted file mode 100644 index 8254754e025..00000000000 --- a/packages/destination-actions/src/destinations/google-data-manager/proto/bulk_upload.proto +++ /dev/null @@ -1,97 +0,0 @@ -syntax = "proto3"; - -package google.ads.datamanager.v1; - -option go_package = "google.golang.org/genproto/googleapis/ads/datamanager/v1;datamanager"; -option java_multiple_files = true; -option java_outer_classname = "UserDataProto"; -option java_package = "com.google.ads.datamanager.v1"; - -// Operation to add or remove a user from user lists. -message UserDataOperation { - // The user identifier. - string user_id = 1; - // The type of user identifier. - UserIdType user_id_type = 2; - // The user list IDs to add or remove the user from. - repeated int64 user_list_ids = 3; - // The timestamp when the user was added to the list (seconds since epoch). - repeated int64 time_added_to_user_list = 4; - // Whether to remove the user from the list. - bool remove = 5; - // Whether the user has opted out. - bool opt_out = 6; - // The data source ID. - uint32 data_source_id = 7; -} - -// Request to update users in user lists. -message UpdateUsersDataRequest { - repeated UserDataOperation operations = 1; - bool send_notifications = 2; - bool process_consent = 3; -} - -// Error information for a user data operation. -message ErrorInfo { - repeated int64 user_list_ids = 1; - string user_id = 2; - UserIdType user_id_type = 3; - ErrorCode error_code = 4; -} - -// Notification information for a user data operation. -message NotificationInfo { - string user_id = 1; - NotificationCode notification_code = 2; -} - -// Response for updating users in user lists. -message UpdateUsersDataResponse { - ErrorCode status = 1; - repeated ErrorInfo errors = 2; - repeated NotificationInfo notifications = 3; - NotificationStatus notification_status = 4; -} - -enum UserIdType { - USER_ID_TYPE_UNSPECIFIED = 0; - GOOGLE_USER_ID = 1; - IDFA = 2; - ANDROID_ADVERTISING_ID = 3; - RIDA = 4; - AFAI = 5; - MSAID = 6; - GENERIC_DEVICE_ID = 7; - PARTNER_PROVIDED_ID = 8; -} - -enum NotificationCode { - NOTIFICATION_CODE_UNSPECIFIED = 0; - INACTIVE_COOKIE = 1; -} - -enum NotificationStatus { - NOTIFICATION_STATUS_UNSPECIFIED = 0; - NO_NOTIFICATION = 1; - NOTIFICATIONS_OMITTED = 2; -} - -enum ErrorCode { - ERROR_CODE_UNSPECIFIED = 0; - NO_ERROR = 1; - PARTIAL_SUCCESS = 2; - PERMISSION_DENIED = 3; - BAD_DATA = 4; - BAD_COOKIE = 5; - BAD_ATTRIBUTE_ID = 6; - BAD_NETWORK_ID = 7; - REQUEST_TOO_BIG = 8; - EMPTY_REQUEST = 9; - INTERNAL_ERROR = 10; - BAD_DATA_SOURCE_ID = 11; - BAD_TIMESTAMP = 12; - MISSING_CONSENT_WILL_BE_DROPPED = 13; - MISSING_CONSENT = 14; - UNKNOWN_ID = 15; -} diff --git a/packages/destination-actions/src/destinations/google-data-manager/proto/protofile.ts b/packages/destination-actions/src/destinations/google-data-manager/proto/protofile.ts deleted file mode 100644 index e3b8ffa97c7..00000000000 --- a/packages/destination-actions/src/destinations/google-data-manager/proto/protofile.ts +++ /dev/null @@ -1,531 +0,0 @@ -// @generated by protoc-gen-es v2.2.3 with parameter "target=ts" -// @generated from file bulk_upload.proto (syntax proto2) -/* eslint-disable */ - -import type { GenEnum, GenFile, GenMessage } from '@bufbuild/protobuf/codegenv1' -import { enumDesc, fileDesc, messageDesc } from '@bufbuild/protobuf/codegenv1' -import type { Message } from '@bufbuild/protobuf' - -/** - * Describes the file bulk_upload.proto. - */ -export const file_bulk_upload: GenFile = - /*@__PURE__*/ - fileDesc('xxxxx') - -/** - * Update data for a single user. - * - * @generated from message UserDataOperation - */ -export type UserDataOperation = Message<'UserDataOperation'> & { - /** - * User id. The type is determined by the user_id_type field. - * - * Must always be present. Specifies which user this operation applies to. - * - * @generated from field: optional string user_id = 1 [default = ""]; - */ - userId: string - - /** - * The type of the user id. - * - * @generated from field: optional UserIdType user_id_type = 14 [default = GOOGLE_USER_ID]; - */ - userIdType: UserIdType - - /** - * The id of the userlist. This can be retrieved from the AdX UI for AdX - * customers, the AdWords API for non-AdX customers, or through your Technical - * Account Manager. - * - * @generated from field: optional int64 user_list_id = 4 [default = 0]; - */ - userListId: bigint - - /** - * Optional time (seconds since the epoch) when the user performed an action - * causing them to be added to the list. Using the default value of 0 - * indicates that the current time on the server should be used. - * - * @generated from field: optional int64 time_added_to_user_list = 5 [default = 0]; - */ - timeAddedToUserList: bigint - - /** - * Same as time_added_to_user_list but with finer grained time resolution, in - * microseconds. If both timestamps are specified, - * time_added_to_user_list_in_usec will be used. - * - * @generated from field: optional int64 time_added_to_user_list_in_usec = 8 [default = 0]; - */ - timeAddedToUserListInUsec: bigint - - /** - * Set to true if the operation is a deletion. - * - * @generated from field: optional bool delete = 6 [default = false]; - */ - delete: boolean - - /** - * Set true if the user opted out from being targeted. - * - * @generated from field: optional bool opt_out = 12 [default = false]; - */ - optOut: boolean - - /** - * An id indicating the data source which contributed this membership. The id - * is required to be in the range of 1 to 1000 and any ids greater than this - * will result in an error of type BAD_DATA_SOURCE_ID. These ids don't have - * any semantics for Google and may be used as labels for reporting purposes. - * - * @generated from field: optional int32 data_source_id = 7 [default = 0]; - */ - dataSourceId: number -} - -/** - * Describes the message UserDataOperation. - * Use `create(UserDataOperationSchema)` to create a new message. - */ -export const UserDataOperationSchema: GenMessage = /*@__PURE__*/ messageDesc(file_bulk_upload, 0) - -/** - * This protocol buffer is used to update user data. It is sent as the payload - * of an HTTPS POST request with the Content-Type header set to - * "application/octet-stream" (preferrably Content-Encoding: gzip). - * - * @generated from message UpdateUsersDataRequest - */ -export type UpdateUsersDataRequest = Message<'UpdateUsersDataRequest'> & { - /** - * Multiple operations over user attributes or user lists. - * - * @generated from field: repeated UserDataOperation ops = 1; - */ - ops: UserDataOperation[] - - /** - * If true, request sending notifications about the given users in the - * response. Note that in some circumstances notifications may not be sent - * even if requested. In this case the notification_status field of the - * response will be set to NOTIFICATIONS_OMITTED. - * - * @generated from field: optional bool send_notifications = 2 [default = false]; - */ - sendNotifications: boolean - - /** - * Partners using the Bulk Upload API must indicate that they have the proper - * legal basis to share user data with Google for Bulk Upload purposes using - * the process_consent parameter. This requirement applies to all Bulk Upload - * requests. - * - * For user data that requires end-user consent as - * required by Google's EU User Consent Policy - * (see https://www.google.com/about/company/user-consent-policy/) or - * by other local laws, partners are required to obtain - * end-user consent and indicate gathered consent - * by setting process_consent=True. - * - * For user data which is not subject to end-user consent requirements, - * partners are required to indicate that consent is not - * required by setting process_consent=True. - * - * Requests without `process_consent=True` will be filtered. - * - * @generated from field: optional bool process_consent = 3 [default = false]; - */ - processConsent: boolean -} - -/** - * Describes the message UpdateUsersDataRequest. - * Use `create(UpdateUsersDataRequestSchema)` to create a new message. - */ -export const UpdateUsersDataRequestSchema: GenMessage = - /*@__PURE__*/ - messageDesc(file_bulk_upload, 1) - -/** - * Information about an error with an individual user operation. - * - * @generated from message ErrorInfo - */ -export type ErrorInfo = Message<'ErrorInfo'> & { - /** - * The user_list_id in the request which caused problems. This may be empty - * if the problem was with a particular user id. - * - * @generated from field: optional int64 user_list_id = 2 [default = 0]; - */ - userListId: bigint - - /** - * The user_id which caused problems. This may be empty if other data was bad - * regardless of a cookie. - * - * @generated from field: optional string user_id = 3 [default = ""]; - */ - userId: string - - /** - * The type of the user ID. - * - * @generated from field: optional UserIdType user_id_type = 7 [default = GOOGLE_USER_ID]; - */ - userIdType: UserIdType - - /** - * @generated from field: optional ErrorCode error_code = 4; - */ - errorCode: ErrorCode -} - -/** - * Describes the message ErrorInfo. - * Use `create(ErrorInfoSchema)` to create a new message. - */ -export const ErrorInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_bulk_upload, 2) - -/** - * Per user notification information. - * - * @generated from message NotificationInfo - */ -export type NotificationInfo = Message<'NotificationInfo'> & { - /** - * The user_id for which the notification applies. One of the user_ids sent - * in a UserDataOperation. - * - * @generated from field: optional string user_id = 1 [default = ""]; - */ - userId: string - - /** - * @generated from field: optional NotificationCode notification_code = 2; - */ - notificationCode: NotificationCode -} - -/** - * Describes the message NotificationInfo. - * Use `create(NotificationInfoSchema)` to create a new message. - */ -export const NotificationInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_bulk_upload, 3) - -/** - * Response to the UpdateUsersDataRequest. Sent in HTTP response to the - * original POST request, with the Content-Type header set to - * "application/octet-stream". The HTTP response status is either 200 (no - * errors) or 400, in which case the protocol buffer will provide error details. - * - * @generated from message UpdateUsersDataResponse - */ -export type UpdateUsersDataResponse = Message<'UpdateUsersDataResponse'> & { - /** - * When status == PARTIAL_SUCCESS, some (not all) of the operations failed and - * the "errors" field has details on the types and number of errors - * encountered. When status == NO_ERROR, all the data was imported - * successfully. When status > PARTIAL_SUCCESS no data was imported. - * - * @generated from field: optional ErrorCode status = 1; - */ - status: ErrorCode - - /** - * Each operation that failed is reported as a separate error here when - * status == PARTIAL_SUCCESS. - * - * @generated from field: repeated ErrorInfo errors = 2; - */ - errors: ErrorInfo[] - - /** - * Useful, non-error, information about the user ids in the request. Each - * NotificationInfo provides information about a single user id. Only sent if - * UpdateUsersDataRequest.send_notifications is set to true. - * - * @generated from field: repeated NotificationInfo notifications = 3; - */ - notifications: NotificationInfo[] - - /** - * Indicates why a notification has not been sent. - * - * @generated from field: optional NotificationStatus notification_status = 4; - */ - notificationStatus: NotificationStatus -} - -/** - * Describes the message UpdateUsersDataResponse. - * Use `create(UpdateUsersDataResponseSchema)` to create a new message. - */ -export const UpdateUsersDataResponseSchema: GenMessage = - /*@__PURE__*/ - messageDesc(file_bulk_upload, 4) - -/** - * The type of identifier being uploaded. - * - * @generated from enum UserIdType - */ -export enum UserIdType { - /** - * A user identifier received through the cookie matching service. - * - * @generated from enum value: GOOGLE_USER_ID = 0; - */ - GOOGLE_USER_ID = 0, - - /** - * iOS Advertising ID. - * - * @generated from enum value: IDFA = 1; - */ - IDFA = 1, - - /** - * Android Advertising ID. - * - * @generated from enum value: ANDROID_ADVERTISING_ID = 2; - */ - ANDROID_ADVERTISING_ID = 2, - - /** - * Roku ID. - * - * @generated from enum value: RIDA = 5; - */ - RIDA = 5, - - /** - * Amazon Fire TV ID. - * - * @generated from enum value: AFAI = 6; - */ - AFAI = 6, - - /** - * XBOX/Microsoft ID. - * - * @generated from enum value: MSAI = 7; - */ - MSAI = 7, - - /** - * A "generic" category for any UUID formatted device provided ID. - * Allows partner uploads without needing to select a specific, - * pre-existing Device ID type. - * - * @generated from enum value: GENERIC_DEVICE_ID = 9; - */ - GENERIC_DEVICE_ID = 9, - - /** - * Partner provided ID. User identifier in partner's namespace. - * If the partner has sent the partner user identifier during cookie matching, - * then Google will be able to store user list membership associated with - * the partner's user identifier. - * See cookie matching documentation: - * https://developers.google.com/authorized-buyers/rtb/cookie-guide - * - * @generated from enum value: PARTNER_PROVIDED_ID = 4; - */ - PARTNER_PROVIDED_ID = 4 -} - -/** - * Describes the enum UserIdType. - */ -export const UserIdTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_bulk_upload, 0) - -/** - * Notification code. - * - * @generated from enum NotificationCode - */ -export enum NotificationCode { - /** - * A cookie is considered inactive if Google has not seen any activity related - * to the cookie in several days. - * - * @generated from enum value: INACTIVE_COOKIE = 0; - */ - INACTIVE_COOKIE = 0 -} - -/** - * Describes the enum NotificationCode. - */ -export const NotificationCodeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_bulk_upload, 1) - -/** - * Notification status code. - * - * @generated from enum NotificationStatus - */ -export enum NotificationStatus { - /** - * No need to send notifications for this request. - * - * @generated from enum value: NO_NOTIFICATION = 0; - */ - NO_NOTIFICATION = 0, - - /** - * Google decided to not send notifications, even though there were - * notifications to send. - * - * @generated from enum value: NOTIFICATIONS_OMITTED = 1; - */ - NOTIFICATIONS_OMITTED = 1 -} - -/** - * Describes the enum NotificationStatus. - */ -export const NotificationStatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_bulk_upload, 2) - -/** - * Response error codes. - * - * @generated from enum ErrorCode - */ -export enum ErrorCode { - /** - * @generated from enum value: NO_ERROR = 0; - */ - NO_ERROR = 0, - - /** - * Some of the user data operations failed. See comments in the - * UpdateUserDataResponse - * - * @generated from enum value: PARTIAL_SUCCESS = 1; - */ - PARTIAL_SUCCESS = 1, - - /** - * Provided network_id cannot add data to attribute_id or non-HTTPS. - * - * @generated from enum value: PERMISSION_DENIED = 2; - */ - PERMISSION_DENIED = 2, - - /** - * Cannot parse payload. - * - * @generated from enum value: BAD_DATA = 3; - */ - BAD_DATA = 3, - - /** - * Cannot decode provided cookie. - * - * @generated from enum value: BAD_COOKIE = 4; - */ - BAD_COOKIE = 4, - - /** - * Invalid or closed user_list_id. - * - * @generated from enum value: BAD_ATTRIBUTE_ID = 5; - */ - BAD_ATTRIBUTE_ID = 5, - - /** - * An invalid nid parameter was provided in the request. - * - * @generated from enum value: BAD_NETWORK_ID = 7; - */ - BAD_NETWORK_ID = 7, - - /** - * Request payload size over allowed limit. - * - * @generated from enum value: REQUEST_TOO_BIG = 8; - */ - REQUEST_TOO_BIG = 8, - - /** - * No UserDataOperation messages in UpdateUsersDataRequest. - * - * @generated from enum value: EMPTY_REQUEST = 9; - */ - EMPTY_REQUEST = 9, - - /** - * The server could not process the request due to an internal error. Retrying - * the same request later is suggested. - * - * @generated from enum value: INTERNAL_ERROR = 10; - */ - INTERNAL_ERROR = 10, - - /** - * Bad data_source_id -- most likely out of range from [1, 1000]. - * - * @generated from enum value: BAD_DATA_SOURCE_ID = 11; - */ - BAD_DATA_SOURCE_ID = 11, - - /** - * The timestamp is a past/future time that is too far from current time. - * - * @generated from enum value: BAD_TIMESTAMP = 12; - */ - BAD_TIMESTAMP = 12, - - /** - * Partners using the Bulk Upload API must indicate that they have the proper - * legal basis to share user data with Google for Bulk Upload purposes using - * the process_consent parameter. This requirement applies to all Bulk Upload - * requests. - * - * For user data that requires end-user consent as - * required by Google's EU User Consent Policy - * (see https://www.google.com/about/company/user-consent-policy/) or - * by other local laws, partners are required to obtain - * end-user consent and indicate gathered consent - * by setting process_consent=True. - * - * For user data which is not subject to end-user consent requirements, - * partners are required to indicate that consent is not - * required by setting process_consent=True. - * - * Requests where `process_consent` is missing will be filtered and - * return the following error: - * - * @generated from enum value: MISSING_CONSENT_WILL_BE_DROPPED = 22; - */ - MISSING_CONSENT_WILL_BE_DROPPED = 22, - - /** - * Requests where `process_consent` is set to `false` will be filtered and - * return the following error: - * - * @generated from enum value: MISSING_CONSENT = 23; - */ - MISSING_CONSENT = 23, - - /** - * Missing internal mapping. - * If operation is PARTNER_PROVIDED_ID, then this error means our mapping - * table does not contain corresponding google user id. This mapping is - * recorded during Cookie Matching. - * For other operations, then it may be internal error. - * - * @generated from enum value: UNKNOWN_ID = 21; - */ - UNKNOWN_ID = 21 -} - -/** - * Describes the enum ErrorCode. - */ -export const ErrorCodeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_bulk_upload, 3) diff --git a/packages/destination-actions/src/destinations/google-data-manager/remove/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/google-data-manager/remove/__tests__/__snapshots__/snapshot.test.ts.snap deleted file mode 100644 index 5c7e8a4f2f2..00000000000 --- a/packages/destination-actions/src/destinations/google-data-manager/remove/__tests__/__snapshots__/snapshot.test.ts.snap +++ /dev/null @@ -1,5 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Testing snapshot for GoogleDataManager's remove destination action: all fields 1`] = `Array []`; - -exports[`Testing snapshot for GoogleDataManager's remove destination action: required fields 1`] = `Array []`; diff --git a/packages/destination-actions/src/destinations/google-data-manager/remove/__tests__/index.test.ts b/packages/destination-actions/src/destinations/google-data-manager/remove/__tests__/index.test.ts deleted file mode 100644 index 6f50e8ee0d9..00000000000 --- a/packages/destination-actions/src/destinations/google-data-manager/remove/__tests__/index.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { createTestIntegration } from '@segment/actions-core' -import Destination from '../../index' - -const testDestination = createTestIntegration(Destination) - -describe('GoogleDataManager.remove', () => { - it('should be defined', () => { - expect(testDestination).toBeDefined() - expect(testDestination.actions.remove).toBeDefined() - }) - - it('should call perform without error', async () => { - const action = testDestination.actions.remove - const perform = action.definition.perform - expect(typeof perform).toBe('function') - await expect(perform({} as any, { payload: {}, settings: {} } as any)).resolves.toBeUndefined() - }) - - // TODO: Add more tests when the action is implemented -}) diff --git a/packages/destination-actions/src/destinations/google-data-manager/remove/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/google-data-manager/remove/__tests__/snapshot.test.ts deleted file mode 100644 index d954827e719..00000000000 --- a/packages/destination-actions/src/destinations/google-data-manager/remove/__tests__/snapshot.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { createTestEvent, createTestIntegration } from '@segment/actions-core' -import { generateTestData } from '../../../../lib/test-data' -import destination from '../../index' -import nock from 'nock' - -const testDestination = createTestIntegration(destination) -const actionSlug = 'remove' -const destinationSlug = 'GoogleDataManager' -const seedName = `${destinationSlug}#${actionSlug}` - -describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { - it('required fields', async () => { - const action = destination.actions[actionSlug] - const [eventData, settingsData] = generateTestData(seedName, destination, action, true) - - nock(/.*!/).persist().get(/.*!/).reply(200) - nock(/.*!/).persist().post(/.*!/).reply(200) - nock(/.*!/).persist().put(/.*!/).reply(200) - - const event = createTestEvent({ - properties: eventData - }) - - const responses = await testDestination.testAction(actionSlug, { - event: event, - mapping: event.properties, - settings: settingsData, - auth: undefined - }) - - // Defensive: handle case where no request is made - if (!responses[0] || !responses[0].request) { - expect(responses).toMatchSnapshot() - return - } - - const request = responses[0].request - let rawBody = '' - try { - rawBody = await request.text() - } catch (e) { - rawBody = '' - } - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } - - expect(request.headers).toMatchSnapshot() - }) - - it('all fields', async () => { - const action = destination.actions[actionSlug] - const [eventData, settingsData] = generateTestData(seedName, destination, action, false) - - nock(/.*!/).persist().get(/.*!/).reply(200) - nock(/.*!/).persist().post(/.*!/).reply(200) - nock(/.*!/).persist().put(/.*!/).reply(200) - - const event = createTestEvent({ - properties: eventData - }) - - const responses = await testDestination.testAction(actionSlug, { - event: event, - mapping: event.properties, - settings: settingsData, - auth: undefined - }) - - // Defensive: handle case where no request is made - if (!responses[0] || !responses[0].request) { - expect(responses).toMatchSnapshot() - return - } - - const request = responses[0].request - let rawBody = '' - try { - rawBody = await request.text() - } catch (e) { - rawBody = '' - } - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } - }) -}) diff --git a/packages/destination-actions/src/destinations/google-data-manager/remove/generated-types.ts b/packages/destination-actions/src/destinations/google-data-manager/remove/generated-types.ts deleted file mode 100644 index 944d22b0857..00000000000 --- a/packages/destination-actions/src/destinations/google-data-manager/remove/generated-types.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Generated file. DO NOT MODIFY IT BY HAND. - -export interface Payload {} diff --git a/packages/destination-actions/src/destinations/google-data-manager/remove/index.ts b/packages/destination-actions/src/destinations/google-data-manager/remove/index.ts deleted file mode 100644 index 0aac96a23fb..00000000000 --- a/packages/destination-actions/src/destinations/google-data-manager/remove/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ActionDefinition } from '@segment/actions-core' -import type { Settings } from '../generated-types' -import type { Payload } from './generated-types' - -const action: ActionDefinition = { - title: 'Remove', - description: 'Remove data from Google Data Manager.', - fields: {}, - perform: async (_request, _data) => { - // Make your partner api request here! - // return request('https://example.com', { - // method: 'post', - // json: data.payload - // }) - return - } -} - -export default action diff --git a/packages/destination-actions/src/destinations/google-data-manager/shared.ts b/packages/destination-actions/src/destinations/google-data-manager/shared.ts index 7a84a6d3ce2..9c77007991b 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/shared.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/shared.ts @@ -1,33 +1,23 @@ -import { HTTPError, IntegrationError, RequestClient, StatsContext } from '@segment/actions-core' -import { OAUTH_URL, SEGMENT_DMP_ID, USER_UPLOAD_ENDPOINT } from './constants' +import { IntegrationError, RequestClient } from '@segment/actions-core' +import { OAUTH_URL, SEGMENT_DMP_ID } from './constants' import type { RefreshTokenResponse } from './types' -import { ListOperation, UpdateHandlerPayload, UserOperation } from './types' -import { - ErrorCode, - UpdateUsersDataRequest, - UpdateUsersDataRequestSchema, - UpdateUsersDataResponse, - UpdateUsersDataResponseSchema, - UserDataOperationSchema -} from './proto/protofile' -import type { AudienceSettings } from './generated-types' -import { create, fromBinary, toBinary } from '@bufbuild/protobuf' +import type { AudienceSettings, Settings } from './generated-types' -type DV360AuthCredentials = { refresh_token: string; access_token: string; client_id: string; client_secret: string } +type AuthCredentials = { refresh_token: string; access_token: string; client_id: string; client_secret: string } -export const getAuthSettings = (): DV360AuthCredentials => { +export const getAuthSettings = (): AuthCredentials => { return { refresh_token: process.env.ACTIONS_DISPLAY_VIDEO_360_REFRESH_TOKEN, client_id: process.env.ACTIONS_DISPLAY_VIDEO_360_CLIENT_ID, client_secret: process.env.ACTIONS_DISPLAY_VIDEO_360_CLIENT_SECRET - } as DV360AuthCredentials + } as AuthCredentials } // Use the refresh token to get a new access token. // Refresh tokens, Client_id and secret are long-lived and belong to the DMP. // Given the short expiration time of access tokens, we need to refresh them periodically. -export const getAuthToken = async (request: RequestClient, settings: DV360AuthCredentials) => { +export const getAuthToken = async (request: RequestClient, settings: AuthCredentials) => { if (!settings.refresh_token) { throw new IntegrationError('Refresh token is missing', 'INVALID_REQUEST_DATA', 400) } @@ -45,8 +35,12 @@ export const getAuthToken = async (request: RequestClient, settings: DV360AuthCr return data.access_token } -export const buildHeaders = (audienceSettings: AudienceSettings | undefined, accessToken: string) => { - if (!audienceSettings || !accessToken) { +export const buildHeaders = ( + audienceSettings: AudienceSettings | undefined, + settings: Settings | undefined, + accessToken: string +) => { + if (!audienceSettings || !accessToken || !settings) { throw new IntegrationError('Bad Request', 'INVALID_REQUEST_DATA', 400) } @@ -54,142 +48,7 @@ export const buildHeaders = (audienceSettings: AudienceSettings | undefined, acc // @ts-ignore - TS doesn't know about the oauth property Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', - 'Login-Customer-Id': `products/DATA_PARTNER/customers/${SEGMENT_DMP_ID}` - // 'Linked-Customer-Id': `products/${audienceSettings.accountType}/customers/${audienceSettings?.advertiserId}` - } -} - -export const assembleRawOps = (_payload: UpdateHandlerPayload, _operation: ListOperation): UserOperation[] => { - return [] -} - -const handleErrorCode = ( - errorCodeString: string, - r: UpdateUsersDataResponse, - statsName: string, - statsContext: StatsContext | undefined -) => { - if (errorCodeString === 'PARTIAL_SUCCESS') { - statsContext?.statsClient.incr(`${statsName}.error.PARTIAL_SUCCESS`, 1, statsContext?.tags) - r.errors?.forEach((e) => { - if (e.errorCode) { - statsContext?.statsClient.incr(`${statsName}.error.${ErrorCode[e.errorCode]}`, 1, statsContext?.tags) - } - }) - } else { - statsContext?.statsClient.incr(`${statsName}.error.${errorCodeString}`, 1, statsContext?.tags) - } -} - -export const bulkUploaderResponseHandler = async ( - response: Response, - statsName: string, - statsContext: StatsContext | undefined -) => { - if (!response || !response.body) { - throw new IntegrationError(`Something went wrong unpacking the protobuf response`, 'INVALID_REQUEST_DATA', 400) - } - - const buffer = await response.arrayBuffer() - const protobufResponse = Buffer.from(buffer) - - const r = fromBinary(UpdateUsersDataResponseSchema, protobufResponse) - const errorCode = r.status - const errorCodeString = ErrorCode[errorCode] || 'UNKNOWN_ERROR' - - if (errorCodeString === 'NO_ERROR' || response.status === 200) { - statsContext?.statsClient.incr(`${statsName}.success`, 1, statsContext?.tags) - } else { - handleErrorCode(errorCodeString, r, statsName, statsContext) - // Only internal errors shall be retried as they imply a temporary issue. - // The rest of the errors are permanent and shall be discarded. - // This emulates the legacy behavior of the DV360 destination. - if (errorCode === ErrorCode.INTERNAL_ERROR) { - statsContext?.statsClient.incr(`${statsName}.error.INTERNAL_ERROR`, 1, statsContext?.tags) - throw new IntegrationError('Bulk Uploader Internal Error', 'INTERNAL_SERVER_ERROR', 500) - } - } -} - -// To interact with the bulk uploader, we need to create a protobuf object as defined in the proto file. -// This method takes the raw payload and creates the protobuf object. -export const createUpdateRequest = ( - payload: UpdateHandlerPayload[], - operation: 'add' | 'remove' -): UpdateUsersDataRequest => { - const updateRequest = create(UpdateUsersDataRequestSchema, {}) - - payload.forEach((p) => { - const rawOps = assembleRawOps(p, operation) - - // Every ID will generate an operation. - // That means that if google_gid, mobile_advertising_id, and anonymous_id are all present, we will create 3 operations. - // This emulates the legacy behavior of the DV360 destination. - rawOps.forEach((rawOp) => { - const op = create(UserDataOperationSchema, { - userId: rawOp.UserId, - userIdType: rawOp.UserIdType, - userListId: BigInt(rawOp.UserListId), - delete: rawOp.Delete - }) - - if (!op) { - throw new Error('Unable to create UserDataOperation') - } - - updateRequest.ops.push(op) - }) - }) - - // Backed by deletion and suppression features in Segment. - updateRequest.processConsent = true - - return updateRequest -} - -export const sendUpdateRequest = async ( - request: RequestClient, - updateRequest: UpdateUsersDataRequest, - statsName: string, - statsContext: StatsContext | undefined -) => { - const binaryOperation = toBinary(UpdateUsersDataRequestSchema, updateRequest) - - try { - const response = await request(USER_UPLOAD_ENDPOINT, { - headers: { 'Content-Type': 'application/octet-stream' }, - body: binaryOperation, - method: 'POST' - }) - - await bulkUploaderResponseHandler(response, statsName, statsContext) - } catch (error) { - if ((error as HTTPError).response?.status === 500) { - throw new IntegrationError(error.response?.message ?? (error as HTTPError).message, 'INTERNAL_SERVER_ERROR', 500) - } - - await bulkUploaderResponseHandler((error as HTTPError).response, statsName, statsContext) - } -} - -export const handleUpdate = async ( - request: RequestClient, - payload: UpdateHandlerPayload[], - operation: 'add' | 'remove', - statsContext: StatsContext | undefined -) => { - const statsName = operation === 'add' ? 'addToAudience' : 'removeFromAudience' - statsContext?.statsClient?.incr(`${statsName}.call`, 1, statsContext?.tags) - - const updateRequest = createUpdateRequest(payload, operation) - - if (updateRequest.ops.length !== 0) { - await sendUpdateRequest(request, updateRequest, statsName, statsContext) - } else { - statsContext?.statsClient.incr(`${statsName}.discard`, 1, statsContext?.tags) - } - - return { - status: 200 + 'login-customer-Id': `products/DATA_PARTNER/customers/${SEGMENT_DMP_ID}`, // this is the Segment account id + 'linked-customer-id': settings?.productLink } } From 951a88c3e730a6731b9760f909304f51b15c41c7 Mon Sep 17 00:00:00 2001 From: Arjun Bhandage Date: Mon, 23 Jun 2025 11:10:02 +0530 Subject: [PATCH 5/5] testing google-data-manager --- .../src/destinations/google-data-manager/generated-types.ts | 2 +- .../src/destinations/google-data-manager/index.ts | 1 - .../src/destinations/google-data-manager/ingest/index.ts | 3 ++- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts b/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts index 86eb33bd625..11a1eb844ab 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/generated-types.ts @@ -20,7 +20,7 @@ export interface AudienceSettings { /** * The product for which you want to create or manage audiences. */ - product: string[] + product: string /** * The ID of the product destination, used to identify the specific destination for audience management. */ diff --git a/packages/destination-actions/src/destinations/google-data-manager/index.ts b/packages/destination-actions/src/destinations/google-data-manager/index.ts index 429f073d3cd..5060f705641 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/index.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/index.ts @@ -248,7 +248,6 @@ const destination: AudienceDestinationDefinition = { label: 'Product', description: 'The product for which you want to create or manage audiences.', type: 'string', - multiple: true, required: true, choices: [ { label: 'Google Ads', value: 'GOOGLE_ADS' }, diff --git a/packages/destination-actions/src/destinations/google-data-manager/ingest/index.ts b/packages/destination-actions/src/destinations/google-data-manager/ingest/index.ts index ae3b1a95091..960388cfca4 100644 --- a/packages/destination-actions/src/destinations/google-data-manager/ingest/index.ts +++ b/packages/destination-actions/src/destinations/google-data-manager/ingest/index.ts @@ -1,6 +1,7 @@ import { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' +import { SEGMENT_DMP_ID } from '../constants' const action: ActionDefinition = { title: 'Ingest', @@ -116,7 +117,7 @@ const action: ActionDefinition = { product: audienceSettings.product // TODO: add multiple entries for different products }, loginAccount: { - accountId: '8172370552', // segment id + accountId: `${SEGMENT_DMP_ID}`, // segment id product: 'DATA_PARTNER' }, productDestinationId: audienceSettings.productDestinationId