diff --git a/packages/destination-actions/src/destinations/attentive/constants.ts b/packages/destination-actions/src/destinations/attentive/constants.ts new file mode 100644 index 0000000000..5ccf522b01 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/constants.ts @@ -0,0 +1,9 @@ +export const API_URL = 'https://api.attentivemobile.com' +export const API_VERSION = 'v1' +export const MARKETING = 'MARKETING' as const +export const TRANSACTIONAL = 'TRANSACTIONAL' as const +export const SUBSCRIPTION_TYPES = [MARKETING, TRANSACTIONAL] as const +export const SUBSCRIPTION_TYPE_CHOICES = [ + { label: MARKETING, value: MARKETING }, + { label: TRANSACTIONAL, value: TRANSACTIONAL } +] \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/attentive/customEvents/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/attentive/customEvents/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..4a6a0b4016 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/customEvents/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Attentive's customEvents destination action: all fields 1`] = ` +Object { + "externalEventId": "*8ANaCHkFXR*[r23fd", + "occurredAt": "*8ANaCHkFXR*[r23fd", + "properties": Object { + "testType": "*8ANaCHkFXR*[r23fd", + }, + "type": "*8ANaCHkFXR*[r23fd", + "user": Object { + "email": "zat@kulihesa.dm", + "externalIdentifiers": Object { + "clientUserId": "*8ANaCHkFXR*[r23fd", + }, + "phone": "*8ANaCHkFXR*[r23fd", + }, +} +`; + +exports[`Testing snapshot for Attentive's customEvents destination action: required fields 1`] = ` +Object { + "type": "*8ANaCHkFXR*[r23fd", + "user": Object { + "externalIdentifiers": Object { + "customIdentifiers": Object { + "userId": "testuserid", + }, + }, + }, +} +`; 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 index 421654cd30..e77f85793b 100644 --- a/packages/destination-actions/src/destinations/attentive/customEvents/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/attentive/customEvents/__tests__/index.test.ts @@ -11,11 +11,11 @@ const settings: Settings = { } const validPayload = { - timestamp: timestamp, - event: 'Event Type 1', + timestamp, + event: 'Product Clicked', // <- Used as "type" in Attentive messageId: '123e4567-e89b-12d3-a456-426614174000', type: 'track', - userId: '123e4567-e89b-12d3-a456-426614174000', + userId: 'user-123', context: { traits: { phone: '+3538675765689', @@ -23,8 +23,8 @@ const validPayload = { } }, properties: { - tracking_url: 'https://tracking-url.com', - product_name: 'Product X' + product_name: 'Product X', + tracking_url: 'https://tracking-url.com' } } as Partial @@ -41,52 +41,79 @@ const mapping = { } 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', + type: 'Product Clicked', user: { phone: '+3538675765689', email: 'test@test.com', externalIdentifiers: { - clientUserId: '123e4567-e89b-12d3-a456-426614174000' + clientUserId: 'user-123' } - } + }, + properties: { + product_name: 'Product X', + tracking_url: 'https://tracking-url.com' + }, + externalEventId: '123e4567-e89b-12d3-a456-426614174000', + occurredAt: timestamp } -beforeEach((done) => { +beforeEach(() => { 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, {}) + nock('https://api.attentivemobile.com', { + reqheaders: { + authorization: 'Bearer test-api-key', + 'content-type': 'application/json' + } + }) + .post('/v1/events/custom', expectedPayload) + .reply(200, {}) const responses = await testDestination.testAction('customEvents', { event, settings, - useDefaultMappings: true, - mapping + mapping, + useDefaultMappings: false }) expect(responses.length).toBe(1) expect(responses[0].status).toBe(200) }) - it('should throw error if no identifiers provided', async () => { + it('throws error if no userIdentifiers provided', async () => { + const badPayload = { + ...validPayload, + context: { + traits: {} + }, + userId: undefined + } + + const event = createTestEvent(badPayload) + + await expect( + testDestination.testAction('customEvents', { + event, + settings, + mapping, + useDefaultMappings: false + }) + ).rejects.toThrowError("At least one user identifier is required.") + }) + + it('throws error if properties contain arrays', async () => { const badPayload = { - ...validPayload + ...validPayload, + properties: { + someArray: [1, 2, 3] + } } - delete badPayload?.context?.traits?.phone - delete badPayload?.context?.traits?.email - badPayload.userId = undefined const event = createTestEvent(badPayload) @@ -94,9 +121,9 @@ describe('Attentive.customEvents', () => { testDestination.testAction('customEvents', { event, settings, - useDefaultMappings: true, - mapping + mapping, + useDefaultMappings: false }) - ).rejects.toThrowError(new PayloadValidationError('At least one user identifier is required.')) + ).rejects.toThrowError("Properties cannot contain arrays.") }) }) diff --git a/packages/destination-actions/src/destinations/attentive/customEvents/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/attentive/customEvents/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..f3647988c3 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/customEvents/__tests__/snapshot.test.ts @@ -0,0 +1,81 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'customEvents' +const destinationSlug = 'Attentive' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: + { + ...event.properties, + userIdentifiers: { + userId: "testuserid" + } + }, + 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('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + 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/attentive/customEvents/generated-types.ts b/packages/destination-actions/src/destinations/attentive/customEvents/generated-types.ts index 44df57ea91..6f4e075d57 100644 --- a/packages/destination-actions/src/destinations/attentive/customEvents/generated-types.ts +++ b/packages/destination-actions/src/destinations/attentive/customEvents/generated-types.ts @@ -1,10 +1,6 @@ // 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. */ @@ -24,17 +20,21 @@ export interface Payload { [k: string]: unknown } /** - * Metadata to associate with the event. + * Timestamp for the event, ISO 8601 format. */ - properties?: { - [k: string]: unknown - } + occurredAt?: string /** - * A unique identifier representing this specific event. Should be a UUID format. + * A unique identifier representing this specific event. */ externalEventId?: string /** - * Timestamp for the event, ISO 8601 format. + * The type of event. This name is case sensitive. "Order shipped" and "Order Shipped" would be considered different event types. */ - occurredAt?: string + type: string + /** + * Metadata to associate with the event. + */ + properties?: { + [k: string]: unknown + } } diff --git a/packages/destination-actions/src/destinations/attentive/customEvents/index.ts b/packages/destination-actions/src/destinations/attentive/customEvents/index.ts index 5213b5de2f..810799bbe3 100644 --- a/packages/destination-actions/src/destinations/attentive/customEvents/index.ts +++ b/packages/destination-actions/src/destinations/attentive/customEvents/index.ts @@ -1,133 +1,26 @@ -import { ActionDefinition, PayloadValidationError } from '@segment/actions-core' +import { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { CustomEvent, User } from './types' +import { userIdentifiers, occurredAt, externalEventId, type, properties } from '../fields' +import { API_URL, API_VERSION } from '../constants' +import { formatCustomEventJSON, validate } from '../functions' const action: ActionDefinition = { - title: 'Custom Events', - description: 'Send Segment analytics events to Attentive.', - defaultSubscription: 'type = "track"', + title: 'Send Custom Events', + description: 'Send custom Segment analytics events to Attentive.', + defaultSubscription: 'type = "track" and event != "Product Viewed" and event != "Product Added" and event != "Order Completed"', 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' - } - } + userIdentifiers, + occurredAt, + externalEventId, + type, + properties }, 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', { + validate(payload) + return request(`${API_URL}/${API_VERSION}/events/custom`, { method: 'post', - json + json: formatCustomEventJSON(payload) }) } } diff --git a/packages/destination-actions/src/destinations/attentive/customEvents/types.ts b/packages/destination-actions/src/destinations/attentive/customEvents/types.ts deleted file mode 100644 index 9eddef039c..0000000000 --- a/packages/destination-actions/src/destinations/attentive/customEvents/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -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/ecommEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/attentive/ecommEvent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..de48151cf9 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/ecommEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,54 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Attentive's ecommEvent destination action: all fields 1`] = ` +Object { + "items": Array [ + Object { + "name": "Test Product", + "price": Object { + "currency": "USD", + "value": 100, + }, + "productId": "test-product-id", + "productImage": "https://example.com/image.jpg", + "productUrl": "https://example.com/product", + "productVariantId": "test-product-variant-id", + "quantity": 1, + }, + ], + "occurredAt": "37Q3wojV%LNtOQ", + "user": Object { + "externalIdentifiers": Object { + "customIdentifiers": Object { + "userId": "testuserid", + }, + }, + }, +} +`; + +exports[`Testing snapshot for Attentive's ecommEvent destination action: required fields 1`] = ` +Object { + "items": Array [ + Object { + "name": "Test Product", + "price": Object { + "currency": "USD", + "value": 100, + }, + "productId": "test-product-id", + "productImage": "https://example.com/image.jpg", + "productUrl": "https://example.com/product", + "productVariantId": "test-product-variant-id", + "quantity": 1, + }, + ], + "user": Object { + "externalIdentifiers": Object { + "customIdentifiers": Object { + "userId": "testuserid", + }, + }, + }, +} +`; diff --git a/packages/destination-actions/src/destinations/attentive/ecommEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/attentive/ecommEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..5eb3808b95 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/ecommEvent/__tests__/index.test.ts @@ -0,0 +1,238 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, SegmentEvent, PayloadValidationError } from '@segment/actions-core' +import Definition from '../../index' // adjust path if needed +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 baseItem = { + productId: 'prod_1', + productVariantId: 'variant_1', + productImage: 'https://example.com/image.png', + productUrl: 'https://example.com/product/prod_1', + name: 'Test Product', + quantity: 1, + value: 19.99, + currency: 'USD' +} + +const basePayload = { + timestamp, + event: 'eCommerce Event', + messageId: '123e4567-e89b-12d3-a456-426614174000', + type: 'track', + userId: 'testuser123', + context: { + traits: { + phone: '+12345556789', + email: 'user@example.com' + } + }, + properties: { + items: [baseItem] + } +} as Partial + +beforeEach(() => { + testDestination = createTestIntegration(Definition) + nock.cleanAll() +}) + +describe('eCommerce API - Product View, Add to Cart, Purchase', () => { + it('should send product-view event successfully', async () => { + const mapping = { + eventType: 'product-view', + items: { '@path': '$.properties.items' }, + occurredAt: { '@path': '$.timestamp' }, + userIdentifiers: { + phone: { '@path': '$.context.traits.phone' }, + email: { '@path': '$.context.traits.email' }, + clientUserId: { '@path': '$.userId' } + } + } + + const event = createTestEvent( + basePayload + ) + + nock('https://api.attentivemobile.com') + .post('/v1/events/ecommerce/product-view', + { + items: [ + { + productId: 'prod_1', + productVariantId: 'variant_1', + price: { + value: 19.99, + currency: 'USD' + }, + productImage: 'https://example.com/image.png', + productUrl: 'https://example.com/product/prod_1', + name: 'Test Product', + quantity: 1 + } + ], + user: { + phone: '+12345556789', + email: 'user@example.com', + externalIdentifiers: { + clientUserId: 'testuser123' + } + }, + occurredAt: '2024-01-08T13:52:50.212Z' + }) + .reply(200, {}) + + const responses = await testDestination.testAction('ecommEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + ...mapping, + eventType: 'product-view', + userIdentifiers: { + phone: { '@path': '$.context.traits.phone' }, + email: { '@path': '$.context.traits.email' }, + clientUserId: { '@path': '$.userId' } + }, + occurredAt: { '@path': '$.timestamp' } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should send add-to-cart event successfully', async () => { + const mapping = { + eventType: 'add-to-cart', + items: { '@path': '$.properties.items' }, + occurredAt: { '@path': '$.timestamp' }, + userIdentifiers: { + phone: { '@path': '$.context.traits.phone' }, + email: { '@path': '$.context.traits.email' }, + clientUserId: { '@path': '$.userId' } + } + } + + const event = createTestEvent( + basePayload + ) + + nock('https://api.attentivemobile.com') + .post('/v1/events/ecommerce/add-to-cart', + { + items: [ + { + productId: 'prod_1', + productVariantId: 'variant_1', + price: { + value: 19.99, + currency: 'USD' + }, + productImage: 'https://example.com/image.png', + productUrl: 'https://example.com/product/prod_1', + name: 'Test Product', + quantity: 1 + } + ], + user: { + phone: '+12345556789', + email: 'user@example.com', + externalIdentifiers: { + clientUserId: 'testuser123' + } + }, + occurredAt: '2024-01-08T13:52:50.212Z' + }) + .reply(200, {}) + + const responses = await testDestination.testAction('ecommEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + ...mapping, + eventType: 'add-to-cart', + userIdentifiers: { + phone: { '@path': '$.context.traits.phone' }, + email: { '@path': '$.context.traits.email' }, + clientUserId: { '@path': '$.userId' } + }, + occurredAt: { '@path': '$.timestamp' } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should send purchase event successfully', async () => { + const mapping = { + eventType: 'purchase', + items: { '@path': '$.properties.items' }, + occurredAt: { '@path': '$.timestamp' }, + userIdentifiers: { + phone: { '@path': '$.context.traits.phone' }, + email: { '@path': '$.context.traits.email' }, + clientUserId: { '@path': '$.userId' } + } + } + + const event = createTestEvent( + basePayload + ) + + nock('https://api.attentivemobile.com') + .post('/v1/events/ecommerce/purchase', + { + items: [ + { + productId: 'prod_1', + productVariantId: 'variant_1', + price: { + value: 19.99, + currency: 'USD' + }, + productImage: 'https://example.com/image.png', + productUrl: 'https://example.com/product/prod_1', + name: 'Test Product', + quantity: 1 + } + ], + user: { + phone: '+12345556789', + email: 'user@example.com', + externalIdentifiers: { + clientUserId: 'testuser123' + } + }, + occurredAt: '2024-01-08T13:52:50.212Z' + }) + .reply(200, {}) + + const responses = await testDestination.testAction('ecommEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + ...mapping, + eventType: 'purchase', + userIdentifiers: { + phone: { '@path': '$.context.traits.phone' }, + email: { '@path': '$.context.traits.email' }, + clientUserId: { '@path': '$.userId' } + }, + occurredAt: { '@path': '$.timestamp' } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) +}) \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/attentive/ecommEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/attentive/ecommEvent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..f18fa1f2cd --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/ecommEvent/__tests__/snapshot.test.ts @@ -0,0 +1,109 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'ecommEvent' +const destinationSlug = 'Attentive' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: + { + ...event.properties, + userIdentifiers: { + userId: "testuserid" + }, + eventType: "product-view", + items: [{ + productId: "test-product-id", + productVariantId: "test-product-variant-id", + value: 100, + productImage: "https://example.com/image.jpg", + productUrl: "https://example.com/product", + name: "Test Product", + quantity: 1, + currency: "USD" + }] + }, + 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('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: + { + ...event.properties, + userIdentifiers: { + userId: "testuserid" + }, + eventType: "product-view", + items: [{ + productId: "test-product-id", + productVariantId: "test-product-variant-id", + value: 100, + productImage: "https://example.com/image.jpg", + productUrl: "https://example.com/product", + name: "Test Product", + quantity: 1, + currency: "USD" + }] + }, + 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/attentive/ecommEvent/generated-types.ts b/packages/destination-actions/src/destinations/attentive/ecommEvent/generated-types.ts new file mode 100644 index 0000000000..fab0d3e82a --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/ecommEvent/generated-types.ts @@ -0,0 +1,67 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * 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 + } + /** + * Timestamp for the event, ISO 8601 format. + */ + occurredAt?: string + /** + * The type of ecommerce event + */ + eventType: string + /** + * List of items. + */ + items: { + /** + * A unique identifier for the product (i.e. "T-Shirt"). + */ + productId: string + /** + * A unique identifier for the product variant (i.e. "Medium Blue T-Shirt"). + */ + productVariantId: string + /** + * The price of the product. + */ + value: number + /** + * A link to the image of the product. The image should not be larger than 500kb. This image will be used when sending MMS text messages. + */ + productImage?: string + /** + * The URL for the product. + */ + productUrl?: string + /** + * The name of the product. This should be in a format that could be used directly in a message. + */ + name?: string + /** + * Default: "USD". The currency used for the price in ISO 4217 format. + */ + currency?: string + /** + * The number of products. + */ + quantity?: number + }[] +} diff --git a/packages/destination-actions/src/destinations/attentive/ecommEvent/index.ts b/packages/destination-actions/src/destinations/attentive/ecommEvent/index.ts new file mode 100644 index 0000000000..0cb5a2b545 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/ecommEvent/index.ts @@ -0,0 +1,28 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { userIdentifiers, occurredAt, eventType, items } from '../fields' +import { API_URL, API_VERSION } from '../constants' +import { formatEcommEventJSON, validate } from '../functions' + +const action: ActionDefinition = { + title: 'Send Ecommerce Event', + description: 'Send Segment ecommerce events to Attentive.', + defaultSubscription: 'event = "Product Viewed" or event = "Product Added" or event = "Order Completed"', + fields: { + userIdentifiers, + occurredAt, + eventType, + items + }, + perform: (request, { payload }) => { + validate(payload) + const { eventType } = payload + return request(`${API_URL}/${API_VERSION}/events/ecommerce/${eventType}`, { + method: 'POST', + json: formatEcommEventJSON(payload) + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/attentive/fields.ts b/packages/destination-actions/src/destinations/attentive/fields.ts new file mode 100644 index 0000000000..ae71f6e807 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/fields.ts @@ -0,0 +1,225 @@ +import { InputField } from '@segment/actions-core/destination-kit/types' +import { SUBSCRIPTION_TYPE_CHOICES, TRANSACTIONAL } from './constants' + +export const eventType: InputField = { + label: 'Event Type', + description: 'The type of ecommerce event', + type: 'string', + required: true, + choices: [ + { label: 'product-view', value: 'product-view' }, + { label: 'add-to-cart', value: 'add-to-cart' }, + { label: 'purchase', value: 'purchase' } + ] +} + +export const items: InputField = { + label: 'Items', + description: 'List of items.', + type: 'object', + multiple: true, + required: true, + defaultObjectUI: 'keyvalue', + additionalProperties: false, + properties: { + productId: { + label: 'productId', + description: 'A unique identifier for the product (i.e. "T-Shirt").', + type: 'string', + required: true + }, + productVariantId: { + label: 'productVariantId', + description: 'A unique identifier for the product variant (i.e. "Medium Blue T-Shirt").', + type: 'string', + required: true + }, + value: { + label: 'value', + description: 'The price of the product.', + type: 'number', + required: true + }, + productImage: { + label: 'productImage', + description: + 'A link to the image of the product. The image should not be larger than 500kb. This image will be used when sending MMS text messages.', + type: 'string', + format: 'uri', + required: false + }, + productUrl: { + label: 'productUrl', + description: 'The URL for the product.', + type: 'string', + format: 'uri', + required: false + }, + name: { + label: 'name', + description: 'The name of the product. This should be in a format that could be used directly in a message.', + type: 'string', + required: false + }, + currency: { + label: 'currency', + description: 'Default: "USD". The currency used for the price in ISO 4217 format.', + type: 'string', + required: false + }, + quantity: { + label: 'quantity', + description: 'The number of products.', + type: 'integer', + required: false + } + }, + default: { + '@arrayPath': [ + '$.properties.products', + { + productId: { '@path': '$.product_id' }, + productVariantId: { '@path': '$.variant' }, + value: { '@path': '$.price' }, + productImage: { '@path': '$.image_url' }, + productUrl: { '@path': '$.url' }, + name: { '@path': '$.name' }, + currency: { '@path': '$.currency' }, + quantity: { '@path': '$.quantity' } + } + ] + } +} + +export const type: InputField = { + 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' + } +} + +export const externalEventId: InputField = { + label: 'External Event Id', + description: 'A unique identifier representing this specific event.', + type: 'string', + required: false, + default: { + '@path': '$.messageId' + } +} + +export const occurredAt: InputField = { + label: 'Occurred At', + description: 'Timestamp for the event, ISO 8601 format.', + type: 'string', + required: false, + default: { + '@path': '$.timestamp' + } +} + +export const userIdentifiers: InputField = { + 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', + 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' } + } +} + +export const properties: InputField = { + label: 'Properties', + description: 'Metadata to associate with the event.', + type: 'object', + required: false, + default: { + '@path': '$.properties' + } +} + +export const subscriptionType: InputField = { + label: 'Subscription Type', + description: 'Type of subscription', + type: 'string', + required: true, + choices: SUBSCRIPTION_TYPE_CHOICES, + default: TRANSACTIONAL +} + +export const locale: InputField = { + label: 'Locale', + description: 'User locale. e.g. "en-US". Either Locale or Signup Source ID is required.', + type: 'string', + allowNull: false, + required: { + match: 'any', + conditions: [ + { fieldKey: 'signUpSourceId', operator: 'is', value: undefined }, + { fieldKey: 'signUpSourceId', operator: 'is', value: "" } + ] + }, + default: { '@path': '$.context.locale' } +} + +export const signUpSourceId: InputField = { + label: 'Signup Source ID', + description: 'A unique identifier for the sign up source. Talk to your Attentive represenative. Either Locale or Signup Source ID is required.', + type: 'string', + required: { + match: 'any', + conditions: [ + { fieldKey: 'locale', operator: 'is', value: undefined }, + { fieldKey: 'locale', operator: 'is', value: "" } + ] + } +} + +export const singleOptIn: InputField = { + label: 'Single Opt-In', + description: 'Whether to use single opt-in for the subscription.', + type: 'boolean', + required: false, + default: false +} \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/attentive/functions.ts b/packages/destination-actions/src/destinations/attentive/functions.ts new file mode 100644 index 0000000000..6b29c61c42 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/functions.ts @@ -0,0 +1,127 @@ +import { PayloadValidationError } from '@segment/actions-core' +import { Item, User, EcommEventJSON, CustomEventJSON, UpsertUserAttributesJSON, SubscribeUserJSON, SubscriptionType } from './types' +import { Payload as CustomEvent } from './customEvents/generated-types' +import { Payload as EcommEvent } from './ecommEvent/generated-types' +import { Payload as CustomAttributesEvent } from './upsertUserAttributes/generated-types' +import { Payload as SubscribeUserEvent } from './subscribeUser/generated-types' + +type UserIdentifiers = + | CustomEvent['userIdentifiers'] + | EcommEvent['userIdentifiers'] + +type Items = EcommEvent['items'] + +export function validate(payload: CustomEvent | EcommEvent | CustomAttributesEvent | SubscribeUserEvent) { + const { + 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.') + } +} + +export function validateSubscribeUser(payload: SubscribeUserEvent) { + const { userIdentifiers, locale } = payload + if (!userIdentifiers && !locale) { + throw new PayloadValidationError('Either locale or signUpSourceId is required.') + } +} + +function formatUser(userIdentifiers: UserIdentifiers): User { + const { phone, email, clientUserId, ...customIdentifiers } = userIdentifiers + return { + ...(phone ? { phone } : {}), + ...(email ? { email } : {}), + ...(clientUserId || Object.keys(customIdentifiers || {}).length > 0 + ? { + externalIdentifiers: { + ...(clientUserId ? { clientUserId } : {}), + ...(Object.keys(customIdentifiers || {}).length > 0 ? { customIdentifiers } : {}) + } + } + : {}) + } +} + +function formatItems(items: Items): Array { + return items.map(({ value, currency, ...rest }) => ({ + ...rest, + price: { + value, + currency, + } + })) +} + +function formatLocale(locale?: string): { language: string, country: string } { + if(!locale) { + throw new PayloadValidationError('Locale Signup Source ID is required.') + } + const parts = locale.split('-') + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new PayloadValidationError('Invalid locale format. Expected format: "language-country" e.g. "en-US".') + } + + const [language, country] = parts + return { language, country } +} + +export function formatEcommEventJSON(payload: EcommEvent): EcommEventJSON { + return { + items: formatItems(payload.items), + occurredAt: payload.occurredAt, + user: formatUser(payload.userIdentifiers) + } +} + +export function formatCustomEventJSON(payload: CustomEvent): CustomEventJSON { + const { + externalEventId, + type, + properties, + occurredAt, + userIdentifiers + } = payload + + if (Object.values(properties ?? {}).some(value => Array.isArray(value))) { + throw new PayloadValidationError('Properties cannot contain arrays.') + } + + return { + type, + properties, + externalEventId, + occurredAt, + user: formatUser(userIdentifiers) + } +} + +export function formatUpsertUserAttributesJSON(payload: CustomAttributesEvent): UpsertUserAttributesJSON { + const { + properties, + userIdentifiers + } = payload + + if (Object.values(properties ?? {}).some(value => typeof value === 'object' || Array.isArray(value))) { + throw new PayloadValidationError('Properties cannot contain objects or arrays.') + } + + return { + properties, + user: formatUser(userIdentifiers) + } +} + +export function formatSubscribeUserJSON(payload: SubscribeUserEvent): SubscribeUserJSON { + const { externalEventId, occurredAt, userIdentifiers, subscriptionType, signUpSourceId, singleOptIn } = payload + return { + externalEventId, + occurredAt, + subscriptionType: subscriptionType as SubscriptionType, + locale: formatLocale(payload?.locale), + signUpSourceId, + singleOptIn, + user: formatUser(userIdentifiers) + } +} \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/attentive/index.ts b/packages/destination-actions/src/destinations/attentive/index.ts index 12b4bbe59b..fca2b82388 100644 --- a/packages/destination-actions/src/destinations/attentive/index.ts +++ b/packages/destination-actions/src/destinations/attentive/index.ts @@ -1,6 +1,10 @@ import { DestinationDefinition, defaultValues } from '@segment/actions-core' import type { Settings } from './generated-types' import customEvents from './customEvents' +import ecommEvent from './ecommEvent' +import upsertUserAttributes from './upsertUserAttributes' +import subscribeUser from './subscribeUser' + const destination: DestinationDefinition = { name: 'Attentive', @@ -35,18 +39,58 @@ const destination: DestinationDefinition = { } } }, - actions: { - customEvents - }, presets: [ { - name: 'Track Event', - subscribe: 'type = "track"', - partnerAction: 'customEvents', - mapping: defaultValues(customEvents.fields), + name: 'Subscribe User', + subscribe: 'type = "track" and event = "User Subscribed"', + partnerAction: 'subscribeUser', + mapping: defaultValues(subscribeUser.fields), + type: 'automatic' + }, + { + name: 'Upsert User Attributes', + subscribe: 'type = "identify"', + partnerAction: 'upsertUserAttributes', + mapping: defaultValues(upsertUserAttributes.fields), + type: 'automatic' + }, + { + name: 'View Item', + subscribe: 'event = "Product Viewed"', + partnerAction: 'ecommEvent', + mapping: { + ...defaultValues(ecommEvent.fields), + eventType: 'view_item', + }, + type: 'automatic' + }, + { + name: 'Add to Cart', + subscribe: 'event = "Product Added"', + partnerAction: 'ecommEvent', + mapping: { + ...defaultValues(ecommEvent.fields), + eventType: 'add_to_cart', + }, + type: 'automatic' + }, + { + name: 'Purchase', + subscribe: 'event = "Order Completed"', + partnerAction: 'ecommEvent', + mapping: { + ...defaultValues(ecommEvent.fields), + eventType: 'purchase', + }, type: 'automatic' } - ] + ], + actions: { + customEvents, + ecommEvent, + upsertUserAttributes, + subscribeUser + } } export default destination diff --git a/packages/destination-actions/src/destinations/attentive/subscribeUser/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/attentive/subscribeUser/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..efa6b163f2 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/subscribeUser/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Attentive's subscribeUser destination action: all fields 1`] = ` +Object { + "externalEventId": "*Cc5M#wXxFe", + "locale": Object { + "country": "US", + "language": "en", + }, + "occurredAt": "*Cc5M#wXxFe", + "signUpSourceId": "*Cc5M#wXxFe", + "singleOptIn": true, + "subscriptionType": "MARKETING", + "user": Object { + "externalIdentifiers": Object { + "customIdentifiers": Object { + "userId": "testuserid", + }, + }, + }, +} +`; + +exports[`Testing snapshot for Attentive's subscribeUser destination action: required fields 1`] = ` +Object { + "locale": Object { + "country": "US", + "language": "en", + }, + "signUpSourceId": "*Cc5M#wXxFe", + "subscriptionType": "MARKETING", + "user": Object { + "externalIdentifiers": Object { + "customIdentifiers": Object { + "userId": "testuserid", + }, + }, + }, +} +`; diff --git a/packages/destination-actions/src/destinations/attentive/subscribeUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/attentive/subscribeUser/__tests__/index.test.ts new file mode 100644 index 0000000000..b6c6acc9ab --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/subscribeUser/__tests__/index.test.ts @@ -0,0 +1,147 @@ +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, + event: 'Identify Event', + messageId: '123e4567-e89b-12d3-a456-426614174000', + type: 'track', + userId: '123e4567-e89b-12d3-a456-426614174000', + context: { + traits: { + phone: '+3538675765689', + email: 'test@test.com' + } + }, + properties: {} +} as Partial + +const mapping = { + userIdentifiers: { + phone: { '@path': '$.context.traits.phone' }, + email: { '@path': '$.context.traits.email' } + }, + subscriptionType: 'MARKETING', + signUpSourceId: 'WEB', + singleOptIn: false, + locale: 'en-US' +} + +beforeEach(() => { + testDestination = createTestIntegration(Definition) + nock.cleanAll() +}) + +describe('Attentive.subscribeUser', () => { + it('should send a subscription request to Attentive', async () => { + const event = createTestEvent(validPayload) + + // Use a function to loosely match the body instead of exact object + nock('https://api.attentivemobile.com', { + reqheaders: { + authorization: 'Bearer test-api-key', + 'content-type': 'application/json', + 'user-agent': 'Segment (Actions)' + } + }) + .post('/v1/subscriptions', (body) => { + // Verify essential fields exist + return ( + body && + body.externalEventId === event.messageId && + body.subscriptionType === 'MARKETING' && + body.signUpSourceId === 'WEB' && + body.singleOptIn === false && + (body.locale === 'en-US' || (body.locale.language === 'en' && body.locale.country === 'US')) && // support either format + body.user && + body.user.phone === '+3538675765689' && + body.user.email === 'test@test.com' + ) + }) + .reply(200, {}) + + const responses = await testDestination.testAction('subscribeUser', { + event, + settings, + useDefaultMappings: true, + mapping + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should throw error if no user identifiers provided', async () => { + const badPayload = { + ...validPayload, + context: { + traits: {} + } + } + + const event = createTestEvent(badPayload) + + await expect( + testDestination.testAction('subscribeUser', { + event, + settings, + useDefaultMappings: true, + mapping + }) + ).rejects.toThrowError(new PayloadValidationError('At least one user identifier is required.')) + }) + + it('should not throw error if only one identifier is provided', async () => { + const partialPayload = { + ...validPayload, + context: { + traits: { + phone: '+3538675765689' + } + } + } + + const event = createTestEvent(partialPayload) + + nock('https://api.attentivemobile.com', { + reqheaders: { + authorization: 'Bearer test-api-key', + 'content-type': 'application/json', + 'user-agent': 'Segment (Actions)' + } + }) + .post('/v1/subscriptions', (body) => { + return ( + body && + body.externalEventId === event.messageId && + body.subscriptionType === 'MARKETING' && + body.signUpSourceId === 'WEB' && + body.singleOptIn === false && + (body.locale === 'en-US' || (body.locale.language === 'en' && body.locale.country === 'US')) && + body.user && + body.user.phone === '+3538675765689' && + !body.user.email + ) + }) + .reply(200, {}) + + const responses = await testDestination.testAction('subscribeUser', { + event, + settings, + useDefaultMappings: true, + mapping + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) +}) diff --git a/packages/destination-actions/src/destinations/attentive/subscribeUser/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/attentive/subscribeUser/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..24ee4e7fec --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/subscribeUser/__tests__/snapshot.test.ts @@ -0,0 +1,89 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'subscribeUser' +const destinationSlug = 'Attentive' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: + { + ...event.properties, + userIdentifiers: { + userId: "testuserid" + }, + locale: "en-US", + }, + 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('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: + { + ...event.properties, + userIdentifiers: { + userId: "testuserid" + }, + locale: "en-US", + }, + 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/attentive/subscribeUser/generated-types.ts b/packages/destination-actions/src/destinations/attentive/subscribeUser/generated-types.ts new file mode 100644 index 0000000000..5e74f5e5cb --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/subscribeUser/generated-types.ts @@ -0,0 +1,46 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * 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 + } + /** + * Timestamp for the event, ISO 8601 format. + */ + occurredAt?: string + /** + * A unique identifier representing this specific event. + */ + externalEventId?: string + /** + * Type of subscription + */ + subscriptionType: string + /** + * User locale. e.g. "en-US". Either Locale or Signup Source ID is required. + */ + locale?: string + /** + * A unique identifier for the sign up source. Talk to your Attentive represenative. Either Locale or Signup Source ID is required. + */ + signUpSourceId?: string + /** + * Whether to use single opt-in for the subscription. + */ + singleOptIn?: boolean +} diff --git a/packages/destination-actions/src/destinations/attentive/subscribeUser/index.ts b/packages/destination-actions/src/destinations/attentive/subscribeUser/index.ts new file mode 100644 index 0000000000..030c4c3922 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/subscribeUser/index.ts @@ -0,0 +1,31 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { userIdentifiers, occurredAt, externalEventId, subscriptionType, locale, signUpSourceId, singleOptIn } from '../fields' +import { API_URL, API_VERSION } from '../constants' +import { formatSubscribeUserJSON, validate, validateSubscribeUser } from '../functions' + +const action: ActionDefinition = { + title: 'Subscribe User to Attentive', + description: 'Send a subscription request to Attentive.', + defaultSubscription: 'type = "track" and event = "User Subscibed"', + fields: { + userIdentifiers, + occurredAt, + externalEventId, + subscriptionType, + locale, + signUpSourceId, + singleOptIn + }, + perform: (request, { payload }) => { + validate(payload) + validateSubscribeUser(payload) + return request(`${API_URL}/${API_VERSION}/subscriptions`, { + method: 'post', + json: formatSubscribeUserJSON(payload) + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/attentive/test-data.ts b/packages/destination-actions/src/destinations/attentive/test-data.ts new file mode 100644 index 0000000000..d5ac0ffadb --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/test-data.ts @@ -0,0 +1,123 @@ +import { SegmentEvent } from '@segment/actions-core' // Adjust the import as needed for your project + +// Define the function with a return type for clarity +export function getCustomEventsTestValidPayload(timestamp: string): Partial { + return { + 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' + } + } +} + +export function getCustomEventsTestMapping() { + return { + 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' } + } +} + +export function getCustomEventsTestExpectedPayload(validPayload: Record) { + return { + type: validPayload.type, + properties: validPayload.properties, + externalEventId: validPayload.messageId, + occurredAt: validPayload.timestamp, + user: { + phone: validPayload.context?.traits?.phone, + email: validPayload.context?.traits?.email, + externalIdentifiers: { + clientUserId: validPayload.userId + } + } + } +} + +export function getECommEventTestValidPayload(timestamp: string): Partial { + return { + timestamp, + event: 'Product Viewed', + messageId: '123e4567-e89b-12d3-a456-426614174001', + type: 'track', + userId: '123e4567-e89b-12d3-a456-426614174001', + context: { + traits: { + phone: '+3538675765689', + email: 'test@test.com' + } + }, + properties: { + items: [ + { + productId: 'prod_123', + productVariantId: 'var_456', + productImage: 'https://image-url.com/product.jpg', + productUrl: 'https://product-url.com', + name: 'Product X', + price: { + value: 29.99, + currency: 'USD' + }, + quantity: 1 + } + ] + } + } +} + +export function getECommEventTestMapping() { + return { + items: [ + { + productId: { '@path': '$.properties.items[0].productId' }, + productVariantId: { '@path': '$.properties.items[0].productVariantId' }, + productImage: { '@path': '$.properties.items[0].productImage' }, + productUrl: { '@path': '$.properties.items[0].productUrl' }, + name: { '@path': '$.properties.items[0].name' }, + price: { + value: { '@path': '$.properties.items[0].price.value' }, + currency: { '@path': '$.properties.items[0].price.currency' } + }, + quantity: { '@path': '$.properties.items[0].quantity' } + } + ], + userIdentifiers: { + phone: { '@path': '$.context.traits.phone' }, + email: { '@path': '$.context.traits.email' }, + clientUserId: { '@path': '$.userId' } + }, + occurredAt: { '@path': '$.timestamp' } + } +} + +export function getECommEventTestExpectedPayload(validPayload: Record) { + return { + items: validPayload.properties?.items, + occurredAt: validPayload.timestamp, + user: { + phone: validPayload.context?.traits?.phone, + email: validPayload.context?.traits?.email, + externalIdentifiers: { + clientUserId: validPayload.userId + } + } + } +} diff --git a/packages/destination-actions/src/destinations/attentive/types.ts b/packages/destination-actions/src/destinations/attentive/types.ts new file mode 100644 index 0000000000..2059fc4d78 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/types.ts @@ -0,0 +1,52 @@ +import { SUBSCRIPTION_TYPES } from "./constants" + +export interface EcommEventJSON { + items: Item[] + occurredAt?: string + user: User +} +export interface CustomEventJSON { + externalEventId?: string + occurredAt?: string + user: User + type: string + properties?: Record // cannot contain arrays +} +export interface UpsertUserAttributesJSON { + properties: Record // cannot contain arrays or objects + user: User +} +export interface SubscribeUserJSON { + externalEventId?: string + occurredAt?: string + user: User + subscriptionType: SubscriptionType + locale?: { + language: string + country: string + }, + signUpSourceId?: string // locale or signUpSourceId is required + singleOptIn?: boolean +} +export interface User { + phone?: string + email?: string + externalIdentifiers?: ExternalIdentifiers +} +export interface ExternalIdentifiers { + clientUserId?: string + customIdentifiers?: Record +} +export interface Item { + productId: string + productVariantId: string + productImage?: string + productUrl?: string + name?: string + price: { + value: number + currency?: string + } + quantity?: number +} +export type SubscriptionType = typeof SUBSCRIPTION_TYPES[number] \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/attentive/upsertUserAttributes/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/attentive/upsertUserAttributes/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..71812299ee --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/upsertUserAttributes/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Attentive's upsertUserAttributes destination action: all fields 1`] = ` +Object { + "properties": Object { + "testType": "%vhd63GyrzEnsdNhru", + }, + "user": Object { + "email": "ni@viltas.cf", + "externalIdentifiers": Object { + "clientUserId": "%vhd63GyrzEnsdNhru", + }, + "phone": "%vhd63GyrzEnsdNhru", + }, +} +`; + +exports[`Testing snapshot for Attentive's upsertUserAttributes destination action: required fields 1`] = ` +Object { + "properties": Object { + "testType": "%vhd63GyrzEnsdNhru", + }, + "user": Object { + "externalIdentifiers": Object { + "customIdentifiers": Object { + "userId": "testuserid", + }, + }, + }, +} +`; diff --git a/packages/destination-actions/src/destinations/attentive/upsertUserAttributes/__tests__/index.test.ts b/packages/destination-actions/src/destinations/attentive/upsertUserAttributes/__tests__/index.test.ts new file mode 100644 index 0000000000..2db6939700 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/upsertUserAttributes/__tests__/index.test.ts @@ -0,0 +1,107 @@ +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, + event: 'Custom Attribute Event', + messageId: '123e4567-e89b-12d3-a456-426614174000', + type: 'track', + userId: '123e4567-e89b-12d3-a456-426614174000', + context: { + traits: { + phone: '+3538675765689', + email: 'test@test.com' + } + }, + properties: { + age: '24', + birthday: '1986-11-16', + 'sign up': '2021-04-23T16:04:33Z', + 'favorite team': 'Minnesota Vikings', + 'Gift card balance': '50.89', + VIP: 'TRUE' + } +} as Partial + +const mapping = { + userIdentifiers: { + phone: { '@path': '$.context.traits.phone' }, + email: { '@path': '$.context.traits.email' }, + clientUserId: { '@path': '$.userId' } + }, + properties: { '@path': '$.properties' } +} + +beforeEach(() => { + testDestination = createTestIntegration(Definition) + nock.cleanAll() +}) + +describe('Attentive.upsertUserAttributes', () => { + it('should send custom attributes to Attentive', async () => { + const event = createTestEvent(validPayload) + + nock('https://api.attentivemobile.com', { + reqheaders: { + authorization: 'Bearer test-api-key', + 'content-type': 'application/json' + } + }) + .post('/v1/attributes/custom', (body) => { + return ( + body && + body.properties.age === '24' && + body.properties.birthday === '1986-11-16' && + body.properties['sign up'] === '2021-04-23T16:04:33Z' && + body.properties['favorite team'] === 'Minnesota Vikings' && + body.properties['Gift card balance'] === '50.89' && + body.properties.VIP === 'TRUE' && + body.user && + body.user.phone === '+3538675765689' && + body.user.email === 'test@test.com' && + body.user.externalIdentifiers && + body.user.externalIdentifiers.clientUserId === '123e4567-e89b-12d3-a456-426614174000' + ) + }) + .reply(200, {}) + + const responses = await testDestination.testAction('upsertUserAttributes', { + 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('upsertUserAttributes', { + 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/upsertUserAttributes/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/attentive/upsertUserAttributes/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..bd2ff89953 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/upsertUserAttributes/__tests__/snapshot.test.ts @@ -0,0 +1,81 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'upsertUserAttributes' +const destinationSlug = 'Attentive' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: + { + ...event.properties, + userIdentifiers: { + userId: "testuserid" + } + }, + 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('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + 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/attentive/upsertUserAttributes/generated-types.ts b/packages/destination-actions/src/destinations/attentive/upsertUserAttributes/generated-types.ts new file mode 100644 index 0000000000..da0233e1ba --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/upsertUserAttributes/generated-types.ts @@ -0,0 +1,28 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * 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 + } +} diff --git a/packages/destination-actions/src/destinations/attentive/upsertUserAttributes/index.ts b/packages/destination-actions/src/destinations/attentive/upsertUserAttributes/index.ts new file mode 100644 index 0000000000..6457458c46 --- /dev/null +++ b/packages/destination-actions/src/destinations/attentive/upsertUserAttributes/index.ts @@ -0,0 +1,31 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { userIdentifiers, properties } from '../fields' +import { API_URL, API_VERSION } from '../constants' +import { formatUpsertUserAttributesJSON, validate } from '../functions' + +const action: ActionDefinition = { + title: 'Upsert Custom Attributes', + description: 'Upserts custom attributes on a user profile in Attentive.', + defaultSubscription: 'type = "identify"', + fields: { + userIdentifiers, + properties: { + ...properties, + default: { + '@path': '$.traits' + }, + required: true + } + }, + perform: (request, { payload }) => { + validate(payload) + return request(`${API_URL}/${API_VERSION}/attributes/custom`, { + method: 'post', + json: formatUpsertUserAttributesJSON(payload) + }) + } +} + +export default action