diff --git a/packages/core/src/flags.ts b/packages/core/src/flags.ts index 1f9e280db37..afd5bf7c981 100644 --- a/packages/core/src/flags.ts +++ b/packages/core/src/flags.ts @@ -2,5 +2,6 @@ export const FLAGS = { ACTIONS_GOOGLE_EC_AUDIENCE_MEMBERSHIP: 'actions-google-ec-audience-membership', ACTIONS_BRAZE_COHORTS_AUDIENCE_MEMBERSHIP: 'actions-braze-cohorts-audience-membership', - ACTIONS_LINKEDIN_AUDIENCES_AUDIENCE_MEMBERSHIP: 'actions-linkedin-audiences-audience-membership' + ACTIONS_LINKEDIN_AUDIENCES_AUDIENCE_MEMBERSHIP: 'actions-linkedin-audiences-audience-membership', + ACTIONS_TIKTOK_AUDIENCES_AUDIENCE_MEMBERSHIP: 'actions-tiktok-audiences-audience-membership' } \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/functions.ts b/packages/destination-actions/src/destinations/tiktok-audiences/functions.ts index 780111b9b66..cc6259138f8 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/functions.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/functions.ts @@ -99,9 +99,9 @@ export function getIDSchema(payload: GenericPayload): string[] { return id_schema } -const isHashedInformation = (information: string): boolean => new RegExp(/[0-9abcdef]{64}/gi).test(information) +export const isHashedInformation = (information: string): boolean => new RegExp(/[0-9abcdef]{64}/gi).test(information) -const hash = (value: string): string => { +export const hash = (value: string): string => { return processHashing(value, 'sha256', 'hex') } diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/index.ts b/packages/destination-actions/src/destinations/tiktok-audiences/index.ts index 88cb83ad408..e6b359ae837 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/index.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/index.ts @@ -14,6 +14,8 @@ import addToAudience from './addToAudience' import removeFromAudience from './removeFromAudience' +import syncAudience from './syncAudience' + const destination: AudienceDestinationDefinition = { name: 'TikTok Audiences', slug: 'actions-tiktok-audiences', @@ -160,6 +162,7 @@ const destination: AudienceDestinationDefinition = { } }, actions: { + syncAudience, addToAudience, removeFromAudience, addUser, diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/syncAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/tiktok-audiences/syncAudience/__tests__/index.test.ts new file mode 100644 index 00000000000..da03a740615 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-audiences/syncAudience/__tests__/index.test.ts @@ -0,0 +1,262 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { BASE_URL } from '../../constants' +import { TIKTOK_AUDIENCES_API_VERSION } from '../../versioning-info' + +const testDestination = createTestIntegration(Destination) + +interface AuthTokens { + accessToken: string + refreshToken: string +} + +const auth: AuthTokens = { + accessToken: 'test', + refreshToken: 'test' +} + +const EXTERNAL_AUDIENCE_ID = '12345' +const ADVERTISER_ID = '123' // References audienceSettings.advertiserId +const ADVERTISING_ID = '4242' // References device.advertisingId +const ID_TYPE = 'EMAIL_SHA256' // References audienceSettings.idType + +const event = createTestEvent({ + event: 'Audience Entered', + type: 'track', + properties: {}, + context: { + device: { + advertisingId: ADVERTISING_ID + }, + traits: { + email: 'testing@testing.com', + phone: '1234567890' + }, + personas: { + audience_settings: { + advertiserId: ADVERTISER_ID, + idType: ID_TYPE + }, + external_audience_id: EXTERNAL_AUDIENCE_ID + } + } +}) + +const updateUsersRequestBody = { + id_schema: ['EMAIL_SHA256', 'PHONE_SHA256', 'IDFA_SHA256'], + advertiser_ids: [ADVERTISER_ID], + action: 'add', + batch_data: [ + [ + { + id: '584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777', + audience_ids: [EXTERNAL_AUDIENCE_ID] + }, + { + id: 'c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646', + audience_ids: [EXTERNAL_AUDIENCE_ID] + }, + { + id: '0315b4020af3eccab7706679580ac87a710d82970733b8719e70af9b57e7b9e6', + audience_ids: [EXTERNAL_AUDIENCE_ID] + } + ] + ] +} + +describe('TiktokAudiences.addToAudience', () => { + it('should succeed if audience id is valid', async () => { + nock(`${BASE_URL}${TIKTOK_AUDIENCES_API_VERSION}`).post('/segment/mapping/', updateUsersRequestBody).reply(200) + + const r = await testDestination.testAction('addToAudience', { + auth, + event, + settings: {}, + useDefaultMappings: true, + mapping: { + send_advertising_id: true + } + }) + + expect(r[0].status).toEqual(200) + expect(r[0].options.body).toMatchInlineSnapshot( + `"{\\"advertiser_ids\\":[\\"123\\"],\\"action\\":\\"add\\",\\"id_schema\\":[\\"EMAIL_SHA256\\",\\"PHONE_SHA256\\",\\"IDFA_SHA256\\"],\\"batch_data\\":[[{\\"id\\":\\"584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777\\",\\"audience_ids\\":[\\"12345\\"]},{\\"id\\":\\"c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646\\",\\"audience_ids\\":[\\"12345\\"]},{\\"id\\":\\"0315b4020af3eccab7706679580ac87a710d82970733b8719e70af9b57e7b9e6\\",\\"audience_ids\\":[\\"12345\\"]}]]}"` + ) + }) + + it('should normalize and hash emails correctly', async () => { + nock(`${BASE_URL}${TIKTOK_AUDIENCES_API_VERSION}`) + .post('/segment/mapping/', { + advertiser_ids: ['123'], + action: 'add', + id_schema: ['EMAIL_SHA256'], + batch_data: [ + [ + { + id: '584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777', + audience_ids: [EXTERNAL_AUDIENCE_ID] + } + ] + ] + }) + .reply(200) + + const responses = await testDestination.testAction('addToAudience', { + event, + settings: { + advertiser_ids: ['123'] + }, + useDefaultMappings: true, + auth, + mapping: { + send_advertising_id: false, + send_phone: false + } + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"advertiser_ids\\":[\\"123\\"],\\"action\\":\\"add\\",\\"id_schema\\":[\\"EMAIL_SHA256\\"],\\"batch_data\\":[[{\\"id\\":\\"584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777\\",\\"audience_ids\\":[\\"12345\\"]}]]}"` + ) + }) + + it('should normalize and hash phone correctly', async () => { + nock(`${BASE_URL}${TIKTOK_AUDIENCES_API_VERSION}`) + .post('/segment/mapping/', { + advertiser_ids: ['123'], + action: 'add', + id_schema: ['PHONE_SHA256'], + batch_data: [ + [ + { + id: 'c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646', + audience_ids: [EXTERNAL_AUDIENCE_ID] + } + ] + ] + }) + .reply(200) + + const responses = await testDestination.testAction('addToAudience', { + event, + settings: { + advertiser_ids: ['123'] + }, + useDefaultMappings: true, + auth, + mapping: { + send_advertising_id: false, + send_email: false + } + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"advertiser_ids\\":[\\"123\\"],\\"action\\":\\"add\\",\\"id_schema\\":[\\"PHONE_SHA256\\"],\\"batch_data\\":[[{\\"id\\":\\"c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646\\",\\"audience_ids\\":[\\"12345\\"]}]]}"` + ) + }) + + it('should fail if an audience id is invalid', async () => { + const anotherEvent = createTestEvent({ + event: 'Audience Entered', + type: 'track', + properties: { + audience_key: 'personas_test_audience' + }, + context: { + device: { + advertisingId: ADVERTISING_ID + }, + traits: { + email: 'testing@testing.com', + phone: '1234567890' + }, + personas: { + audience_settings: { + advertiserId: ADVERTISER_ID, + idType: ID_TYPE + }, + external_audience_id: 'THIS_ISNT_REAL' + } + } + }) + + nock(`${BASE_URL}${TIKTOK_AUDIENCES_API_VERSION}/segment/mapping/`) + .post(/.*/, { + id_schema: ['EMAIL_SHA256', 'PHONE_SHA256', 'IDFA_SHA256'], + advertiser_ids: [ADVERTISER_ID], + action: 'add', + batch_data: [ + [ + { + id: '584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777', + audience_ids: ['THIS_ISNT_REAL'] + }, + { + id: 'c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646', + audience_ids: ['THIS_ISNT_REAL'] + }, + { + id: '0315b4020af3eccab7706679580ac87a710d82970733b8719e70af9b57e7b9e6', + audience_ids: ['THIS_ISNT_REAL'] + } + ] + ] + }) + .reply(400) + + const r = await testDestination.testAction('addToAudience', { + event: anotherEvent, + settings: { + advertiser_ids: ['123'] + }, + useDefaultMappings: true, + auth, + mapping: {} + }) + + expect(r[0].status).toEqual(400) + }) + + it('should fail if all the send fields are false', async () => { + nock(`${BASE_URL}${TIKTOK_AUDIENCES_API_VERSION}/segment/mapping/`).post(/.*/, updateUsersRequestBody).reply(200) + + await expect( + testDestination.testAction('addToAudience', { + event, + settings: { + advertiser_ids: ['123'] + }, + useDefaultMappings: true, + auth, + mapping: { + selected_advertiser_id: '123', + audience_id: '123456', + send_email: false, + send_advertising_id: false, + send_phone: false + } + }) + ).rejects.toThrow('At least one of `Send Email`, `Send Phone` or `Send Advertising ID` must be set to `true`.') + }) + it('should fail if email and/or advertising_id is not in the payload', async () => { + nock(`${BASE_URL}${TIKTOK_AUDIENCES_API_VERSION}/segment/mapping/`).post(/.*/, updateUsersRequestBody).reply(400) + + delete event?.context?.device + delete event?.context?.traits + + await expect( + testDestination.testAction('addToAudience', { + event, + settings: { + advertiser_ids: ['123'] + }, + useDefaultMappings: true, + auth, + mapping: { + send_email: true, + send_advertising_id: true + } + }) + ).rejects.toThrowError('At least one of Email Id or Advertising ID must be provided.') + }) +}) diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/syncAudience/functions.ts b/packages/destination-actions/src/destinations/tiktok-audiences/syncAudience/functions.ts new file mode 100644 index 00000000000..491066e6ca1 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-audiences/syncAudience/functions.ts @@ -0,0 +1,297 @@ +import { + RequestClient, + MultiStatusResponse, + ModifiedResponse, + AudienceMembership, + JSONLikeObject, + PayloadValidationError +} from '@segment/actions-core' +import { TikTokAudiences } from '../api' +import { AudienceSettings } from '../generated-types' +import { Payload } from './generated-types' +import { getIDSchema, isHashedInformation, hash } from '../functions' +import { TikTokAudienceAction } from './types' + +export async function send( + request: RequestClient, + payloads: Payload[], + audienceSettings?: AudienceSettings, + audienceMembership?: AudienceMembership[], + isBatch?: boolean +) { + const multiStatusResponse = new MultiStatusResponse() + + if (!audienceSettings) { + return returnAllErrors(multiStatusResponse, payloads, 400, 'Bad Request: no audienceSettings found.', isBatch) + } + + if (!Array.isArray(audienceMembership) || audienceMembership.length !== payloads.length) { + return returnAllErrors( + multiStatusResponse, + payloads, + 400, + 'Audience Memberships must be an array with the same length as payloads.', + isBatch + ) + } + + const addMap = new Map() + const deleteMap = new Map() + + payloads.forEach((p, i) => { + const membership = audienceMembership[i] + if (!validate(p, multiStatusResponse, i, membership, isBatch)) { + return + } + + if (membership === true) { + addMap.set(i, p) + } else { + deleteMap.set(i, p) + } + }) + + const requests: Promise[] = [] + + if (addMap.size > 0) { + requests.push( + isBatch + ? sendAndCollectResponses(request, audienceSettings, addMap, 'add', multiStatusResponse) + : sendRequest(request, audienceSettings, addMap, 'add') + ) + } + + if (deleteMap.size > 0) { + requests.push( + isBatch + ? sendAndCollectResponses(request, audienceSettings, deleteMap, 'delete', multiStatusResponse) + : sendRequest(request, audienceSettings, deleteMap, 'delete') + ) + } + + const responses = await Promise.all(requests) + + if (!isBatch) { + return responses[0] + } + + return multiStatusResponse +} + +export async function sendRequest( + request: RequestClient, + audienceSettings: AudienceSettings, + payloadMap: Map, + action: TikTokAudienceAction +): Promise { + const payloads = Array.from(payloadMap.values()) + const advertiserId = audienceSettings.advertiserId + const idSchema = getIDSchema(payloads[0]) + const batchData = extractUsers(payloads) + const TikTokApiClient = new TikTokAudiences(request, advertiserId) + + return TikTokApiClient.batchUpdate({ + advertiser_ids: [advertiserId], + action, + id_schema: idSchema, + batch_data: batchData + }) +} + +export async function sendAndCollectResponses( + request: RequestClient, + audienceSettings: AudienceSettings, + payloadMap: Map, + action: TikTokAudienceAction, + multiStatusResponse: MultiStatusResponse +): Promise { + const payloads = Array.from(payloadMap.values()) + + if (payloads.length === 0) { + return + } + + const advertiserId = audienceSettings.advertiserId + const idSchema = getIDSchema(payloads[0]) + const batchData = extractUsers(payloads) + + try { + const TikTokApiClient = new TikTokAudiences(request, advertiserId) + + await TikTokApiClient.batchUpdate({ + advertiser_ids: [advertiserId], + action, + id_schema: idSchema, + batch_data: batchData + }) + + for (const [index, p] of payloadMap) { + if (!multiStatusResponse.getResponseAtIndex(index)) { + multiStatusResponse.setSuccessResponseAtIndex(index, { + status: 200, + sent: { + action, + advertiser_ids: [advertiserId], + id_schema: idSchema, + batch_data: extractUsers([p]) + } as unknown as JSONLikeObject, + body: p as unknown as JSONLikeObject + }) + } + } + } catch (err) { + const error = err as { message?: string; response?: { status?: number } } + const status = error.response?.status ?? 500 + const message = error.message ?? 'Unknown error' + + for (const [index, p] of payloadMap) { + if (!multiStatusResponse.getResponseAtIndex(index)) { + multiStatusResponse.setErrorResponseAtIndex(index, { + status, + errormessage: message, + sent: { + action, + advertiser_ids: [advertiserId], + id_schema: idSchema, + batch_data: extractUsers([p]) + } as unknown as JSONLikeObject, + body: p as unknown as JSONLikeObject + }) + } + } + } +} + +function returnAllErrors( + multiStatusResponse: MultiStatusResponse, + payloads: Payload[], + status: number, + message: string, + isBatch?: boolean +): never | MultiStatusResponse { + if (!isBatch) { + throw new PayloadValidationError(message) + } + payloads.forEach((p, i) => { + multiStatusResponse.setErrorResponseAtIndex(i, { + status, + errormessage: message, + body: p as unknown as JSONLikeObject + }) + }) + return multiStatusResponse +} + +function handleValidationError( + multiStatusResponse: MultiStatusResponse, + index: number, + payload: Payload, + message: string, + isBatch?: boolean +): never | false { + if (!isBatch) { + throw new PayloadValidationError(message) + } + multiStatusResponse.setErrorResponseAtIndex(index, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: message, + body: payload as unknown as JSONLikeObject + }) + return false +} + +export function validate( + payload: Payload, + multiStatusResponse: MultiStatusResponse, + index: number, + membership: boolean | undefined, + isBatch?: boolean +): boolean { + if (membership !== true && membership !== false) { + return handleValidationError( + multiStatusResponse, + index, + payload, + 'Audience membership value must be a boolean.', + isBatch + ) + } + + const { email, phone, advertising_id, send_email, send_phone, send_advertising_id } = payload + + if (!send_email && !send_phone && !send_advertising_id) { + return handleValidationError( + multiStatusResponse, + index, + payload, + 'At least one of `Send Email`, `Send Phone` or `Send Advertising ID` must be set to `true`.', + isBatch + ) + } + + const hasEnabledIdentifier = (send_email && email) || (send_phone && phone) || (send_advertising_id && advertising_id) + + if (!hasEnabledIdentifier) { + return handleValidationError( + multiStatusResponse, + index, + payload, + 'At least one enabled identifier (Email, Phone, or Advertising ID) must have a value.', + isBatch + ) + } + + return true +} + +/* + * Kept the same logic as before for extracting users + */ +export function extractUsers(payloads: Payload[]): Record[][] { + const batchData: Record[][] = [] + + for (const payload of payloads) { + const userIds: Record[] = [] + const audienceIds = [payload.external_audience_id] + + if (payload.send_email) { + if (payload.email) { + const normalized = normalizeEmail(payload.email) + userIds.push({ id: isHashedInformation(normalized) ? normalized : hash(normalized), audience_ids: audienceIds }) + } else { + userIds.push({}) + } + } + + if (payload.send_phone) { + if (payload.phone) { + userIds.push({ + id: isHashedInformation(payload.phone) ? payload.phone : hash(payload.phone), + audience_ids: audienceIds + }) + } else { + userIds.push({}) + } + } + + if (payload.send_advertising_id) { + if (payload.advertising_id) { + userIds.push({ id: hash(payload.advertising_id), audience_ids: audienceIds }) + } else { + userIds.push({}) + } + } + + batchData.push(userIds) + } + + return batchData +} + +export function normalizeEmail(email: string): string { + return email + .replace(/\+.*@/, '@') + .replace(/\.(?=.*@)/g, '') + .toLowerCase() +} \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/syncAudience/generated-types.ts b/packages/destination-actions/src/destinations/tiktok-audiences/syncAudience/generated-types.ts new file mode 100644 index 00000000000..c2c84d97543 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-audiences/syncAudience/generated-types.ts @@ -0,0 +1,44 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The user's email address to send to TikTok. + */ + email?: string + /** + * The user's phone number to send to TikTok. + */ + phone?: string + /** + * The user's mobile advertising ID to send to TikTok. This could be a GAID, IDFA, or AAID + */ + advertising_id?: string + /** + * Send email to TikTok. Segment will hash this value before sending + */ + send_email?: boolean + /** + * Send phone number to TikTok. Segment will hash this value before sending + */ + send_phone?: boolean + /** + * Send mobile advertising ID (IDFA, AAID or GAID) to TikTok. Segment will hash this value before sending. + */ + send_advertising_id?: boolean + /** + * The name of the current Segment event. + */ + event_name?: string + /** + * Enable batching of requests to the TikTok Audiences. + */ + enable_batching?: boolean + /** + * The keys to use for batching the events. + */ + batch_keys?: string[] + /** + * The Audience ID in TikTok's DB. + */ + external_audience_id?: string +} diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/syncAudience/index.ts b/packages/destination-actions/src/destinations/tiktok-audiences/syncAudience/index.ts new file mode 100644 index 00000000000..ca0b70dd443 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-audiences/syncAudience/index.ts @@ -0,0 +1,48 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings, AudienceSettings } from '../generated-types' +import type { Payload } from './generated-types' +import { send } from './functions' +import { + email, + advertising_id, + phone, + send_email, + send_phone, + send_advertising_id, + event_name, + enable_batching, + external_audience_id +} from '../properties' + +const action: ActionDefinition = { + title: 'Sync Audience', + description: 'Sync an Engage Audience to a TikTok Audience Segment.', + defaultSubscription: 'type = "track"', + fields: { + email, + phone, + advertising_id, + send_email, + send_phone, + send_advertising_id, + event_name, + enable_batching, + batch_keys: { + label: 'Batch Keys', + description: 'The keys to use for batching the events.', + type: 'string', + multiple: true, + default: ['send_email', 'send_phone', 'send_advertising_id', 'external_audience_id'], + unsafe_hidden: true + }, + external_audience_id + }, + perform: async (request, { audienceSettings, payload, audienceMembership }) => { + return send(request, [payload], audienceSettings, [audienceMembership]) + }, + performBatch: async (request, { payload: payloads, audienceSettings, audienceMembership: audienceMemberships }) => { + return send(request, payloads, audienceSettings, audienceMemberships, true) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/syncAudience/types.ts b/packages/destination-actions/src/destinations/tiktok-audiences/syncAudience/types.ts new file mode 100644 index 00000000000..6c13431cb63 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-audiences/syncAudience/types.ts @@ -0,0 +1 @@ +export type TikTokAudienceAction = 'add' | 'delete'