From acd31452f177928321c9127aa36152000914ce69 Mon Sep 17 00:00:00 2001 From: Alice Mackel Date: Tue, 17 Sep 2024 05:49:51 -0400 Subject: [PATCH] StackAdapt Audiences destination (#2390) * Feature(IDE-2251): creates authentication header and sample gql caller * nit * Feature(IDE-2258): Perform and perform batch functions for Segment audience destination * Pass audience ID and audience name as specific fields * Use global domain variable * Feature(IDE-2305): Implement onDelete handler * Change external provider to segmentio * Feature(IDE-2338): Force profile fields to camelCase * Feature(IDE-2356): Handle alias events * Make previous_id camelCase * Feature(IDE-2335): Dynamic field for advertiser ID that loads options from our API (#7) * Feature(IDE-2335): Dynamic field for Audience ID that loads audience list from our API * Correct audience to advertiser * Feature(IDE-2546): Profile and audience mappings (#9) * Feature(IDE-2546): Profile and audience mappings * Update audience mapping destination keys * Change marketing status name * Alias userId to external_id * Prepare for submission to Segment.io * Use prod URL * Address feedback from PR * Update request due to change to backend API * Separate audience and profile handling actions * Cleanup --------- Co-authored-by: Ricky Zhong Co-authored-by: Ricky Weijie Zhong --- .../__snapshots__/snapshot.test.ts.snap | 143 ++++++++ .../__tests__/snapshot.test.ts | 77 +++++ .../__tests__/index.test.ts | 254 +++++++++++++++ .../forwardAudienceEvent/functions.ts | 83 +++++ .../forwardAudienceEvent/generated-types.ts | 38 +++ .../forwardAudienceEvent/index.ts | 105 ++++++ .../forwardProfile/__tests__/index.test.ts | 305 ++++++++++++++++++ .../forwardProfile/functions.ts | 130 ++++++++ .../forwardProfile/generated-types.ts | 66 ++++ .../forwardProfile/index.ts | 191 +++++++++++ .../stackadapt-audiences/functions.ts | 75 +++++ .../stackadapt-audiences/generated-types.ts | 8 + .../stackadapt-audiences/index.ts | 68 ++++ .../stackadapt-audiences/types.ts | 15 + 14 files changed, 1558 insertions(+) create mode 100644 packages/destination-actions/src/destinations/stackadapt-audiences/__tests__/__snapshots__/snapshot.test.ts.snap create mode 100644 packages/destination-actions/src/destinations/stackadapt-audiences/__tests__/snapshot.test.ts create mode 100644 packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/functions.ts create mode 100644 packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/index.ts create mode 100644 packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/functions.ts create mode 100644 packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/index.ts create mode 100644 packages/destination-actions/src/destinations/stackadapt-audiences/functions.ts create mode 100644 packages/destination-actions/src/destinations/stackadapt-audiences/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/stackadapt-audiences/index.ts create mode 100644 packages/destination-actions/src/destinations/stackadapt-audiences/types.ts diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/stackadapt-audiences/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..2dbeac1701 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,143 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-stackadapt-audiences destination: forwardAudienceEvent action - all fields 1`] = ` +Object { + "query": "mutation { + upsertProfiles( + input: { + advertiserId: 84GW[vK%wv2xv@UF5iy, + externalProvider: \\"segmentio\\", + syncId: \\"7bcb527cec5517b1155595cd74dc96b6db6ed1d0c54b91ebe04297f3524bd775\\", + profiles: \\"[{\\\\\\"userId\\\\\\":\\\\\\"84GW[vK%wv2xv@UF5iy\\\\\\",\\\\\\"audienceId\\\\\\":\\\\\\"84GW[vK%wv2xv@UF5iy\\\\\\",\\\\\\"audienceName\\\\\\":\\\\\\"84GW[vK%wv2xv@UF5iy\\\\\\",\\\\\\"action\\\\\\":\\\\\\"exit\\\\\\"}]\\" + } + ) { + userErrors { + message + } + } + upsertProfileMapping( + input: { + advertiserId: 84GW[vK%wv2xv@UF5iy, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappableType: \\"segmentio\\", + } + ) { + userErrors { + message + } + } + upsertExternalAudienceMapping( + input: { + advertiserId: 84GW[vK%wv2xv@UF5iy, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\"},{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceName\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"name\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\"}]\\", + mappableType: \\"segmentio\\" + } + ) { + userErrors { + message + } + } + }", +} +`; + +exports[`Testing snapshot for actions-stackadapt-audiences destination: forwardAudienceEvent action - required fields 1`] = ` +Object { + "query": "mutation { + upsertProfiles( + input: { + advertiserId: 84GW[vK%wv2xv@UF5iy, + externalProvider: \\"segmentio\\", + syncId: \\"7bcb527cec5517b1155595cd74dc96b6db6ed1d0c54b91ebe04297f3524bd775\\", + profiles: \\"[{\\\\\\"userId\\\\\\":\\\\\\"84GW[vK%wv2xv@UF5iy\\\\\\",\\\\\\"audienceId\\\\\\":\\\\\\"84GW[vK%wv2xv@UF5iy\\\\\\",\\\\\\"audienceName\\\\\\":\\\\\\"84GW[vK%wv2xv@UF5iy\\\\\\",\\\\\\"action\\\\\\":\\\\\\"exit\\\\\\"}]\\" + } + ) { + userErrors { + message + } + } + upsertProfileMapping( + input: { + advertiserId: 84GW[vK%wv2xv@UF5iy, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappableType: \\"segmentio\\", + } + ) { + userErrors { + message + } + } + upsertExternalAudienceMapping( + input: { + advertiserId: 84GW[vK%wv2xv@UF5iy, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\"},{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceName\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"name\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\"}]\\", + mappableType: \\"segmentio\\" + } + ) { + userErrors { + message + } + } + }", +} +`; + +exports[`Testing snapshot for actions-stackadapt-audiences destination: forwardProfile action - all fields 1`] = ` +Object { + "query": "mutation { + upsertProfiles( + input: { + advertiserId: PsAwlRv%, + externalProvider: \\"segmentio\\", + syncId: \\"1187e62a973b77faa5387b91939d70295b25063e3439b583b997efa92d9c8e78\\", + profiles: \\"[{\\\\\\"email\\\\\\":\\\\\\"zobbufpop@usliz.mh\\\\\\",\\\\\\"firstName\\\\\\":\\\\\\"PsAwlRv%\\\\\\",\\\\\\"lastName\\\\\\":\\\\\\"PsAwlRv%\\\\\\",\\\\\\"phone\\\\\\":\\\\\\"PsAwlRv%\\\\\\",\\\\\\"city\\\\\\":\\\\\\"PsAwlRv%\\\\\\",\\\\\\"country\\\\\\":\\\\\\"PsAwlRv%\\\\\\",\\\\\\"state\\\\\\":\\\\\\"PsAwlRv%\\\\\\",\\\\\\"postalCode\\\\\\":\\\\\\"PsAwlRv%\\\\\\",\\\\\\"userId\\\\\\":\\\\\\"PsAwlRv%\\\\\\",\\\\\\"birthDay\\\\\\":null,\\\\\\"birthMonth\\\\\\":null}]\\" + } + ) { + userErrors { + message + } + } + upsertProfileMapping( + input: { + advertiserId: PsAwlRv%, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappableType: \\"segmentio\\", + } + ) { + userErrors { + message + } + } + }", +} +`; + +exports[`Testing snapshot for actions-stackadapt-audiences destination: forwardProfile action - required fields 1`] = ` +Object { + "query": "mutation { + upsertProfiles( + input: { + advertiserId: PsAwlRv%, + externalProvider: \\"segmentio\\", + syncId: \\"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945\\", + profiles: \\"[]\\" + } + ) { + userErrors { + message + } + } + upsertProfileMapping( + input: { + advertiserId: PsAwlRv%, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappableType: \\"segmentio\\", + } + ) { + userErrors { + message + } + } + }", +} +`; diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..11c572ac33 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +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 destinationSlug = 'actions-stackadapt-audiences' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + 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 + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + 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 + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..631d569a88 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/__tests__/index.test.ts @@ -0,0 +1,254 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../../index' +import { SegmentEvent } from '@segment/actions-core/*' + +const testDestination = createTestIntegration(Definition) +const mockGqlKey = 'test-graphql-key' + +const gqlHostUrl = 'https://api.stackadapt.com' +const gqlPath = '/graphql' +const mockUserId = 'user-id' +const mockAdvertiserId = '23' +const mockMappings = { advertiser_id: mockAdvertiserId } + +const defaultEventPayload: Partial = { + userId: mockUserId, + type: 'identify', + traits: { + first_time_buyer: true + }, + context: { + personas: { + computation_class: 'audience', + computation_key: 'first_time_buyer', + computation_id: 'aud_123' + } + } +} + +const trackEventPayload: Partial = { + userId: mockUserId, + type: 'track', + event: 'Audience Entered', + properties: { + audience_key: 'first_time_buyer', + first_time_buyer: true + }, + context: { + personas: { + computation_class: 'audience', + computation_key: 'first_time_buyer', + computation_id: 'aud_123' + } + } +} + +describe('forwardAudienceEvent', () => { + it('should translate identify audience entry/exit into GQL format', async () => { + let requestBody + nock(gqlHostUrl) + .post(gqlPath, (body) => { + requestBody = body + return body + }) + .reply(200, { data: { success: true } }) + const event = createTestEvent(defaultEventPayload) + const responses = await testDestination.testAction('forwardAudienceEvent', { + event, + useDefaultMappings: true, + mapping: mockMappings, + settings: { apiKey: mockGqlKey } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].request.headers).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer test-graphql-key", + ], + "content-type": Array [ + "application/json", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, + } + `) + expect(requestBody).toMatchInlineSnapshot(` + Object { + "query": "mutation { + upsertProfiles( + input: { + advertiserId: 23, + externalProvider: \\"segmentio\\", + syncId: \\"18173ad77a58c56aee5ef6ebde0ff2911b80807f32985ff1e10c03b02cd0b8bc\\", + profiles: \\"[{\\\\\\"userId\\\\\\":\\\\\\"user-id\\\\\\",\\\\\\"audienceId\\\\\\":\\\\\\"aud_123\\\\\\",\\\\\\"audienceName\\\\\\":\\\\\\"first_time_buyer\\\\\\",\\\\\\"action\\\\\\":\\\\\\"enter\\\\\\"}]\\" + } + ) { + userErrors { + message + } + } + upsertProfileMapping( + input: { + advertiserId: 23, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappableType: \\"segmentio\\", + } + ) { + userErrors { + message + } + } + upsertExternalAudienceMapping( + input: { + advertiserId: 23, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\"},{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceName\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"name\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\"}]\\", + mappableType: \\"segmentio\\" + } + ) { + userErrors { + message + } + } + }", + } + `) + }) + + it('should translate track audience entry/exit into GQL format', async () => { + let requestBody + nock(gqlHostUrl) + .post(gqlPath, (body) => { + requestBody = body + return body + }) + .reply(200, { data: { success: true } }) + const event = createTestEvent(trackEventPayload) + const responses = await testDestination.testAction('forwardAudienceEvent', { + event, + useDefaultMappings: true, + mapping: mockMappings, + settings: { apiKey: mockGqlKey } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].request.headers).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer test-graphql-key", + ], + "content-type": Array [ + "application/json", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, + } + `) + expect(requestBody).toMatchInlineSnapshot(` + Object { + "query": "mutation { + upsertProfiles( + input: { + advertiserId: 23, + externalProvider: \\"segmentio\\", + syncId: \\"18173ad77a58c56aee5ef6ebde0ff2911b80807f32985ff1e10c03b02cd0b8bc\\", + profiles: \\"[{\\\\\\"userId\\\\\\":\\\\\\"user-id\\\\\\",\\\\\\"audienceId\\\\\\":\\\\\\"aud_123\\\\\\",\\\\\\"audienceName\\\\\\":\\\\\\"first_time_buyer\\\\\\",\\\\\\"action\\\\\\":\\\\\\"enter\\\\\\"}]\\" + } + ) { + userErrors { + message + } + } + upsertProfileMapping( + input: { + advertiserId: 23, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappableType: \\"segmentio\\", + } + ) { + userErrors { + message + } + } + upsertExternalAudienceMapping( + input: { + advertiserId: 23, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\"},{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceName\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"name\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\"}]\\", + mappableType: \\"segmentio\\" + } + ) { + userErrors { + message + } + } + }", + } + `) + }) + + it('should batch multiple profile events into a single request', async () => { + let requestBody + nock(gqlHostUrl) + .post(gqlPath, (body) => { + requestBody = body + return body + }) + .reply(200, { data: { success: true } }) + const events = [createTestEvent(defaultEventPayload), createTestEvent(trackEventPayload)] + const responses = await testDestination.testBatchAction('forwardAudienceEvent', { + events, + useDefaultMappings: true, + mapping: mockMappings, + settings: { apiKey: mockGqlKey } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(requestBody).toMatchInlineSnapshot(` + Object { + "query": "mutation { + upsertProfiles( + input: { + advertiserId: 23, + externalProvider: \\"segmentio\\", + syncId: \\"c371022fd0a74b3ff0376ee0a8838c0e7d21be220ba335bfdd7205bca9545bd3\\", + profiles: \\"[{\\\\\\"userId\\\\\\":\\\\\\"user-id\\\\\\",\\\\\\"audienceId\\\\\\":\\\\\\"aud_123\\\\\\",\\\\\\"audienceName\\\\\\":\\\\\\"first_time_buyer\\\\\\",\\\\\\"action\\\\\\":\\\\\\"enter\\\\\\"},{\\\\\\"userId\\\\\\":\\\\\\"user-id\\\\\\",\\\\\\"audienceId\\\\\\":\\\\\\"aud_123\\\\\\",\\\\\\"audienceName\\\\\\":\\\\\\"first_time_buyer\\\\\\",\\\\\\"action\\\\\\":\\\\\\"enter\\\\\\"}]\\" + } + ) { + userErrors { + message + } + } + upsertProfileMapping( + input: { + advertiserId: 23, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappableType: \\"segmentio\\", + } + ) { + userErrors { + message + } + } + upsertExternalAudienceMapping( + input: { + advertiserId: 23, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\"},{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceName\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"name\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\"}]\\", + mappableType: \\"segmentio\\" + } + ) { + userErrors { + message + } + } + }", + } + `) + }) +}) diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/functions.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/functions.ts new file mode 100644 index 0000000000..96dd01f305 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/functions.ts @@ -0,0 +1,83 @@ +import { RequestClient } from '@segment/actions-core' +import { Payload } from './generated-types' +import { GQL_ENDPOINT, sha256hash, stringifyJsonWithEscapedQuotes } from '../functions' + +const EXTERNAL_PROVIDER = 'segmentio' + +const audienceMapping = stringifyJsonWithEscapedQuotes([ + { + incoming_key: 'audienceId', + destination_key: 'external_id', + data_type: 'string' + }, + { + incoming_key: 'audienceName', + destination_key: 'name', + data_type: 'string' + } +]) + +const profileMapping = stringifyJsonWithEscapedQuotes([ + { + incoming_key: 'userId', + destination_key: 'external_id', + data_type: 'string', + is_pii: false + } +]) + +export async function performForwardAudienceEvents(request: RequestClient, events: Payload[]) { + const advertiserId = events[0].advertiser_id + const profileUpdates = events.map((event) => { + const { segment_computation_key: audienceKey, segment_computation_id: audienceId, user_id, traits_or_props } = event + + const { [audienceKey]: action } = traits_or_props + return { + userId: user_id, + audienceId, + audienceName: audienceKey, + action: action ? 'enter' : 'exit' + } + }) + + const profiles = stringifyJsonWithEscapedQuotes(profileUpdates) + const mutation = `mutation { + upsertProfiles( + input: { + advertiserId: ${advertiserId}, + externalProvider: "${EXTERNAL_PROVIDER}", + syncId: "${sha256hash(profiles)}", + profiles: "${profiles}" + } + ) { + userErrors { + message + } + } + upsertProfileMapping( + input: { + advertiserId: ${advertiserId}, + mappingSchema: "${profileMapping}", + mappableType: "${EXTERNAL_PROVIDER}", + } + ) { + userErrors { + message + } + } + upsertExternalAudienceMapping( + input: { + advertiserId: ${advertiserId}, + mappingSchema: "${audienceMapping}", + mappableType: "${EXTERNAL_PROVIDER}" + } + ) { + userErrors { + message + } + } + }` + return await request(GQL_ENDPOINT, { + body: JSON.stringify({ query: mutation }) + }) +} diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/generated-types.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/generated-types.ts new file mode 100644 index 0000000000..3e826c9066 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/generated-types.ts @@ -0,0 +1,38 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The properties of the user or event. + */ + traits_or_props: { + [k: string]: unknown + } + /** + * The ID of the user in Segment + */ + user_id?: string + /** + * The Segment event type (identify, alias, etc.) + */ + event_type?: string + /** + * When enabled, Segment will batch profiles together and send them to StackAdapt in a single request. + */ + enable_batching: boolean + /** + * Segment computation class used to determine if input event is from an Engage Audience'. + */ + segment_computation_class?: string + /** + * For audience enter/exit events, this will be the audience ID. + */ + segment_computation_id?: string + /** + * For audience enter/exit events, this will be the audience key. + */ + segment_computation_key: string + /** + * The StackAdapt advertiser to add the profile to. + */ + advertiser_id: string +} diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/index.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/index.ts new file mode 100644 index 0000000000..4c4d73a929 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/index.ts @@ -0,0 +1,105 @@ +import { ActionDefinition } from '@segment/actions-core' +import { Payload } from './generated-types' +import { Settings } from '../generated-types' +import { performForwardAudienceEvents } from './functions' +import { advertiserIdFieldImplementation } from '../functions' + +const action: ActionDefinition = { + title: 'Forward Audience Event', + description: 'Forward audience enter or exit events to StackAdapt', + defaultSubscription: 'type = "identify" or type = "track"', + fields: { + traits_or_props: { + label: 'Event Properties', + type: 'object', + description: 'The properties of the user or event.', + unsafe_hidden: true, + required: true, + default: { + '@if': { + exists: { '@path': '$.properties.audience_key' }, + then: { '@path': '$.properties' }, + else: { '@path': '$.traits' } + } + } + }, + user_id: { + label: 'Segment User ID', + description: 'The ID of the user in Segment', + type: 'string', + default: { + // By default we want to use the permanent user id that's consistent across a customer's lifetime. + // But if we don't have that we can fall back to the anonymous id + '@if': { + exists: { '@path': '$.userId' }, + then: { '@path': '$.userId' }, + else: { '@path': '$.anonymousId' } + } + } + }, + event_type: { + label: 'Event Type', + description: 'The Segment event type (identify, alias, etc.)', + type: 'string', + default: { + '@path': '$.type' + } + }, + enable_batching: { + type: 'boolean', + label: 'Batch Profiles', + unsafe_hidden: true, + description: + 'When enabled, Segment will batch profiles together and send them to StackAdapt in a single request.', + required: true, + default: true + }, + segment_computation_class: { + label: 'Segment Computation Class', + description: "Segment computation class used to determine if input event is from an Engage Audience'.", + type: 'string', + unsafe_hidden: true, + default: { + '@path': '$.context.personas.computation_class' + } + }, + segment_computation_id: { + label: 'Segment Computation ID', + description: 'For audience enter/exit events, this will be the audience ID.', + type: 'string', + unsafe_hidden: true, + default: { + '@path': '$.context.personas.computation_id' + } + }, + segment_computation_key: { + label: 'Segment Computation Key', + description: 'For audience enter/exit events, this will be the audience key.', + type: 'string', + unsafe_hidden: true, + required: true, + default: { + '@path': '$.context.personas.computation_key' + } + }, + advertiser_id: { + label: 'Advertiser', + description: 'The StackAdapt advertiser to add the profile to.', + type: 'string', + disabledInputMethods: ['literal', 'variable', 'function', 'freeform', 'enrichment'], + required: true, + dynamic: true + } + }, + dynamicFields: { + advertiser_id: advertiserIdFieldImplementation + }, + perform: async (request, { payload }) => { + return await performForwardAudienceEvents(request, [payload]) + }, + performBatch: async (request, { payload }) => { + return await performForwardAudienceEvents(request, payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/__tests__/index.test.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/__tests__/index.test.ts new file mode 100644 index 0000000000..3c6eed73f8 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/__tests__/index.test.ts @@ -0,0 +1,305 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../../index' +import { SegmentEvent } from '@segment/actions-core/*' + +const testDestination = createTestIntegration(Definition) +const mockGqlKey = 'test-graphql-key' + +const gqlHostUrl = 'https://api.stackadapt.com' +const gqlPath = '/graphql' +const mockEmail = 'admin@stackadapt.com' +const mockUserId = 'user-id' +const mockEmail2 = 'email2@stackadapt.com' +const mockBirthday = '2001-01-02T00:00:00.000Z' +const mockUserId2 = 'user-id2' +const mockAdvertiserId = '23' +const mockMappings = { + advertiser_id: mockAdvertiserId, + traits: { + email: { + '@path': '$.traits.email' + }, + birthday: { + '@path': '$.traits.birthday' + }, + custom_field: { + '@path': '$.traits.custom_field' + }, + number_custom_field: { + '@path': '$.traits.number_custom_field' + } + } +} +const trackMockMappings = { + advertiser_id: mockAdvertiserId, + traits: { + email: { + '@path': '$.context.traits.email' + }, + birthday: { + '@path': '$.context.traits.birthday' + } + } +} + +const defaultEventPayload: Partial = { + userId: mockUserId, + type: 'identify', + traits: { + email: mockEmail, + birthday: mockBirthday + } +} + +const trackEventPayload: Partial = { + userId: mockUserId, + type: 'track', + event: 'Track Event Name', + context: { + traits: { + email: mockEmail, + birthday: mockBirthday + } + } +} + +const batchEventPayload: Partial = { + userId: mockUserId2, + type: 'identify', + traits: { + email: mockEmail2, + custom_field: 'value', + number_custom_field: 123 + } +} + +const aliasEventPayload: Partial = { + type: 'alias', + userId: mockUserId, + previousId: mockUserId2 +} + +describe('forwardProfile', () => { + it('should translate identify into GQL format', async () => { + let requestBody + nock(gqlHostUrl) + .post(gqlPath, (body) => { + requestBody = body + return body + }) + .reply(200, { data: { success: true } }) + const event = createTestEvent(defaultEventPayload) + const responses = await testDestination.testAction('forwardProfile', { + event, + useDefaultMappings: true, + mapping: mockMappings, + settings: { apiKey: mockGqlKey } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].request.headers).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer test-graphql-key", + ], + "content-type": Array [ + "application/json", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, + } + `) + expect(requestBody).toMatchInlineSnapshot(` + Object { + "query": "mutation { + upsertProfiles( + input: { + advertiserId: 23, + externalProvider: \\"segmentio\\", + syncId: \\"e6a568a61b0264fb8038ae64dbfb72032f7d1f5b32cf54acbe02979d9312f470\\", + profiles: \\"[{\\\\\\"email\\\\\\":\\\\\\"admin@stackadapt.com\\\\\\",\\\\\\"userId\\\\\\":\\\\\\"user-id\\\\\\",\\\\\\"birthDay\\\\\\":1,\\\\\\"birthMonth\\\\\\":2}]\\" + } + ) { + userErrors { + message + } + } + upsertProfileMapping( + input: { + advertiserId: 23, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappableType: \\"segmentio\\", + } + ) { + userErrors { + message + } + } + }", + } + `) + }) + + it('should translate track into GQL format', async () => { + let requestBody + nock(gqlHostUrl) + .post(gqlPath, (body) => { + requestBody = body + return body + }) + .reply(200, { data: { success: true } }) + const event = createTestEvent(trackEventPayload) + const responses = await testDestination.testAction('forwardProfile', { + event, + useDefaultMappings: true, + mapping: trackMockMappings, + settings: { apiKey: mockGqlKey } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].request.headers).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer test-graphql-key", + ], + "content-type": Array [ + "application/json", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, + } + `) + expect(requestBody).toMatchInlineSnapshot(` + Object { + "query": "mutation { + upsertProfiles( + input: { + advertiserId: 23, + externalProvider: \\"segmentio\\", + syncId: \\"e6a568a61b0264fb8038ae64dbfb72032f7d1f5b32cf54acbe02979d9312f470\\", + profiles: \\"[{\\\\\\"email\\\\\\":\\\\\\"admin@stackadapt.com\\\\\\",\\\\\\"userId\\\\\\":\\\\\\"user-id\\\\\\",\\\\\\"birthDay\\\\\\":1,\\\\\\"birthMonth\\\\\\":2}]\\" + } + ) { + userErrors { + message + } + } + upsertProfileMapping( + input: { + advertiserId: 23, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappableType: \\"segmentio\\", + } + ) { + userErrors { + message + } + } + }", + } + `) + }) + + it('should batch multiple profile events into a single request', async () => { + let requestBody + nock(gqlHostUrl) + .post(gqlPath, (body) => { + requestBody = body + return body + }) + .reply(200, { data: { success: true } }) + const events = [createTestEvent(defaultEventPayload), createTestEvent(batchEventPayload)] + const responses = await testDestination.testBatchAction('forwardProfile', { + events, + useDefaultMappings: true, + mapping: mockMappings, + settings: { apiKey: mockGqlKey } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(requestBody).toMatchInlineSnapshot(` + Object { + "query": "mutation { + upsertProfiles( + input: { + advertiserId: 23, + externalProvider: \\"segmentio\\", + syncId: \\"fab5978d05bc4be0dadaed90eb6372333239e1c0c464a6a62b48d34cbaf676b2\\", + profiles: \\"[{\\\\\\"email\\\\\\":\\\\\\"admin@stackadapt.com\\\\\\",\\\\\\"userId\\\\\\":\\\\\\"user-id\\\\\\",\\\\\\"birthDay\\\\\\":1,\\\\\\"birthMonth\\\\\\":2},{\\\\\\"email\\\\\\":\\\\\\"email2@stackadapt.com\\\\\\",\\\\\\"customField\\\\\\":\\\\\\"value\\\\\\",\\\\\\"numberCustomField\\\\\\":123,\\\\\\"userId\\\\\\":\\\\\\"user-id2\\\\\\"}]\\" + } + ) { + userErrors { + message + } + } + upsertProfileMapping( + input: { + advertiserId: 23, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false},{\\\\\\"incoming_key\\\\\\":\\\\\\"customField\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"customField\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false},{\\\\\\"incoming_key\\\\\\":\\\\\\"numberCustomField\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"numberCustomField\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"number\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappableType: \\"segmentio\\", + } + ) { + userErrors { + message + } + } + }", + } + `) + }) + + it('should translate alias event into GQL format', async () => { + let requestBody + nock(gqlHostUrl) + .post(gqlPath, (body) => { + requestBody = body + return body + }) + .reply(200, { data: { success: true } }) + const event = createTestEvent(aliasEventPayload) + const responses = await testDestination.testAction('forwardProfile', { + event, + useDefaultMappings: true, + mapping: mockMappings, + settings: { apiKey: mockGqlKey } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(requestBody).toMatchInlineSnapshot(` + Object { + "query": "mutation { + upsertProfiles( + input: { + advertiserId: 23, + externalProvider: \\"segmentio\\", + syncId: \\"b9612b9eb0ade5b30e0f474e03e54449e0d108e09306aa1afdf92e2a6267146e\\", + profiles: \\"[{\\\\\\"userId\\\\\\":\\\\\\"user-id\\\\\\",\\\\\\"previousId\\\\\\":\\\\\\"user-id2\\\\\\"}]\\" + } + ) { + userErrors { + message + } + } + upsertProfileMapping( + input: { + advertiserId: 23, + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"data_type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappableType: \\"segmentio\\", + } + ) { + userErrors { + message + } + } + }", + } + `) + }) +}) diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/functions.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/functions.ts new file mode 100644 index 0000000000..e2f86f21f8 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/functions.ts @@ -0,0 +1,130 @@ +import { RequestClient } from '@segment/actions-core' +import camelCase from 'lodash/camelCase' +import isEmpty from 'lodash/isEmpty' +import { Payload } from './generated-types' +import { GQL_ENDPOINT, EXTERNAL_PROVIDER, sha256hash, stringifyJsonWithEscapedQuotes } from '../functions' + +const standardFields = new Set([ + 'email', + 'firstName', + 'lastName', + 'phone', + 'marketingStatus', + 'company', + 'gender', + 'city', + 'state', + 'country', + 'timezone', + 'postalCode', + 'birthDay', + 'birthMonth', + 'address', + 'previousId' +]) + +interface Mapping { + incoming_key: string + destination_key: string + data_type: string + is_pii: boolean +} + +export async function performForwardProfiles(request: RequestClient, events: Payload[]) { + const fieldsToMap: Set = new Set(['userId']) + const fieldTypes: Record = { userId: 'string' } + const advertiserId = events[0].advertiser_id + const profileUpdates = events.flatMap((event) => { + const { event_type, previous_id, user_id, traits } = event + const profile: Record = { + userId: user_id + } + if (event_type === 'alias') { + profile.previousId = previous_id + } else if (isEmpty(traits)) { + // Skip if there are no traits + return [] + } + const { birthday, ...rest } = traits ?? {} + if (birthday) { + // Extract birthDay and birthMonth from ISO date string + const [birthDay, birthMonth] = birthday.split('T')[0].split('-').slice(1) + profile.birthDay = parseInt(birthDay) + profile.birthMonth = parseInt(birthMonth) + } + return { ...processTraits(rest), ...profile } + }) + + const profiles = stringifyJsonWithEscapedQuotes(profileUpdates) + const mutation = `mutation { + upsertProfiles( + input: { + advertiserId: ${advertiserId}, + externalProvider: "${EXTERNAL_PROVIDER}", + syncId: "${sha256hash(profiles)}", + profiles: "${profiles}" + } + ) { + userErrors { + message + } + } + upsertProfileMapping( + input: { + advertiserId: ${advertiserId}, + mappingSchema: "${getProfileMappings(Array.from(fieldsToMap), fieldTypes)}", + mappableType: "${EXTERNAL_PROVIDER}", + } + ) { + userErrors { + message + } + } + }` + return await request(GQL_ENDPOINT, { + body: JSON.stringify({ query: mutation }) + }) + + function processTraits(traits: Record) { + // Convert trait keys to camelCase and capture any non-standard fields as mappings + return Object.keys(traits).reduce((acc: Record, key) => { + const camelCaseKey = camelCase(key) + acc[camelCaseKey] = traits[key] + if (!standardFields.has(camelCaseKey)) { + fieldsToMap.add(camelCaseKey) + // Field type should be the most specific type of the values we've seen so far, use string if there is a conflict of types + if (traits[key] || traits[key] === 0) { + const type = getType(traits[key]) + if (fieldTypes[camelCaseKey] && fieldTypes[camelCaseKey] !== type) { + fieldTypes[camelCaseKey] = 'string' + } else { + fieldTypes[camelCaseKey] = type + } + } + } + return acc + }, {}) + } +} + +function getProfileMappings(customFields: string[], fieldTypes: Record) { + const mappingSchema: Mapping[] = [] + for (const field of customFields) { + mappingSchema.push({ + incoming_key: field, + destination_key: field === 'userId' ? 'external_id' : field, + data_type: fieldTypes[field] ?? 'string', + is_pii: false + }) + } + return stringifyJsonWithEscapedQuotes(mappingSchema) +} + +function getType(value: unknown) { + if (isDateStr(value)) return 'date' + return typeof value +} + +function isDateStr(value: unknown) { + return typeof value === 'string' && !isNaN(Date.parse(value)) +} diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/generated-types.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/generated-types.ts new file mode 100644 index 0000000000..2ee7e2b91c --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/generated-types.ts @@ -0,0 +1,66 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The properties of the user. + */ + traits?: { + /** + * The email address of the user. + */ + email?: string + /** + * The user's first name. + */ + firstName?: string + /** + * The user's last name. + */ + lastName?: string + /** + * The phone number of the user. + */ + phone?: string + /** + * The city of the user. + */ + city?: string + /** + * The country of the user. + */ + country?: string + /** + * The state of the user. + */ + state?: string + /** + * The postal code of the user. + */ + postalCode?: string + /** + * The birthday of the user. + */ + birthday?: string + [k: string]: unknown + } + /** + * The ID of the user in Segment + */ + user_id?: string + /** + * The user's previous ID, for alias events + */ + previous_id?: string + /** + * The Segment event type (identify, alias, etc.) + */ + event_type?: string + /** + * When enabled, Segment will batch profiles together and send them to StackAdapt in a single request. + */ + enable_batching: boolean + /** + * The StackAdapt advertiser to add the profile to. + */ + advertiser_id: string +} diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/index.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/index.ts new file mode 100644 index 0000000000..108b7cacf6 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/index.ts @@ -0,0 +1,191 @@ +import { ActionDefinition } from '@segment/actions-core' +import { Payload } from './generated-types' +import { Settings } from '../generated-types' +import { performForwardProfiles } from './functions' +import { advertiserIdFieldImplementation } from '../functions' + +const action: ActionDefinition = { + title: 'Forward Profile', + description: 'Forward new or updated user profile to StackAdapt', + defaultSubscription: 'type = "identify" or type = "alias" or type = "track"', + fields: { + traits: { + label: 'User Properties', + type: 'object', + description: 'The properties of the user.', + defaultObjectUI: 'keyvalue', + additionalProperties: true, + required: false, + properties: { + email: { + label: 'Email', + type: 'string', + description: 'The email address of the user.' + }, + firstName: { + label: 'First Name', + type: 'string', + description: "The user's first name." + }, + lastName: { + label: 'Last Name', + type: 'string', + description: "The user's last name." + }, + phone: { + label: 'Phone', + type: 'string', + description: 'The phone number of the user.' + }, + city: { + label: 'City', + type: 'string', + description: 'The city of the user.' + }, + country: { + label: 'Country', + type: 'string', + description: 'The country of the user.' + }, + state: { + label: 'State', + type: 'string', + description: 'The state of the user.' + }, + postalCode: { + label: 'Postal Code', + type: 'string', + description: 'The postal code of the user.' + }, + birthday: { + label: 'Birthday', + type: 'string', + description: 'The birthday of the user.' + } + }, + default: { + email: { + '@if': { + exists: { '@path': '$.traits.email' }, + then: { '@path': '$.traits.email' }, + else: { '@path': '$.context.traits.email' } + } + }, + firstName: { + '@if': { + exists: { '@path': '$.traits.first_name' }, + then: { '@path': '$.traits.first_name' }, + else: { '@path': '$.context.traits.first_name' } + } + }, + lastName: { + '@if': { + exists: { '@path': '$.traits.last_name' }, + then: { '@path': '$.traits.last_name' }, + else: { '@path': '$.context.traits.last_name' } + } + }, + phone: { + '@if': { + exists: { '@path': '$.traits.phone' }, + then: { '@path': '$.traits.phone' }, + else: { '@path': '$.context.traits.phone' } + } + }, + city: { + '@if': { + exists: { '@path': '$.traits.address.city' }, + then: { '@path': '$.traits.address.city' }, + else: { '@path': '$.context.traits.address.city' } + } + }, + country: { + '@if': { + exists: { '@path': '$.traits.address.country' }, + then: { '@path': '$.traits.address.country' }, + else: { '@path': '$.context.traits.address.country' } + } + }, + state: { + '@if': { + exists: { '@path': '$.traits.address.state' }, + then: { '@path': '$.traits.address.state' }, + else: { '@path': '$.context.traits.address.state' } + } + }, + postalCode: { + '@if': { + exists: { '@path': '$.traits.address.postalCode' }, + then: { '@path': '$.traits.address.postalCode' }, + else: { '@path': '$.context.traits.address.postalCode' } + } + }, + birthday: { + '@if': { + exists: { '@path': '$.traits.birthday' }, + then: { '@path': '$.traits.birthday' }, + else: { '@path': '$.context.traits.birthday' } + } + } + } + }, + user_id: { + label: 'Segment User ID', + description: 'The ID of the user in Segment', + type: 'string', + default: { + // By default we want to use the permanent user id that's consistent across a customer's lifetime. + // But if we don't have that we can fall back to the anonymous id + '@if': { + exists: { '@path': '$.userId' }, + then: { '@path': '$.userId' }, + else: { '@path': '$.anonymousId' } + } + } + }, + previous_id: { + label: 'Previous ID', + type: 'string', + description: "The user's previous ID, for alias events", + default: { + '@path': '$.previousId' + } + }, + event_type: { + label: 'Event Type', + description: 'The Segment event type (identify, alias, etc.)', + type: 'string', + default: { + '@path': '$.type' + } + }, + enable_batching: { + type: 'boolean', + label: 'Batch Profiles', + unsafe_hidden: true, + description: + 'When enabled, Segment will batch profiles together and send them to StackAdapt in a single request.', + required: true, + default: true + }, + advertiser_id: { + label: 'Advertiser', + description: 'The StackAdapt advertiser to add the profile to.', + type: 'string', + disabledInputMethods: ['literal', 'variable', 'function', 'freeform', 'enrichment'], + required: true, + dynamic: true + } + }, + dynamicFields: { + advertiser_id: advertiserIdFieldImplementation + }, + perform: async (request, { payload }) => { + return await performForwardProfiles(request, [payload]) + }, + performBatch: async (request, { payload }) => { + return await performForwardProfiles(request, payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/functions.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/functions.ts new file mode 100644 index 0000000000..4fa1b112ad --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/functions.ts @@ -0,0 +1,75 @@ +import { APIError, createRequestClient, DynamicFieldResponse } from '@segment/actions-core' +import { createHash } from 'crypto' + +interface Advertiser { + id: string + name: string +} + +interface TokenInfoResponse { + data: { + tokenInfo: { + scopesByAdvertiser: { + nodes: { + advertiser: Advertiser + scopes: string[] + }[] + pageInfo: { + hasNextPage: boolean + endCursor: string + } + } + } + } +} + +export const EXTERNAL_PROVIDER = 'segmentio' +export const GQL_ENDPOINT = 'https://api.stackadapt.com/graphql' + +export async function advertiserIdFieldImplementation( + request: ReturnType +): Promise { + try { + const query = `query { + tokenInfo { + scopesByAdvertiser { + nodes { + advertiser { + id + name + } + scopes + } + } + } + }` + const response = await request(GQL_ENDPOINT, { + body: JSON.stringify({ query }) + }) + const scopesByAdvertiser = response.data.data.tokenInfo.scopesByAdvertiser + const choices = scopesByAdvertiser.nodes + .filter((advertiserEntry) => advertiserEntry.scopes.includes('WRITE')) + .map((advertiserEntry) => ({ value: advertiserEntry.advertiser.id, label: advertiserEntry.advertiser.name })) + .sort((a, b) => a.label.localeCompare(b.label)) + return { choices } + } catch (error) { + return { + choices: [], + nextPage: '', + error: { + message: (error as APIError).message ?? 'Unknown error', + code: (error as APIError).status?.toString() ?? 'Unknown error' + } + } + } +} + +export function sha256hash(data: string) { + const hash = createHash('sha256') + hash.update(data) + return hash.digest('hex') +} + +export function stringifyJsonWithEscapedQuotes(value: unknown) { + return JSON.stringify(value).replace(/"/g, '\\"') +} diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/generated-types.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/generated-types.ts new file mode 100644 index 0000000000..63b18f26ea --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your StackAdapt GQL API Token + */ + apiKey: string +} diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/index.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/index.ts new file mode 100644 index 0000000000..97abdf7278 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/index.ts @@ -0,0 +1,68 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import forwardProfile from './forwardProfile' +import forwardAudienceEvent from './forwardAudienceEvent' +import { AdvertiserScopesResponse } from './types' +import { GQL_ENDPOINT } from './functions' + +const destination: DestinationDefinition = { + name: 'StackAdapt Audiences', + slug: 'actions-stackadapt-audiences', + mode: 'cloud', + + authentication: { + scheme: 'custom', + fields: { + apiKey: { + label: 'GraphQL Token', + description: 'Your StackAdapt GQL API Token', + type: 'string', + required: true + } + }, + testAuthentication: async (request) => { + const scopeQuery = `query { + tokenInfo { + scopesByAdvertiser { + nodes { + advertiser { + name + } + scopes + } + } + } + }` + + const res = await request(GQL_ENDPOINT, { + body: JSON.stringify({ query: scopeQuery }), + throwHttpErrors: false + }) + if (res.status !== 200) { + throw new Error(res.status + res.statusText) + } + const canWrite = ( + (await res.json()) as AdvertiserScopesResponse + ).data?.tokenInfo?.scopesByAdvertiser?.nodes?.some((node: { scopes: string[] }) => node.scopes.includes('WRITE')) + if (!canWrite) { + throw new Error('Please verify your GQL Token or contact support') + } + } + }, + extendRequest: ({ settings }) => { + return { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${settings.apiKey}` + } + } + }, + actions: { + forwardProfile, + forwardAudienceEvent + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/types.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/types.ts new file mode 100644 index 0000000000..6db09c912d --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/types.ts @@ -0,0 +1,15 @@ +export interface AdvertiserScopesResponse { + data: { + tokenInfo: { + scopesByAdvertiser: { + nodes: { + advertiser: { + id: string + name: string + } + scopes: string[] + }[] + } + } + } +}