diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/setConfigurationFields.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/setConfigurationFields.test.ts index feca9da7d3..b2068d9ea9 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/setConfigurationFields.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/setConfigurationFields.test.ts @@ -806,4 +806,36 @@ describe('Set Configuration Fields action', () => { send_page_view: false }) }) + + it('should convert consent values to lower case', async () => { + defaultSettings.enableConsentMode = true + + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...defaultSettings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + ads_storage_consent_state: 'GRANTED', + analytics_storage_consent_state: 'Granted' + } + }) + + setConfigurationEvent.page?.(context) + + expect(mockGtag).toHaveBeenCalledWith('consent', 'update', { + ad_storage: 'granted', + analytics_storage: 'granted' + }) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true + }) + }) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/setConfigurationFields/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/setConfigurationFields/index.ts index bfc4451ceb..09bfe17792 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/setConfigurationFields/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/setConfigurationFields/index.ts @@ -20,13 +20,23 @@ const action: BrowserActionDefinition = { description: 'Consent state indicated by the user for ad cookies. Value must be “granted” or “denied.” This is only used if the Enable Consent Mode setting is on.', label: 'Ads Storage Consent State', - type: 'string' + type: 'string', + choices: [ + { label: 'Granted', value: 'granted' }, + { label: 'Denied', value: 'denied' } + ], + default: undefined }, analytics_storage_consent_state: { description: 'Consent state indicated by the user for ad cookies. Value must be “granted” or “denied.” This is only used if the Enable Consent Mode setting is on.', label: 'Analytics Storage Consent State', - type: 'string' + type: 'string', + choices: [ + { label: 'Granted', value: 'granted' }, + { label: 'Denied', value: 'denied' } + ], + default: undefined }, ad_user_data_consent_state: { description: @@ -140,10 +150,10 @@ const action: BrowserActionDefinition = { ad_personalization?: ConsentParamsArg } = {} if (payload.ads_storage_consent_state) { - consentParams.ad_storage = payload.ads_storage_consent_state as ConsentParamsArg + consentParams.ad_storage = payload.ads_storage_consent_state.toLowerCase() as ConsentParamsArg } if (payload.analytics_storage_consent_state) { - consentParams.analytics_storage = payload.analytics_storage_consent_state as ConsentParamsArg + consentParams.analytics_storage = payload.analytics_storage_consent_state.toLowerCase() as ConsentParamsArg } if (payload.ad_user_data_consent_state) { consentParams.ad_user_data = payload.ad_user_data_consent_state as ConsentParamsArg diff --git a/packages/browser-destinations/destinations/hubspot-web/src/upsertContact/__tests__/index.test.ts b/packages/browser-destinations/destinations/hubspot-web/src/upsertContact/__tests__/index.test.ts index 9aea19304f..9da9e398a1 100644 --- a/packages/browser-destinations/destinations/hubspot-web/src/upsertContact/__tests__/index.test.ts +++ b/packages/browser-destinations/destinations/hubspot-web/src/upsertContact/__tests__/index.test.ts @@ -204,4 +204,52 @@ describe('Hubspot.upsertContact', () => { } ]) }) + + test('trims string traits', async () => { + const context = new Context({ + type: 'identify', + userId: 'mike', + traits: { + friendly: false, + email: 'mike_eh@lph.com', + address: { + street: '6th St', + city: ' San Francisco ', // to be trimmed + state: 'CA', + postalCode: '94103', + country: 'USA' + }, + equipment: { + type: '🚘', + color: ' red ', // to be trimmed + make: { + make: 'Tesla', + model: 'Model S', + year: 2019 + } + } + } + }) + + await upsertContactEvent.identify?.(context) + expect(mockHubspot.push).toHaveBeenCalledTimes(1) + expect(mockHubspot.push).toHaveBeenCalledWith([ + 'identify', + { + email: 'mike_eh@lph.com', + id: 'mike', + friendly: false, + address: '6th St', + country: 'USA', + state: 'CA', + city: 'San Francisco', + zip: '94103', + equipment_type: '🚘', + equipment_color: 'red', + equipment_make_make: 'Tesla', + equipment_make_model: 'Model S', + equipment_make_year: 2019 + } + ]) + }) }) diff --git a/packages/browser-destinations/destinations/hubspot-web/src/upsertContact/index.ts b/packages/browser-destinations/destinations/hubspot-web/src/upsertContact/index.ts index 123c0b050f..7da47406dd 100644 --- a/packages/browser-destinations/destinations/hubspot-web/src/upsertContact/index.ts +++ b/packages/browser-destinations/destinations/hubspot-web/src/upsertContact/index.ts @@ -30,7 +30,8 @@ const action: BrowserActionDefinition = { } }, custom_properties: { - description: 'A list of key-value pairs that describe the contact. Please see [HubSpot`s documentation](https://knowledge.hubspot.com/account/prevent-contact-properties-update-through-tracking-code-api) for limitations in updating contact properties.', + description: + 'A list of key-value pairs that describe the contact. Please see [HubSpot`s documentation](https://knowledge.hubspot.com/account/prevent-contact-properties-update-through-tracking-code-api) for limitations in updating contact properties.', label: 'Custom Properties', type: 'object', required: false, @@ -103,6 +104,15 @@ const action: BrowserActionDefinition = { return } + payload.email = payload.email?.trim() + payload.id = payload.id?.trim() + payload.company = payload.company?.trim() + payload.country = payload.country?.trim() + payload.state = payload.state?.trim() + payload.city = payload.city?.trim() + payload.address = payload.address?.trim() + payload.zip = payload.zip?.trim() + // custom properties should be key-value pairs of strings, therefore, filtering out any non-primitive const { custom_properties, ...rest } = payload let flattenProperties diff --git a/packages/browser-destinations/destinations/hubspot-web/src/utils/flatten.ts b/packages/browser-destinations/destinations/hubspot-web/src/utils/flatten.ts index 6d1c8b53f6..daff14e367 100644 --- a/packages/browser-destinations/destinations/hubspot-web/src/utils/flatten.ts +++ b/packages/browser-destinations/destinations/hubspot-web/src/utils/flatten.ts @@ -23,7 +23,9 @@ export function flatten( const flattened = flatten(data[key] as Properties, `${prefix}_${key}`, skipList, keyTransformation) result = { ...result, ...flattened } } else { - result[keyTransformation(`${prefix}_${key}`.replace(/^_/, ''))] = data[key] as JSONPrimitive + result[keyTransformation(`${prefix}_${key}`.replace(/^_/, ''))] = ( + typeof data[key] === 'string' ? (data[key] as string).trim() : data[key] + ) as JSONPrimitive } } return result diff --git a/packages/core/src/__tests__/destination-kit.test.ts b/packages/core/src/__tests__/destination-kit.test.ts index e7ba277772..84914be375 100644 --- a/packages/core/src/__tests__/destination-kit.test.ts +++ b/packages/core/src/__tests__/destination-kit.test.ts @@ -352,9 +352,19 @@ const multiStatusCompatibleDestination: DestinationDefinition = { label: 'Email', description: 'The user email', type: 'string' + }, + phone: { + label: 'Phone', + description: 'The user phone number', + type: 'string' } }, perform: (_request, { payload }) => { + // Emulate an API error + if (payload.phone) { + throw new IntegrationError('Phone number validation failed', 'Invalid Phone Number', 400) + } + if (payload.email) { throw new IntegrationError('Email is required', 'Missing required fields', 400) } @@ -367,6 +377,21 @@ const multiStatusCompatibleDestination: DestinationDefinition = { performBatch: (_request, { payload }) => { const response = new MultiStatusResponse() payload.forEach((event) => { + // Emulate an API error + if (event?.phone) { + response.pushErrorResponse({ + status: 400, + errortype: ErrorCodes.BAD_REQUEST, + errormessage: 'Phone number validation failed', + sent: event, + body: { + events_processed: 0, + message: 'Phone number validation failed' + } + }) + return + } + if (event?.email) { response.pushSuccessResponse({ body: {}, @@ -1803,6 +1828,15 @@ describe('destination kit', () => { email: 'user.one@example.com' }, receivedAt + }, + { + // Valid Event with emulated rejection + event: 'Add to Cart', + type: 'track', + properties: { + phone: '1234567890' + }, + receivedAt } ] @@ -1813,7 +1847,8 @@ describe('destination kit', () => { partnerAction: 'trackEvent', mapping: { name: { '@path': '$.event' }, - email: { '@path': '$.properties.email' } + email: { '@path': '$.properties.email' }, + phone: { '@path': '$.properties.phone' } } } } @@ -1856,7 +1891,7 @@ describe('destination kit', () => { }, Object { "errormessage": "Email is required", - "errorreporter": "DESTINATION", + "errorreporter": "INTEGRATIONS", "errortype": "PAYLOAD_VALIDATION_FAILED", "status": 400, }, @@ -1865,6 +1900,20 @@ describe('destination kit', () => { "sent": Object {}, "status": 200, }, + Object { + "body": Object { + "events_processed": 0, + "message": "Phone number validation failed", + }, + "errormessage": "Phone number validation failed", + "errorreporter": "DESTINATION", + "errortype": "BAD_REQUEST", + "sent": Object { + "name": "Add to Cart", + "phone": "1234567890", + }, + "status": 400, + }, ], }, ] diff --git a/packages/core/src/destination-kit/action.ts b/packages/core/src/destination-kit/action.ts index 42291a0091..cc641efa44 100644 --- a/packages/core/src/destination-kit/action.ts +++ b/packages/core/src/destination-kit/action.ts @@ -542,9 +542,16 @@ export class Action = ( ) => MaybePromise /** The supported sync mode values */ -export const syncModeTypes = ['add', 'update', 'upsert', 'delete'] as const +export const syncModeTypes = ['add', 'update', 'upsert', 'delete', 'mirror'] as const export type SyncMode = typeof syncModeTypes[number] export interface SyncModeOption { diff --git a/packages/destination-actions/package.json b/packages/destination-actions/package.json index 6de0df6fcf..2d5c46499b 100644 --- a/packages/destination-actions/package.json +++ b/packages/destination-actions/package.json @@ -1,7 +1,7 @@ { "name": "@segment/action-destinations", "description": "Destination Actions engine and definitions.", - "version": "3.345.0", + "version": "3.345.1", "repository": { "type": "git", "url": "https://github.com/segmentio/action-destinations", diff --git a/packages/destination-actions/src/destinations/attentive/customEvents/__tests__/index.test.ts b/packages/destination-actions/src/destinations/attentive/customEvents/__tests__/index.test.ts new file mode 100644 index 0000000000..421654cd30 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/customEvents/__tests__/index.test.ts @@ -0,0 +1,102 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, SegmentEvent, PayloadValidationError } from '@segment/actions-core' +import Definition from '../../index' +import { Settings } from '../../generated-types' + +let testDestination = createTestIntegration(Definition) +const timestamp = '2024-01-08T13:52:50.212Z' + +const settings: Settings = { + apiKey: 'test-api-key' +} + +const validPayload = { + timestamp: timestamp, + event: 'Event Type 1', + messageId: '123e4567-e89b-12d3-a456-426614174000', + type: 'track', + userId: '123e4567-e89b-12d3-a456-426614174000', + context: { + traits: { + phone: '+3538675765689', + email: 'test@test.com' + } + }, + properties: { + tracking_url: 'https://tracking-url.com', + product_name: 'Product X' + } +} as Partial + +const mapping = { + type: { '@path': '$.event' }, + userIdentifiers: { + phone: { '@path': '$.context.traits.phone' }, + email: { '@path': '$.context.traits.email' }, + clientUserId: { '@path': '$.userId' } + }, + properties: { '@path': '$.properties' }, + externalEventId: { '@path': '$.messageId' }, + occurredAt: { '@path': '$.timestamp' } +} + +const expectedPayload = { + type: 'Event Type 1', + properties: { + tracking_url: 'https://tracking-url.com', + product_name: 'Product X' + }, + externalEventId: '123e4567-e89b-12d3-a456-426614174000', + occurredAt: '2024-01-08T13:52:50.212Z', + user: { + phone: '+3538675765689', + email: 'test@test.com', + externalIdentifiers: { + clientUserId: '123e4567-e89b-12d3-a456-426614174000' + } + } +} + +beforeEach((done) => { + testDestination = createTestIntegration(Definition) + nock.cleanAll() + done() +}) + +describe('Attentive.customEvents', () => { + it('should send a custom event to Attentive', async () => { + const event = createTestEvent(validPayload) + + nock('https://api.attentivemobile.com').post('/v1/events/custom', expectedPayload).reply(200, {}) + + const responses = await testDestination.testAction('customEvents', { + event, + settings, + useDefaultMappings: true, + mapping + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should throw error if no identifiers provided', async () => { + const badPayload = { + ...validPayload + } + delete badPayload?.context?.traits?.phone + delete badPayload?.context?.traits?.email + badPayload.userId = undefined + + const event = createTestEvent(badPayload) + + await expect( + testDestination.testAction('customEvents', { + event, + settings, + useDefaultMappings: true, + mapping + }) + ).rejects.toThrowError(new PayloadValidationError('At least one user identifier is required.')) + }) +}) diff --git a/packages/destination-actions/src/destinations/attentive/customEvents/generated-types.ts b/packages/destination-actions/src/destinations/attentive/customEvents/generated-types.ts new file mode 100644 index 0000000000..44df57ea91 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/customEvents/generated-types.ts @@ -0,0 +1,40 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The type of event. This name is case sensitive. "Order shipped" and "Order Shipped" would be considered different event types. + */ + type: string + /** + * At least one identifier is required. Custom identifiers can be added as additional key:value pairs. + */ + userIdentifiers: { + /** + * The user's phone number in E.164 format. + */ + phone?: string + /** + * The user's email address. + */ + email?: string + /** + * A primary ID for a user. Should be a UUID. + */ + clientUserId?: string + [k: string]: unknown + } + /** + * Metadata to associate with the event. + */ + properties?: { + [k: string]: unknown + } + /** + * A unique identifier representing this specific event. Should be a UUID format. + */ + externalEventId?: string + /** + * Timestamp for the event, ISO 8601 format. + */ + occurredAt?: string +} diff --git a/packages/destination-actions/src/destinations/attentive/customEvents/index.ts b/packages/destination-actions/src/destinations/attentive/customEvents/index.ts new file mode 100644 index 0000000000..5213b5de2f --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/customEvents/index.ts @@ -0,0 +1,135 @@ +import { ActionDefinition, PayloadValidationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { CustomEvent, User } from './types' + +const action: ActionDefinition = { + title: 'Custom Events', + description: 'Send Segment analytics events to Attentive.', + defaultSubscription: 'type = "track"', + fields: { + type: { + label: 'Type', + description: 'The type of event. This name is case sensitive. "Order shipped" and "Order Shipped" would be considered different event types.', + type: 'string', + required: true, + default: { + '@path': '$.event' + } + }, + userIdentifiers: { + label: 'User Identifiers', + description: 'At least one identifier is required. Custom identifiers can be added as additional key:value pairs.', + type: 'object', + required: true, + additionalProperties: true, + defaultObjectUI: 'keyvalue:only', + properties: { + phone: { + label: 'Phone', + description: "The user's phone number in E.164 format.", + type: 'string', + required: false + }, + email: { + label: 'Email', + description: "The user's email address.", + type: 'string', + format: 'email', + required: false + }, + clientUserId: { + label: 'Client User ID', + description: 'A primary ID for a user. Should be a UUID.', + type: 'string', + format: 'uuid', + required: false + } + }, + default: { + phone: { + '@if': { + exists: { '@path': '$.context.traits.phone' }, + then: { '@path': '$.context.traits.phone' }, + else: { '@path': '$.properties.phone' } + } + }, + email: { + '@if': { + exists: { '@path': '$.context.traits.email' }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } + } + }, + clientUserId: { '@path': '$.userId' } + } + }, + properties: { + label: 'Properties', + description: 'Metadata to associate with the event.', + type: 'object', + required: false, + default: { + '@path': '$.properties' + } + }, + externalEventId: { + label: 'External Event Id', + description: 'A unique identifier representing this specific event. Should be a UUID format.', + type: 'string', + format: 'uuid', + required: false, + default: { + '@path': '$.messageId' + } + }, + occurredAt: { + label: 'Occurred At', + description: 'Timestamp for the event, ISO 8601 format.', + type: 'string', + required: false, + default: { + '@path': '$.timestamp' + } + } + }, + perform: (request, { payload }) => { + const { + externalEventId, + type, + properties, + occurredAt, + userIdentifiers: { phone, email, clientUserId, ...customIdentifiers } + } = payload + + if (!email && !phone && !clientUserId && Object.keys(customIdentifiers).length === 0) { + throw new PayloadValidationError('At least one user identifier is required.') + } + + const json: CustomEvent = { + type, + properties, + externalEventId, + occurredAt, + user: { + phone, + email, + ...(clientUserId || customIdentifiers + ? { + externalIdentifiers: { + ...(clientUserId ? { clientUserId } : undefined), + ...(Object.entries(customIdentifiers).length>0 ? { customIdentifiers } : undefined) + } + } + : {}) + } as User + } + + return request('https://api.attentivemobile.com/v1/events/custom', { + method: 'post', + json + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/attentive/customEvents/types.ts b/packages/destination-actions/src/destinations/attentive/customEvents/types.ts new file mode 100644 index 0000000000..9eddef039c --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/customEvents/types.ts @@ -0,0 +1,16 @@ +export interface CustomEvent { + type: string + properties?: Record + externalEventId?: string + occurredAt?: string + user: User +} + +export interface User { + phone?: string + email?: string + externalIdentifiers?: { + clientUserId?: string + [key: string]: string | undefined + } +} diff --git a/packages/destination-actions/src/destinations/attentive/generated-types.ts b/packages/destination-actions/src/destinations/attentive/generated-types.ts new file mode 100644 index 0000000000..9ab11e7232 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Attentive API Key. + */ + apiKey: string +} diff --git a/packages/destination-actions/src/destinations/attentive/index.ts b/packages/destination-actions/src/destinations/attentive/index.ts new file mode 100644 index 0000000000..12b4bbe59b --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/index.ts @@ -0,0 +1,52 @@ +import { DestinationDefinition, defaultValues } from '@segment/actions-core' +import type { Settings } from './generated-types' +import customEvents from './customEvents' + +const destination: DestinationDefinition = { + name: 'Attentive', + slug: 'actions-attentive', + mode: 'cloud', + description: 'Send Segment analytics events to Attentive.', + authentication: { + scheme: 'custom', + fields: { + apiKey: { + label: 'API Key', + description: 'Your Attentive API Key.', + type: 'string', + required: true + } + }, + testAuthentication: async (request, { settings }) => { + return request('https://api.attentivemobile.com/v1/me', { + method: 'GET', + headers: { + Authorization: `Bearer ${settings.apiKey}` + } + }) + } + }, + + extendRequest({ settings }) { + return { + headers: { + Authorization: `Bearer ${settings.apiKey}`, + 'Content-Type': 'application/json' + } + } + }, + actions: { + customEvents + }, + presets: [ + { + name: 'Track Event', + subscribe: 'type = "track"', + partnerAction: 'customEvents', + mapping: defaultValues(customEvents.fields), + type: 'automatic' + } + ] +} + +export default destination diff --git a/packages/destination-actions/src/destinations/braze/__tests__/multistatus.test.ts b/packages/destination-actions/src/destinations/braze/__tests__/multistatus.test.ts index a8d05ce4bd..9ae45d53f1 100644 --- a/packages/destination-actions/src/destinations/braze/__tests__/multistatus.test.ts +++ b/packages/destination-actions/src/destinations/braze/__tests__/multistatus.test.ts @@ -88,7 +88,7 @@ describe('MultiStatus', () => { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'One of "external_id" or "user_alias" or "braze_id" or "email" is required.', - errorreporter: 'DESTINATION' + errorreporter: 'INTEGRATIONS' }) }) @@ -326,7 +326,7 @@ describe('MultiStatus', () => { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'This event was not sent to Braze because it did not contain any products.', - errorreporter: 'DESTINATION' + errorreporter: 'INTEGRATIONS' }) // The forth event fails as pre-request validation fails for not having a valid user identifier @@ -334,7 +334,7 @@ describe('MultiStatus', () => { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'One of "external_id" or "user_alias" or "braze_id" or "email" is required.', - errorreporter: 'DESTINATION' + errorreporter: 'INTEGRATIONS' }) }) @@ -441,7 +441,7 @@ describe('MultiStatus', () => { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'This event was not sent to Braze because it did not contain any products.', - errorreporter: 'DESTINATION' + errorreporter: 'INTEGRATIONS' }) }) @@ -547,7 +547,7 @@ describe('MultiStatus', () => { status: 400, errormessage: 'This event was not sent to Braze because it did not contain any products.', errortype: 'PAYLOAD_VALIDATION_FAILED', - errorreporter: 'DESTINATION' + errorreporter: 'INTEGRATIONS' } ]) }) @@ -630,7 +630,7 @@ describe('MultiStatus', () => { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'One of "external_id" or "user_alias" or "braze_id" or "email" is required.', - errorreporter: 'DESTINATION' + errorreporter: 'INTEGRATIONS' }) }) @@ -809,13 +809,13 @@ describe('MultiStatus', () => { expect(response).toMatchObject([ { errormessage: 'Invalid syncMode, must be set to "add" or "update"', - errorreporter: 'DESTINATION', + errorreporter: 'INTEGRATIONS', errortype: 'PAYLOAD_VALIDATION_FAILED', status: 400 }, { errormessage: 'Invalid syncMode, must be set to "add" or "update"', - errorreporter: 'DESTINATION', + errorreporter: 'INTEGRATIONS', errortype: 'PAYLOAD_VALIDATION_FAILED', status: 400 } @@ -880,13 +880,13 @@ describe('MultiStatus', () => { expect(response).toMatchObject([ { errormessage: 'Invalid syncMode, must be set to "add" or "update"', - errorreporter: 'DESTINATION', + errorreporter: 'INTEGRATIONS', errortype: 'PAYLOAD_VALIDATION_FAILED', status: 400 }, { errormessage: 'Invalid syncMode, must be set to "add" or "update"', - errorreporter: 'DESTINATION', + errorreporter: 'INTEGRATIONS', errortype: 'PAYLOAD_VALIDATION_FAILED', status: 400 } @@ -947,13 +947,13 @@ describe('MultiStatus', () => { expect(response).toMatchObject([ { errormessage: 'Invalid syncMode, must be set to "add" or "update"', - errorreporter: 'DESTINATION', + errorreporter: 'INTEGRATIONS', errortype: 'PAYLOAD_VALIDATION_FAILED', status: 400 }, { errormessage: 'Invalid syncMode, must be set to "add" or "update"', - errorreporter: 'DESTINATION', + errorreporter: 'INTEGRATIONS', errortype: 'PAYLOAD_VALIDATION_FAILED', status: 400 } diff --git a/packages/destination-actions/src/destinations/drip/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/drip/__tests__/__snapshots__/snapshot.test.ts.snap index 30351312fc..a9243ea672 100644 --- a/packages/destination-actions/src/destinations/drip/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/drip/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,24 +2,32 @@ exports[`Testing snapshot for drip destination: identify action - all fields 1`] = ` Object { - "custom_fields": Object { - "testType": "XoS!vJs", - }, - "email": "mivsaj@pu.co.uk", - "ip_address": "33.172.51.152", - "sms_number": "XoS!vJs", - "status": "XoS!vJs", - "status_updated_at": "2021-02-01T00:00:00.000Z", - "tags": Array [ - "XoS!vJs", + "subscribers": Array [ + Object { + "custom_fields": Object { + "testType": "XoS!vJs", + }, + "email": "mivsaj@pu.co.uk", + "ip_address": "33.172.51.152", + "sms_number": "XoS!vJs", + "status": "XoS!vJs", + "status_updated_at": "2021-02-01T00:00:00.000Z", + "tags": Array [ + "XoS!vJs", + ], + "time_zone": "XoS!vJs", + }, ], - "time_zone": "XoS!vJs", } `; exports[`Testing snapshot for drip destination: identify action - required fields 1`] = ` Object { - "email": "mivsaj@pu.co.uk", + "subscribers": Array [ + Object { + "email": "mivsaj@pu.co.uk", + }, + ], } `; diff --git a/packages/destination-actions/src/destinations/drip/identify/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/drip/identify/__tests__/__snapshots__/snapshot.test.ts.snap index a46fd4300e..9e665ff322 100644 --- a/packages/destination-actions/src/destinations/drip/identify/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/drip/identify/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,23 +2,31 @@ exports[`Testing snapshot for Drip's identify destination action: all fields 1`] = ` Object { - "custom_fields": Object { - "testType": "DVw6A7$UK[I", - }, - "email": "okavno@kulusof.bg", - "ip_address": "100.102.165.77", - "sms_number": "DVw6A7$UK[I", - "status": "DVw6A7$UK[I", - "status_updated_at": "2021-02-01T00:00:00.000Z", - "tags": Array [ - "DVw6A7$UK[I", + "subscribers": Array [ + Object { + "custom_fields": Object { + "testType": "DVw6A7$UK[I", + }, + "email": "okavno@kulusof.bg", + "ip_address": "100.102.165.77", + "sms_number": "DVw6A7$UK[I", + "status": "DVw6A7$UK[I", + "status_updated_at": "2021-02-01T00:00:00.000Z", + "tags": Array [ + "DVw6A7$UK[I", + ], + "time_zone": "DVw6A7$UK[I", + }, ], - "time_zone": "DVw6A7$UK[I", } `; exports[`Testing snapshot for Drip's identify destination action: required fields 1`] = ` Object { - "email": "okavno@kulusof.bg", + "subscribers": Array [ + Object { + "email": "okavno@kulusof.bg", + }, + ], } `; diff --git a/packages/destination-actions/src/destinations/drip/identify/__tests__/index.test.ts b/packages/destination-actions/src/destinations/drip/identify/__tests__/index.test.ts index 204ee624c2..2870434a71 100644 --- a/packages/destination-actions/src/destinations/drip/identify/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/drip/identify/__tests__/index.test.ts @@ -24,7 +24,15 @@ describe('Drip.identify', () => { phone: '1234567890', status: 'unsubscribed', status_updated_at: '2021-01-01T00:00:00Z', - custom_fields: { fizz: 'buzz', numb:1234, bool:true, oppBool:false, arr: ["hello", 1234, false], obj: { key: 'value' }, null: null }, + custom_fields: { + fizz: 'buzz', + numb: 1234, + bool: true, + oppBool: false, + arr: ['hello', 1234, false], + obj: { key: 'value' }, + null: null + }, tags: 'tag1,tag2' } }) @@ -36,14 +44,25 @@ describe('Drip.identify', () => { }) const body = { - custom_fields: { fizz: 'buzz', numb:"1234", bool:"true", oppBool:"false", arr: "[\"hello\",1234,false]", obj: "{\"key\":\"value\"}" }, - email: 'test@example.com', - ip_address: '127.0.0.1', - sms_number: '1234567890', - status: 'unsubscribed', - status_updated_at: '2021-01-01T00:00:00Z', - tags: ['tag1', 'tag2'], - time_zone: 'Europe/Amsterdam' + subscribers: [ + { + custom_fields: { + fizz: 'buzz', + numb: '1234', + bool: 'true', + oppBool: 'false', + arr: '["hello",1234,false]', + obj: '{"key":"value"}' + }, + email: 'test@example.com', + ip_address: '127.0.0.1', + sms_number: '1234567890', + status: 'unsubscribed', + status_updated_at: '2021-01-01T00:00:00Z', + tags: ['tag1', 'tag2'], + time_zone: 'Europe/Amsterdam' + } + ] } expect(responses.length).toBe(1) diff --git a/packages/destination-actions/src/destinations/drip/identify/index.ts b/packages/destination-actions/src/destinations/drip/identify/index.ts index 2e0faa9dd3..406c6a05a3 100644 --- a/packages/destination-actions/src/destinations/drip/identify/index.ts +++ b/packages/destination-actions/src/destinations/drip/identify/index.ts @@ -11,7 +11,7 @@ const person = (payload: Payload) => { .map(([key, value]) => [key, typeof value === 'object' ? JSON.stringify(value) : String(value)]) ) return Object.keys(result).length > 0 ? result : undefined - })(), + })(), email: payload.email, ip_address: payload.ip, sms_number: payload.phone, @@ -95,7 +95,7 @@ const action: ActionDefinition = { perform: (request, { settings, payload }) => { return request(`https://api.getdrip.com/v2/${settings.accountId}/subscribers`, { method: 'POST', - json: person(payload) + json: { subscribers: [person(payload)] } }) }, performBatch: (request, { settings, payload }) => { diff --git a/packages/destination-actions/src/destinations/drip/index.ts b/packages/destination-actions/src/destinations/drip/index.ts index 3aee56ffa8..132ca6dcef 100644 --- a/packages/destination-actions/src/destinations/drip/index.ts +++ b/packages/destination-actions/src/destinations/drip/index.ts @@ -7,7 +7,7 @@ const destination: DestinationDefinition = { name: 'Drip (Actions)', slug: 'actions-drip', mode: 'cloud', - description: 'Send Segment events to Drip', + description: 'Send Segment analytics events and user profile details to Drip', authentication: { scheme: 'custom', fields: { diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList.test.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList.test.ts index 3acfa309e4..0a423c3c0d 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList.test.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/userList.test.ts @@ -10,7 +10,7 @@ const customerId = '1234' describe('GoogleEnhancedConversions', () => { describe('userList', () => { - it('sends an event with default mappings', async () => { + it('sends an event with default mappings - event = Audience Entered', async () => { const event = createTestEvent({ timestamp, event: 'Audience Entered', @@ -73,6 +73,325 @@ describe('GoogleEnhancedConversions', () => { ) }) + it('sends an event with default mappings - event = Audience Exited', async () => { + const event = createTestEvent({ + timestamp, + event: 'Audience Exited', + properties: { + gclid: '54321', + email: 'test@gmail.com', + orderId: '1234', + phone: '1234567890', + firstName: 'Jane', + lastName: 'Doe', + currency: 'USD', + value: '123', + address: { + street: '123 Street SW', + city: 'San Diego', + state: 'CA', + postalCode: '982004' + } + } + }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + external_audience_id: '1234', + retlOnMappingSave: { + outputs: { + id: '1234', + name: 'Test List', + external_id_type: 'CONTACT_INFO' + } + } + }, + useDefaultMappings: true, + settings: { + customerId + } + }) + + expect(responses.length).toEqual(3) + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"job\\":{\\"type\\":\\"CUSTOMER_MATCH_USER_LIST\\",\\"customerMatchUserListMetadata\\":{\\"userList\\":\\"customers/1234/userLists/1234\\",\\"consent\\":{\\"adUserData\\":\\"GRANTED\\",\\"adPersonalization\\":\\"GRANTED\\"}}}}"` + ) + expect(responses[1].options.body).toMatchInlineSnapshot( + `"{\\"operations\\":[{\\"remove\\":{\\"userIdentifiers\\":[{\\"hashedEmail\\":\\"87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674\\"},{\\"hashedPhoneNumber\\":\\"422ce82c6fc1724ac878042f7d055653ab5e983d186e616826a72d4384b68af8\\"},{\\"addressInfo\\":{\\"hashedFirstName\\":\\"4f23798d92708359b734a18172c9c864f1d48044a754115a0d4b843bca3a5332\\",\\"hashedLastName\\":\\"fd53ef835b15485572a6e82cf470dcb41fd218ae5751ab7531c956a2a6bcd3c7\\",\\"countryCode\\":\\"\\",\\"postalCode\\":\\"\\"}}]}}],\\"enable_warnings\\":true}"` + ) + }) + + it('sends an event with default mappings - syncMode = add', async () => { + const event = createTestEvent({ + timestamp, + event: 'Test Event', + properties: { + gclid: '54321', + email: 'test@gmail.com', + orderId: '1234', + phone: '1234567890', + firstName: 'Jane', + lastName: 'Doe', + currency: 'USD', + value: '123', + address: { + street: '123 Street SW', + city: 'San Diego', + state: 'CA', + postalCode: '982004' + } + } + }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + external_audience_id: '1234', + __segment_internal_sync_mode: 'add', + retlOnMappingSave: { + outputs: { + id: '1234', + name: 'Test List', + external_id_type: 'CONTACT_INFO' + } + } + }, + useDefaultMappings: true, + settings: { + customerId + } + }) + + expect(responses.length).toEqual(3) + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"job\\":{\\"type\\":\\"CUSTOMER_MATCH_USER_LIST\\",\\"customerMatchUserListMetadata\\":{\\"userList\\":\\"customers/1234/userLists/1234\\",\\"consent\\":{\\"adUserData\\":\\"GRANTED\\",\\"adPersonalization\\":\\"GRANTED\\"}}}}"` + ) + expect(responses[1].options.body).toMatchInlineSnapshot( + `"{\\"operations\\":[{\\"create\\":{\\"userIdentifiers\\":[{\\"hashedEmail\\":\\"87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674\\"},{\\"hashedPhoneNumber\\":\\"422ce82c6fc1724ac878042f7d055653ab5e983d186e616826a72d4384b68af8\\"},{\\"addressInfo\\":{\\"hashedFirstName\\":\\"4f23798d92708359b734a18172c9c864f1d48044a754115a0d4b843bca3a5332\\",\\"hashedLastName\\":\\"fd53ef835b15485572a6e82cf470dcb41fd218ae5751ab7531c956a2a6bcd3c7\\",\\"countryCode\\":\\"\\",\\"postalCode\\":\\"\\"}}]}}],\\"enable_warnings\\":true}"` + ) + }) + + it('sends an event with default mappings - syncMode = mirror and event = new', async () => { + const event = createTestEvent({ + timestamp, + event: 'new', + properties: { + gclid: '54321', + email: 'test@gmail.com', + orderId: '1234', + phone: '1234567890', + firstName: 'Jane', + lastName: 'Doe', + currency: 'USD', + value: '123', + address: { + street: '123 Street SW', + city: 'San Diego', + state: 'CA', + postalCode: '982004' + } + } + }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + external_audience_id: '1234', + __segment_internal_sync_mode: 'mirror', + retlOnMappingSave: { + outputs: { + id: '1234', + name: 'Test List', + external_id_type: 'CONTACT_INFO' + } + } + }, + useDefaultMappings: true, + settings: { + customerId + } + }) + + expect(responses.length).toEqual(3) + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"job\\":{\\"type\\":\\"CUSTOMER_MATCH_USER_LIST\\",\\"customerMatchUserListMetadata\\":{\\"userList\\":\\"customers/1234/userLists/1234\\",\\"consent\\":{\\"adUserData\\":\\"GRANTED\\",\\"adPersonalization\\":\\"GRANTED\\"}}}}"` + ) + expect(responses[1].options.body).toMatchInlineSnapshot( + `"{\\"operations\\":[{\\"create\\":{\\"userIdentifiers\\":[{\\"hashedEmail\\":\\"87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674\\"},{\\"hashedPhoneNumber\\":\\"422ce82c6fc1724ac878042f7d055653ab5e983d186e616826a72d4384b68af8\\"},{\\"addressInfo\\":{\\"hashedFirstName\\":\\"4f23798d92708359b734a18172c9c864f1d48044a754115a0d4b843bca3a5332\\",\\"hashedLastName\\":\\"fd53ef835b15485572a6e82cf470dcb41fd218ae5751ab7531c956a2a6bcd3c7\\",\\"countryCode\\":\\"\\",\\"postalCode\\":\\"\\"}}]}}],\\"enable_warnings\\":true}"` + ) + }) + + it('sends an event with default mappings - syncMode = mirror and event = deleted', async () => { + const event = createTestEvent({ + timestamp, + event: 'deleted', + properties: { + gclid: '54321', + email: 'test@gmail.com', + orderId: '1234', + phone: '1234567890', + firstName: 'Jane', + lastName: 'Doe', + currency: 'USD', + value: '123', + address: { + street: '123 Street SW', + city: 'San Diego', + state: 'CA', + postalCode: '982004' + } + } + }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + external_audience_id: '1234', + __segment_internal_sync_mode: 'mirror', + retlOnMappingSave: { + outputs: { + id: '1234', + name: 'Test List', + external_id_type: 'CONTACT_INFO' + } + } + }, + useDefaultMappings: true, + settings: { + customerId + } + }) + + expect(responses.length).toEqual(3) + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"job\\":{\\"type\\":\\"CUSTOMER_MATCH_USER_LIST\\",\\"customerMatchUserListMetadata\\":{\\"userList\\":\\"customers/1234/userLists/1234\\",\\"consent\\":{\\"adUserData\\":\\"GRANTED\\",\\"adPersonalization\\":\\"GRANTED\\"}}}}"` + ) + expect(responses[1].options.body).toMatchInlineSnapshot( + `"{\\"operations\\":[{\\"remove\\":{\\"userIdentifiers\\":[{\\"hashedEmail\\":\\"87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674\\"},{\\"hashedPhoneNumber\\":\\"422ce82c6fc1724ac878042f7d055653ab5e983d186e616826a72d4384b68af8\\"},{\\"addressInfo\\":{\\"hashedFirstName\\":\\"4f23798d92708359b734a18172c9c864f1d48044a754115a0d4b843bca3a5332\\",\\"hashedLastName\\":\\"fd53ef835b15485572a6e82cf470dcb41fd218ae5751ab7531c956a2a6bcd3c7\\",\\"countryCode\\":\\"\\",\\"postalCode\\":\\"\\"}}]}}],\\"enable_warnings\\":true}"` + ) + }) + + it('sends an event with default mappings - syncMode = delete', async () => { + const event = createTestEvent({ + timestamp, + event: 'Test Event', + properties: { + gclid: '54321', + email: 'test@gmail.com', + orderId: '1234', + phone: '1234567890', + firstName: 'Jane', + lastName: 'Doe', + currency: 'USD', + value: '123', + address: { + street: '123 Street SW', + city: 'San Diego', + state: 'CA', + postalCode: '982004' + } + } + }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}/offlineUserDataJobs:create`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:addOperations`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/offlineDataJob:run`) + .post(/.*/) + .reply(200, { data: 'offlineDataJob' }) + + const responses = await testDestination.testAction('userList', { + event, + mapping: { + ad_user_data_consent_state: 'GRANTED', + ad_personalization_consent_state: 'GRANTED', + external_audience_id: '1234', + __segment_internal_sync_mode: 'delete', + retlOnMappingSave: { + outputs: { + id: '1234', + name: 'Test List', + external_id_type: 'CONTACT_INFO' + } + } + }, + useDefaultMappings: true, + settings: { + customerId + } + }) + + expect(responses.length).toEqual(3) + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"job\\":{\\"type\\":\\"CUSTOMER_MATCH_USER_LIST\\",\\"customerMatchUserListMetadata\\":{\\"userList\\":\\"customers/1234/userLists/1234\\",\\"consent\\":{\\"adUserData\\":\\"GRANTED\\",\\"adPersonalization\\":\\"GRANTED\\"}}}}"` + ) + expect(responses[1].options.body).toMatchInlineSnapshot( + `"{\\"operations\\":[{\\"remove\\":{\\"userIdentifiers\\":[{\\"hashedEmail\\":\\"87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674\\"},{\\"hashedPhoneNumber\\":\\"422ce82c6fc1724ac878042f7d055653ab5e983d186e616826a72d4384b68af8\\"},{\\"addressInfo\\":{\\"hashedFirstName\\":\\"4f23798d92708359b734a18172c9c864f1d48044a754115a0d4b843bca3a5332\\",\\"hashedLastName\\":\\"fd53ef835b15485572a6e82cf470dcb41fd218ae5751ab7531c956a2a6bcd3c7\\",\\"countryCode\\":\\"\\",\\"postalCode\\":\\"\\"}}]}}],\\"enable_warnings\\":true}"` + ) + }) + it('handles concurrent_modification error correctly', async () => { const events: SegmentEvent[] = [ createTestEvent({ diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index 608728117f..d8c7319e70 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -486,9 +486,17 @@ const extractUserIdentifiers = (payloads: UserListPayload[], idType: string, syn } // Map user data to Google Ads API format for (const payload of payloads) { - if (payload.event_name == 'Audience Entered' || syncMode == 'add') { + if ( + payload.event_name === 'Audience Entered' || + syncMode === 'add' || + (syncMode === 'mirror' && payload.event_name === 'new') + ) { addUserIdentifiers.push({ create: { userIdentifiers: identifierFunctions[idType](payload) } }) - } else if (payload.event_name == 'Audience Exited' || syncMode == 'delete') { + } else if ( + payload.event_name === 'Audience Exited' || + syncMode === 'delete' || + (syncMode === 'mirror' && payload.event_name === 'deleted') + ) { removeUserIdentifiers.push({ remove: { userIdentifiers: identifierFunctions[idType](payload) } }) } } diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts index 92c5021260..8aac255047 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/index.ts @@ -15,7 +15,8 @@ const action: ActionDefinition = { default: 'add', choices: [ { label: 'Adds users to the connected Google Customer Match User List', value: 'add' }, - { label: 'Remove users from the connected Google Customer Match User List', value: 'delete' } + { label: 'Remove users from the connected Google Customer Match User List', value: 'delete' }, + { label: 'Add and remove users in the connected Google Customer Match User List', value: 'mirror' } ] }, fields: { diff --git a/packages/destination-actions/src/destinations/hubspot/customEvent/functions/dynamic-field-functions.ts b/packages/destination-actions/src/destinations/hubspot/customEvent/functions/dynamic-field-functions.ts index f465286e47..8004cda3f9 100644 --- a/packages/destination-actions/src/destinations/hubspot/customEvent/functions/dynamic-field-functions.ts +++ b/packages/destination-actions/src/destinations/hubspot/customEvent/functions/dynamic-field-functions.ts @@ -78,7 +78,7 @@ async function dynamicReadEventNames(request: RequestClient): Promise { interface ResultItem { labels: { singular: string; plural: string } - name: string + fullyQualifiedName: string } interface ResponseType { @@ -96,7 +96,7 @@ async function dynamicReadObjectTypes(request: RequestClient): Promise ({ label: `${schema.labels.plural} (Custom)`, - value: schema.name + value: schema.fullyQualifiedName })) return { choices: [...choices, ...defaultChoices].sort((a, b) => diff --git a/packages/destination-actions/src/destinations/rokt-audiences/__tests__/index.test.ts b/packages/destination-actions/src/destinations/rokt-audiences/__tests__/index.test.ts index 2009fb2f1f..679f08265b 100644 --- a/packages/destination-actions/src/destinations/rokt-audiences/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/rokt-audiences/__tests__/index.test.ts @@ -6,7 +6,9 @@ import { CONSTANTS } from '../constants' const testDestination = createTestIntegration(Definition) const VALID_SETTINGS = { - apiKey: 'key' + rpub: 'rpub-***', + rsec: 'rsec-***', + accountid: '8675309' } const MOCK_TOKEN_RESPONSE = { @@ -15,14 +17,14 @@ const MOCK_TOKEN_RESPONSE = { describe('Rokt Audiences', () => { describe('testAuthentication', () => { - it('should validate valid auth token', async () => { - nock(CONSTANTS.ROKT_API_BASE_URL).get(CONSTANTS.ROKT_API_AUTH_ENDPOINT).reply(200, MOCK_TOKEN_RESPONSE) + it('should validate proper rpub-rsec credentials', async () => { + nock(CONSTANTS.ROKT_API_BASE_URL).post(CONSTANTS.ROKT_API_AUTH_ENDPOINT).reply(200, MOCK_TOKEN_RESPONSE) const settings = VALID_SETTINGS await expect(testDestination.testAuthentication(settings)).resolves.not.toThrowError() }) it('should test that authentication fails', async () => { - nock(CONSTANTS.ROKT_API_BASE_URL).get(CONSTANTS.ROKT_API_AUTH_ENDPOINT).reply(401) + nock(CONSTANTS.ROKT_API_BASE_URL).post(CONSTANTS.ROKT_API_AUTH_ENDPOINT).reply(401) const settings = VALID_SETTINGS await expect(testDestination.testAuthentication(settings)).rejects.toThrowError('') }) diff --git a/packages/destination-actions/src/destinations/rokt-audiences/constants.ts b/packages/destination-actions/src/destinations/rokt-audiences/constants.ts index de0194a921..a0895a0257 100644 --- a/packages/destination-actions/src/destinations/rokt-audiences/constants.ts +++ b/packages/destination-actions/src/destinations/rokt-audiences/constants.ts @@ -2,7 +2,7 @@ export const CONSTANTS = { // rokt INCLUDE: 'include', EXCLUDE: 'exclude', - ROKT_API_BASE_URL: 'https://data.rokt.com/api/1.0', + ROKT_API_BASE_URL: 'https://data.rokt.com/v3', ROKT_API_CUSTOM_AUDIENCE_ENDPOINT: '/import/suppression', ROKT_API_AUTH_ENDPOINT: '/auth-check', diff --git a/packages/destination-actions/src/destinations/rokt-audiences/generated-types.ts b/packages/destination-actions/src/destinations/rokt-audiences/generated-types.ts index fb5cbba98e..5ca7916638 100644 --- a/packages/destination-actions/src/destinations/rokt-audiences/generated-types.ts +++ b/packages/destination-actions/src/destinations/rokt-audiences/generated-types.ts @@ -2,7 +2,15 @@ export interface Settings { /** - * APIKey used for Rokt API authorization before sending custom audiences data + * Rokt public key, starts with `rpub-` */ - apiKey: string + rpub: string + /** + * Rokt secret key, starts with `rsec-` + */ + rsec: string + /** + * Rokt ID assigned to your particular account. + */ + accountid: string } diff --git a/packages/destination-actions/src/destinations/rokt-audiences/index.ts b/packages/destination-actions/src/destinations/rokt-audiences/index.ts index bd9211f13f..d77e79af3e 100644 --- a/packages/destination-actions/src/destinations/rokt-audiences/index.ts +++ b/packages/destination-actions/src/destinations/rokt-audiences/index.ts @@ -1,3 +1,4 @@ +import btoa from 'btoa-lite' import { defaultValues, DestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' @@ -9,26 +10,40 @@ const destination: DestinationDefinition = { slug: 'actions-rokt-audiences', mode: 'cloud', description: ` - This destination allows user to engage audiences using Rokt public API. - User can connect Rokt Audiences (Actions) as a destination to their Engage Audience in segment, - which will create/update custom audiences in the Rokt data platform. + This destination allows user to engage audiences using Rokt's Public APIs. + User can connect Rokt Audiences (Actions) as a destination to their Engage Audience in Segment, + which will create/update custom audiences in the Rokt data platform. `, authentication: { scheme: 'custom', fields: { - apiKey: { - label: 'API Key provided by Rokt integration', - description: 'APIKey used for Rokt API authorization before sending custom audiences data', + rpub: { + label: 'Rokt public key', + description: 'Rokt public key, starts with `rpub-`', + type: 'string', + required: true + }, + rsec: { + label: 'Rokt secret key', + description: 'Rokt secret key, starts with `rsec-`', type: 'password', required: true + }, + accountid: { + label: 'Rokt Account ID', + description: 'Rokt ID assigned to your particular account.', + type: 'string', + required: true } }, - testAuthentication: async (request, { settings }) => { - return request(CONSTANTS.ROKT_API_BASE_URL + CONSTANTS.ROKT_API_AUTH_ENDPOINT, { - method: 'GET', + return await request(CONSTANTS.ROKT_API_BASE_URL + CONSTANTS.ROKT_API_AUTH_ENDPOINT, { + method: 'POST', headers: { - Authorization: `${settings.apiKey}` + Authorization: `Basic ${btoa(settings.rpub + ':' + settings.rsec)}` + }, + json: { + accountId: settings.accountid } }) } @@ -36,7 +51,7 @@ const destination: DestinationDefinition = { extendRequest({ settings }) { return { - headers: { Authorization: `${settings.apiKey}` } + headers: { Authorization: `Basic ${btoa(settings.rpub + ':' + settings.rsec)}` } } }, actions: { diff --git a/packages/destination-actions/src/destinations/rokt-audiences/upsertCustomAudiences/custom-audience-operations.ts b/packages/destination-actions/src/destinations/rokt-audiences/upsertCustomAudiences/custom-audience-operations.ts index 1d76d5615c..806b0eb760 100644 --- a/packages/destination-actions/src/destinations/rokt-audiences/upsertCustomAudiences/custom-audience-operations.ts +++ b/packages/destination-actions/src/destinations/rokt-audiences/upsertCustomAudiences/custom-audience-operations.ts @@ -1,26 +1,29 @@ import type { RequestClient } from '@segment/actions-core' +import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { CONSTANTS } from '../constants' /** * CustomAudienceOperation is a custom type to encapsulate the request body params - * for inserting/updating custom audience list in rokt data platform + * for inserting/updating custom audience list in Rokt data platform + * @accountid the Rokt account * @action [include, exclude] - * @list custom audience name ( or list ) in rokt data platform + * @list custom audience name ( or list ) in Rokt data platform * @emails list of user emails to be included/excluded from custom audience list */ type CustomAudienceOperation = { + accountId: string action: string list: string emails: string[] } /** - * getCustomAudienceOperations parses event payloads from segment to convert to request object for rokt api + * getCustomAudienceOperations parses event payloads from segment to convert to request object for Rokt api * @payload payload of events */ -const getCustomAudienceOperations = (payload: Payload[]): CustomAudienceOperation[] => { +const getCustomAudienceOperations = (payload: Payload[], settings: Settings): CustomAudienceOperation[] => { // map to handle different audiences in the batch // this will contain audience_name=>[action=>emails] const audience_map = new Map>([]) @@ -54,13 +57,15 @@ const getCustomAudienceOperations = (payload: Payload[]): CustomAudienceOperatio } } - // build operation request to be sent to rokt api + // build operation request to be sent to Rokt api const custom_audience_ops: CustomAudienceOperation[] = [] + audience_map.forEach((action_map_values: Map, list: string) => { // key will be audience list // value will map of action=>email_list action_map_values.forEach((emails: string[], action: string) => { const custom_audience_op: CustomAudienceOperation = { + accountId: settings.accountid, list: list, action: action, emails: emails @@ -72,19 +77,19 @@ const getCustomAudienceOperations = (payload: Payload[]): CustomAudienceOperatio } /** - * Takes an array of events of type Payload, decides whether event is meant for include/exclude action of rokt api + * Takes an array of events of type Payload, decides whether event is meant for include/exclude action of Rokt api * and then pushes the event to proper list to build request body. * @param request request object used to perform HTTP calls * @param events array of events containing Rokt custom audience details */ -async function processPayload(request: RequestClient, events: Payload[]) { - const custom_audience_ops: CustomAudienceOperation[] = getCustomAudienceOperations(events) +async function processPayload(request: RequestClient, settings: Settings, events: Payload[]) { + const custom_audience_ops: CustomAudienceOperation[] = getCustomAudienceOperations(events, settings) const promises = [] for (const op of custom_audience_ops) { if (op.emails.length > 0) { - // if emails are present for action, send to rokt. Push to list of promises - // There will be max 2 promies for 2 http reuests ( include & exclude actions ) + // if emails are present for action, send to Rokt. Push to list of promises + // There will be max 2 promises for 2 http requests ( include & exclude actions ) promises.push( request(CONSTANTS.ROKT_API_BASE_URL + CONSTANTS.ROKT_API_CUSTOM_AUDIENCE_ENDPOINT, { method: 'POST', diff --git a/packages/destination-actions/src/destinations/rokt-audiences/upsertCustomAudiences/index.ts b/packages/destination-actions/src/destinations/rokt-audiences/upsertCustomAudiences/index.ts index 19416e9c79..7a6fc37e66 100644 --- a/packages/destination-actions/src/destinations/rokt-audiences/upsertCustomAudiences/index.ts +++ b/packages/destination-actions/src/destinations/rokt-audiences/upsertCustomAudiences/index.ts @@ -62,12 +62,12 @@ const action: ActionDefinition = { } }, - perform: (request, { payload }) => { - return processPayload(request, [payload]) + perform: (request, { settings, payload }) => { + return processPayload(request, settings, [payload]) }, - performBatch: (request, { payload }) => { - return processPayload(request, payload) + performBatch: (request, { settings, payload }) => { + return processPayload(request, settings, payload) } } diff --git a/packages/destination-actions/src/destinations/sendgrid-audiences/constants.ts b/packages/destination-actions/src/destinations/sendgrid-audiences/constants.ts index 2c5474a4cc..e1bf31101d 100644 --- a/packages/destination-actions/src/destinations/sendgrid-audiences/constants.ts +++ b/packages/destination-actions/src/destinations/sendgrid-audiences/constants.ts @@ -12,4 +12,6 @@ export const GET_CONTACT_BY_EMAIL_URL = 'https://api.sendgrid.com/v3/marketing/c export const SEARCH_CONTACTS_URL = 'https://api.sendgrid.com/v3/marketing/contacts/search' -export const MAX_CHUNK_SIZE_SEARCH = 50 \ No newline at end of file +export const MAX_CHUNK_SIZE_SEARCH = 50 + +export const GET_CUSTOM_FIELDS_URL = 'https://api.sendgrid.com/v3/marketing/field_definitions' \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/__tests__/index.test.ts index 0be6dacb39..8f7d1da0c5 100644 --- a/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/__tests__/index.test.ts @@ -61,6 +61,17 @@ const mapping = { anonymous_id: { '@path': '$.anonymousId' }, external_id: { '@path': '$.traits.external_id' }, phone_number_id: { '@path': '$.traits.phone' }, + user_attributes: { + first_name: { '@path': '$.traits.first_name' }, + last_name: { '@path': '$.traits.last_name' }, + address_line_1: { '@path': '$.traits.street' }, + address_line_2: { '@path': '$.traits.address_line_2' }, + city: { '@path': '$.traits.city' }, + state_province_region: { '@path': '$.traits.state' }, + country: { '@path': '$.traits.country' }, + postal_code: { '@path': '$.traits.postal_code' } + }, + custom_fields: { '@path': '$.traits.custom_fields' }, enable_batching: true, batch_size: 200 } @@ -97,6 +108,65 @@ describe('SendgridAudiences.syncAudience', () => { expect(responses[0].status).toBe(200) }) + it('should upsert a single Contact with user attributes and custom fields, and add it to a Sendgrid list correctly', async () => { + const event = createTestEvent({ + ...addPayload, + traits: { + ...addPayload.traits, + first_name: 'fname', + last_name: 'lname', + street: '123 Main St', + address_line_2: 123456, // should be stringified + city: 'SF', + state: 'CA', + country: 'US', + postal_code: "N88EU", + custom_fields: { + custom_field_1: 'custom_field_1_value', + custom_field_2: 2345, + custom_field_3: '2024-01-01T00:00:00.000Z', + custom_field_4: false, // should be removed + custom_field_5: null // should be removed + } + } + }) + + const addExpectedPayloadWithAttributes = { + ...addExpectedPayload, + contacts: [ + { + email: 'testemail@gmail.com', + external_id: 'some_external_id', + phone_number_id: '+353123456789', + anonymous_id: 'some_anonymous_id', + first_name: 'fname', + last_name: 'lname', + address_line_1: '123 Main St', + address_line_2: '123456', + city: 'SF', + state_province_region: 'CA', + country: 'US', + postal_code: "N88EU", + custom_fields: { + custom_field_1: 'custom_field_1_value', + custom_field_2: 2345, + custom_field_3: '2024-01-01T00:00:00.000Z' + } + } + ] + } + + nock('https://api.sendgrid.com').put('/v3/marketing/contacts', addExpectedPayloadWithAttributes).reply(200, {}) + const responses = await testDestination.testAction('syncAudience', { + event, + settings, + useDefaultMappings: true, + mapping + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + it('should upsert a single Contact with just email, and add it to a Sendgrid list correctly', async () => { const addPayloadCopy = JSON.parse(JSON.stringify(addPayload)) diff --git a/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/dynamic-fields.ts b/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/dynamic-fields.ts new file mode 100644 index 0000000000..658ba25dba --- /dev/null +++ b/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/dynamic-fields.ts @@ -0,0 +1,56 @@ + +import { RequestClient } from '@segment/actions-core' +import { DynamicFieldResponse } from '@segment/actions-core' +import { GET_CUSTOM_FIELDS_URL } from '../constants' +import { Payload } from './generated-types' + +export async function dynamicCustomFields(request: RequestClient, payload: Payload): Promise { + interface ResultItem { + id: string + name: string + field_type: string + } + interface ResponseType { + data: { + custom_fields: Array + } + } + interface ResultError { + response: { + data: { + errors: Array<{ message: string }> + }, + status: number + } + } + + try { + const response: ResponseType = await request(GET_CUSTOM_FIELDS_URL, { + method: 'GET', + skipResponseCloning: true + }) + + const allFields = response.data.custom_fields.map(field => field.name) + const selectedFields = new Set(Object.keys(payload.custom_fields ?? {})) + const availableFields = allFields.filter(field => !selectedFields.has(field)) + + return { + choices: availableFields.map( + fieldName => ({ + label: fieldName, + value: fieldName + }) + ) + } + + } catch (err) { + const error = err as ResultError + return { + choices: [], + error: { + message: error.response.data.errors.map((e) => e.message).join(';') ?? 'Unknown error: dynamicCustomFields', + code: String(error.response.status) + } + } + } + } \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/fields.ts b/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/fields.ts index e97d620bd9..f30954a00c 100644 --- a/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/fields.ts +++ b/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/fields.ts @@ -95,6 +95,123 @@ export const fields: Record = { } } }, + user_attributes: { + label: 'User Attributes', + description: `Additional user attributes to be included in the request.`, + type: 'object', + required: false, + defaultObjectUI: 'keyvalue', + additionalProperties: false, + properties: { + first_name: { + label: 'First Name', + description: `The contact's first name.`, + type: 'string' + }, + last_name: { + label: 'Last Name', + description: `The contact's last name.`, + type: 'string' + }, + address_line_1: { + label: 'Address Line 1', + description: `The contact's address line 1.`, + type: 'string' + }, + address_line_2: { + label: 'Address Line 2', + description: `The contact's address line 2.`, + type: 'string' + }, + city: { + label: 'City', + description: `The contact's city.`, + type: 'string' + }, + state_province_region: { + label: 'State/Province/Region', + description: `The contact's state, province, or region.`, + type: 'string' + }, + country: { + label: 'Country', + description: `The contact's country.`, + type: 'string' + }, + postal_code: { + label: 'Postal Code', + description: `The contact's postal code.`, + type: 'string' + } + }, + default: { + first_name: { + '@if': { + exists: { '@path': '$.traits.first_name' }, + then: { '@path': '$.traits.first_name' }, + else: { '@path': '$.properties.first_name' } + } + }, + last_name: { + '@if': { + exists: { '@path': '$.traits.last_name' }, + then: { '@path': '$.traits.last_name' }, + else: { '@path': '$.properties.last_name' } + } + }, + address_line_1: { + '@if': { + exists: { '@path': '$.traits.street' }, + then: { '@path': '$.traits.street' }, + else: { '@path': '$.properties.street' } + } + }, + address_line_2: { + '@if': { + exists: { '@path': '$.traits.address_line_2' }, + then: { '@path': '$.traits.address_line_2' }, + else: { '@path': '$.properties.address_line_2' } + } + }, + city: { + '@if': { + exists: { '@path': '$.traits.city' }, + then: { '@path': '$.traits.city' }, + else: { '@path': '$.properties.city' } + } + }, + state_province_region: { + '@if': { + exists: { '@path': '$.traits.state' }, + then: { '@path': '$.traits.state' }, + else: { '@path': '$.properties.state' } + } + }, + country: { + '@if': { + exists: { '@path': '$.traits.country' }, + then: { '@path': '$.traits.country' }, + else: { '@path': '$.properties.country' } + } + }, + postal_code: { + '@if': { + exists: { '@path': '$.traits.postal_code' }, + then: { '@path': '$.traits.postal_code' }, + else: { '@path': '$.properties.postal_code' } + } + } + } + }, + custom_fields: { + label: 'Custom Fields', + description: `Custom Field values to be added to the Contact. The Custom Fields must already be defined in Sendgrid. Custom Field values must be string, numbers or dates.`, + type: 'object', + required: false, + defaultObjectUI: 'keyvalue', + additionalProperties: true, + dynamic: true + }, enable_batching: { type: 'boolean', label: 'Batch events', diff --git a/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/generated-types.ts b/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/generated-types.ts index d984f36b23..73772b7880 100644 --- a/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/generated-types.ts +++ b/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/generated-types.ts @@ -35,6 +35,49 @@ export interface Payload { * The contact's primary phone number. Should include the country code e.g. +19876543213. */ phone_number_id?: string + /** + * Additional user attributes to be included in the request. + */ + user_attributes?: { + /** + * The contact's first name. + */ + first_name?: string + /** + * The contact's last name. + */ + last_name?: string + /** + * The contact's address line 1. + */ + address_line_1?: string + /** + * The contact's address line 2. + */ + address_line_2?: string + /** + * The contact's city. + */ + city?: string + /** + * The contact's state, province, or region. + */ + state_province_region?: string + /** + * The contact's country. + */ + country?: string + /** + * The contact's postal code. + */ + postal_code?: string + } + /** + * Custom Field values to be added to the Contact. The Custom Fields must already be defined in Sendgrid. Custom Field values must be string, numbers or dates. + */ + custom_fields?: { + [k: string]: unknown + } /** * When enabled, the action will batch events before sending them to Sendgrid. */ diff --git a/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/index.ts b/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/index.ts index 5112e031ab..58fb8997d8 100644 --- a/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/index.ts +++ b/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/index.ts @@ -3,12 +3,20 @@ import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { send } from './utils' import { fields } from './fields' +import { dynamicCustomFields } from './dynamic-fields' const action: ActionDefinition = { title: 'Sync Audience', description: 'Sync a Segment Engage Audience to a Sendgrid List', defaultSubscription: 'type = "identify" or type = "track"', fields, + dynamicFields: { + custom_fields: { + __keys__: async (request, { payload }) => { + return await dynamicCustomFields(request, payload) + } + } + }, perform: async (request, { payload }) => { return await send(request, [payload]) }, diff --git a/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/utils.ts b/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/utils.ts index 6653734c1d..123a76697c 100644 --- a/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/utils.ts +++ b/packages/destination-actions/src/destinations/sendgrid-audiences/syncAudience/utils.ts @@ -101,6 +101,24 @@ function validate(payloads: Payload[], ignoreErrors: boolean, invalidEmails?: st if (p.email && invalidEmails?.includes(p.email)) { delete p.email } + + if (p.custom_fields) { + p.custom_fields = Object.fromEntries( + Object.entries(p.custom_fields).filter(([_, value]) => typeof value === 'string' || typeof value === 'number') + ) + } + + if (p.user_attributes) { + p.user_attributes = Object.fromEntries( + Object.entries(p.user_attributes ?? {}).map(([key, value]) => { + if (typeof value !== 'string') { + return [key, String(value)] + } + return [key, value] + }) + ); + } + const hasRequiredField = [p.email, p.anonymous_id, p.external_id, p.phone_number_id].some(Boolean) if (!hasRequiredField && !ignoreErrors) { throw new PayloadValidationError( @@ -127,9 +145,14 @@ function createPayload(payloads: Payload[], externalAudienceId: string): UpsertC email: payload.email ?? undefined, phone_number_id: payload.phone_number_id ?? undefined, external_id: payload.external_id ?? undefined, - anonymous_id: payload.anonymous_id ?? undefined + anonymous_id: payload.anonymous_id ?? undefined, + ...payload.user_attributes, + custom_fields: payload.custom_fields && Object.keys(payload.custom_fields).length > 0 + ? payload.custom_fields + : undefined })) as UpsertContactsReq['contacts'] } + return json } diff --git a/packages/destination-actions/src/destinations/sendgrid-audiences/types.ts b/packages/destination-actions/src/destinations/sendgrid-audiences/types.ts index 3d6f684fe4..80b178a0cf 100644 --- a/packages/destination-actions/src/destinations/sendgrid-audiences/types.ts +++ b/packages/destination-actions/src/destinations/sendgrid-audiences/types.ts @@ -15,6 +15,17 @@ export interface UpsertContactsReq { email?: string phone_number_id?: string anonymous_id?: string + first_name?: string + last_name?: string + address_line_1?: string + address_line_2?: string + city?: string + state_province_region?: string + country?: string + postal_code?: string + custom_fields?: { + [k: string]: string | number + } } & ({ external_id: string } | { email: string } | { phone_number_id: string } | { anonymous_id: string }) > } 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 index df2cc00f69..e1f778524a 100644 --- 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 @@ -18,7 +18,7 @@ Object { upsertProfileMapping( input: { advertiserId: 84GW[vK%wv2xv@UF5iy, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false,\\\\\\"label\\\\\\":\\\\\\"External Profile ID\\\\\\"}]\\", mappableType: \\"segmentio\\", } ) { @@ -29,7 +29,7 @@ Object { upsertExternalAudienceMapping( input: { advertiserId: 84GW[vK%wv2xv@UF5iy, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\"},{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceName\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"name\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\"}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"label\\\\\\":\\\\\\"External Audience ID\\\\\\"},{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceName\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"name\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"label\\\\\\":\\\\\\"External Audience Name\\\\\\"}]\\", mappableType: \\"segmentio\\" } ) { @@ -59,7 +59,7 @@ Object { upsertProfileMapping( input: { advertiserId: 84GW[vK%wv2xv@UF5iy, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false,\\\\\\"label\\\\\\":\\\\\\"External Profile ID\\\\\\"}]\\", mappableType: \\"segmentio\\", } ) { @@ -70,7 +70,7 @@ Object { upsertExternalAudienceMapping( input: { advertiserId: 84GW[vK%wv2xv@UF5iy, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\"},{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceName\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"name\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\"}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"label\\\\\\":\\\\\\"External Audience ID\\\\\\"},{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceName\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"name\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"label\\\\\\":\\\\\\"External Audience Name\\\\\\"}]\\", mappableType: \\"segmentio\\" } ) { @@ -100,7 +100,7 @@ Object { upsertProfileMapping( input: { advertiserId: PsAwlRv%, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"label\\\\\\":\\\\\\"User Id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", mappableType: \\"segmentio\\", } ) { @@ -130,7 +130,7 @@ Object { upsertProfileMapping( input: { advertiserId: PsAwlRv%, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"label\\\\\\":\\\\\\"User Id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", mappableType: \\"segmentio\\", } ) { @@ -140,4 +140,4 @@ Object { } }", } -`; +`; \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/__tests__/index.test.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/__tests__/index.test.ts new file mode 100644 index 0000000000..b6997162e0 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/__tests__/index.test.ts @@ -0,0 +1,97 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import nock from 'nock' +import Definition from '../index' +import { GQL_ENDPOINT } from '../functions' + +const testDestination = createTestIntegration(Definition) + +describe('StackAdapt Audiences - Destination Tests', () => { + const mockSettings = { apiKey: 'test-api-key' } + const gqlHostUrl = 'https://api.stackadapt.com' + + afterEach(() => { + nock.cleanAll() + }) + + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + nock(GQL_ENDPOINT, { + reqheaders: { + authorization: `Bearer ${mockSettings.apiKey}`, + 'content-type': 'application/json' + } + }) + .post('', { + query: /tokenInfo/ + }) + .reply(200, { + data: { + tokenInfo: { + scopesByAdvertiser: { + nodes: [ + { + advertiser: { name: 'Test Advertiser' }, + scopes: ['WRITE'] + } + ] + } + } + } + }) + + await expect(testDestination.testAuthentication(mockSettings)).resolves.not.toThrowError() + }) + + it('should fail if authentication is invalid', async () => { + nock(GQL_ENDPOINT).post('').reply(403, {}) + + await expect(testDestination.testAuthentication(mockSettings)).rejects.toThrowError('403Forbidden') + }) + }) + + describe('onDelete', () => { + it('should delete a user with a given userId', async () => { + const userId = '9999' + const event = createTestEvent({ userId, type: 'identify' }) + + // Mock the GraphQL deleteProfilesWithExternalIds mutation + nock(gqlHostUrl) + .post('/graphql', (body) => { + return body.query.includes('deleteProfilesWithExternalIds') && body.query.includes(userId) + }) + .reply(200, { + data: { deleteProfilesWithExternalIds: { userErrors: [] } } + }) + + const response = await testDestination.onDelete!(event, { + apiKey: 'test-api-key' + }) + + expect(response).toMatchObject({ + data: { deleteProfilesWithExternalIds: { userErrors: [] } } + }) + }) + + it('should throw an error if profile deletion fails with userErrors', async () => { + const userId = '9999' + const event = createTestEvent({ userId, type: 'identify' }) + + // Mock the GraphQL deleteProfilesWithExternalIds mutation with an error + nock(gqlHostUrl) + .post('/graphql') + .reply(200, { + data: { + deleteProfilesWithExternalIds: { + userErrors: [{ message: 'Deletion failed' }] + } + } + }) + + await expect( + testDestination.onDelete!(event, { + apiKey: 'test-api-key' + }) + ).rejects.toThrowError('Profile deletion was not successful: Deletion failed') + }) + }) +}) 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 index 3cbef3968c..aa9c1fd64e 100644 --- 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 @@ -95,7 +95,7 @@ describe('forwardAudienceEvent', () => { upsertProfileMapping( input: { advertiserId: 23, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false,\\\\\\"label\\\\\\":\\\\\\"External Profile ID\\\\\\"}]\\", mappableType: \\"segmentio\\", } ) { @@ -106,7 +106,7 @@ describe('forwardAudienceEvent', () => { upsertExternalAudienceMapping( input: { advertiserId: 23, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\"},{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceName\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"name\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\"}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"label\\\\\\":\\\\\\"External Audience ID\\\\\\"},{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceName\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"name\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"label\\\\\\":\\\\\\"External Audience Name\\\\\\"}]\\", mappableType: \\"segmentio\\" } ) { @@ -169,7 +169,7 @@ describe('forwardAudienceEvent', () => { upsertProfileMapping( input: { advertiserId: 23, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false,\\\\\\"label\\\\\\":\\\\\\"External Profile ID\\\\\\"}]\\", mappableType: \\"segmentio\\", } ) { @@ -180,7 +180,7 @@ describe('forwardAudienceEvent', () => { upsertExternalAudienceMapping( input: { advertiserId: 23, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\"},{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceName\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"name\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\"}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"label\\\\\\":\\\\\\"External Audience ID\\\\\\"},{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceName\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"name\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"label\\\\\\":\\\\\\"External Audience Name\\\\\\"}]\\", mappableType: \\"segmentio\\" } ) { @@ -228,7 +228,7 @@ describe('forwardAudienceEvent', () => { upsertProfileMapping( input: { advertiserId: 23, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false,\\\\\\"label\\\\\\":\\\\\\"External Profile ID\\\\\\"}]\\", mappableType: \\"segmentio\\", } ) { @@ -239,7 +239,7 @@ describe('forwardAudienceEvent', () => { upsertExternalAudienceMapping( input: { advertiserId: 23, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\"},{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceName\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"name\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\"}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"label\\\\\\":\\\\\\"External Audience ID\\\\\\"},{\\\\\\"incoming_key\\\\\\":\\\\\\"audienceName\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"name\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"label\\\\\\":\\\\\\"External Audience Name\\\\\\"}]\\", mappableType: \\"segmentio\\" } ) { @@ -251,4 +251,4 @@ describe('forwardAudienceEvent', () => { } `) }) -}) +}) \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/functions.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/functions.ts index 6db62d6895..c551a0c0a7 100644 --- a/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/functions.ts +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardAudienceEvent/functions.ts @@ -8,12 +8,14 @@ const audienceMapping = stringifyJsonWithEscapedQuotes([ { incoming_key: 'audienceId', destination_key: 'external_id', - type: 'string' + type: 'string', + label: 'External Audience ID' }, { incoming_key: 'audienceName', destination_key: 'name', - type: 'string' + type: 'string', + label: 'External Audience Name' } ]) @@ -22,7 +24,8 @@ const profileMapping = stringifyJsonWithEscapedQuotes([ incoming_key: 'userId', destination_key: 'external_id', type: 'string', - is_pii: false + is_pii: false, + label: 'External Profile ID' } ]) @@ -80,4 +83,4 @@ export async function performForwardAudienceEvents(request: RequestClient, event return await request(GQL_ENDPOINT, { body: JSON.stringify({ query: mutation }) }) -} +} \ No newline at end of file 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 index 34354f147f..5ba542cd94 100644 --- 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 @@ -131,7 +131,7 @@ describe('forwardProfile', () => { upsertProfileMapping( input: { advertiserId: 23, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"label\\\\\\":\\\\\\"User Id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", mappableType: \\"segmentio\\", } ) { @@ -194,7 +194,7 @@ describe('forwardProfile', () => { upsertProfileMapping( input: { advertiserId: 23, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"label\\\\\\":\\\\\\"User Id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", mappableType: \\"segmentio\\", } ) { @@ -242,7 +242,7 @@ describe('forwardProfile', () => { upsertProfileMapping( input: { advertiserId: 23, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false},{\\\\\\"incoming_key\\\\\\":\\\\\\"customField\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"customField\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false},{\\\\\\"incoming_key\\\\\\":\\\\\\"numberCustomField\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"numberCustomField\\\\\\",\\\\\\"type\\\\\\":\\\\\\"number\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"label\\\\\\":\\\\\\"User Id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false},{\\\\\\"incoming_key\\\\\\":\\\\\\"customField\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"customField\\\\\\",\\\\\\"label\\\\\\":\\\\\\"Custom Field\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false},{\\\\\\"incoming_key\\\\\\":\\\\\\"numberCustomField\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"numberCustomField\\\\\\",\\\\\\"label\\\\\\":\\\\\\"Number Custom Field\\\\\\",\\\\\\"type\\\\\\":\\\\\\"number\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", mappableType: \\"segmentio\\", } ) { @@ -290,7 +290,7 @@ describe('forwardProfile', () => { upsertProfileMapping( input: { advertiserId: 23, - mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", + mappingSchema: \\"[{\\\\\\"incoming_key\\\\\\":\\\\\\"userId\\\\\\",\\\\\\"destination_key\\\\\\":\\\\\\"external_id\\\\\\",\\\\\\"label\\\\\\":\\\\\\"User Id\\\\\\",\\\\\\"type\\\\\\":\\\\\\"string\\\\\\",\\\\\\"is_pii\\\\\\":false}]\\", mappableType: \\"segmentio\\", } ) { @@ -302,4 +302,4 @@ describe('forwardProfile', () => { } `) }) -}) +}) \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/functions.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/functions.ts index f20d8144c6..b2ab03fd7b 100644 --- a/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/functions.ts +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/forwardProfile/functions.ts @@ -26,6 +26,7 @@ const standardFields = new Set([ interface Mapping { incoming_key: string destination_key: string + label: string type: string is_pii: boolean } @@ -113,6 +114,7 @@ function getProfileMappings(customFields: string[], fieldTypes: Record str.toUpperCase()) + + // Check if the input starts with "audience" and attach "External" if true + if (field.startsWith('audience')) { + label = `External ${label}` + } + + return label +} + function getType(value: unknown) { if (isDateStr(value)) return 'date' return typeof value @@ -127,4 +144,4 @@ function getType(value: unknown) { function isDateStr(value: unknown) { return typeof value === 'string' && !isNaN(Date.parse(value)) -} +} \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/index.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/index.ts index 97abdf7278..a4565a459f 100644 --- a/packages/destination-actions/src/destinations/stackadapt-audiences/index.ts +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/index.ts @@ -1,10 +1,10 @@ import type { DestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' - +import { IntegrationError } from '@segment/actions-core' import forwardProfile from './forwardProfile' import forwardAudienceEvent from './forwardAudienceEvent' import { AdvertiserScopesResponse } from './types' -import { GQL_ENDPOINT } from './functions' +import { GQL_ENDPOINT, EXTERNAL_PROVIDER, sha256hash } from './functions' const destination: DestinationDefinition = { name: 'StackAdapt Audiences', @@ -59,6 +59,45 @@ const destination: DestinationDefinition = { } } }, + onDelete: async (request, { payload }) => { + const userId = payload.userId + const formattedExternalIds = `["${userId}"]` + const syncId = sha256hash(String(userId)) + + const mutation = `mutation { + deleteProfilesWithExternalIds( + externalIds: ${formattedExternalIds}, + externalProvider: "${EXTERNAL_PROVIDER}", + syncId: "${syncId}" + ) { + userErrors { + message + path + } + } + }` + + const response = await request(GQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: mutation }) + }) + + const result: { + data: { + deleteProfilesWithExternalIds: { + userErrors: { message: string }[] + } + } + } = await response.json() + + if (result.data.deleteProfilesWithExternalIds.userErrors.length > 0) { + const errorMessages = result.data.deleteProfilesWithExternalIds.userErrors.map((e) => e.message).join(', ') + throw new IntegrationError(`Profile deletion was not successful: ${errorMessages}`, 'DELETE_FAILED', 400) + } + return result + }, + actions: { forwardProfile, forwardAudienceEvent