diff --git a/packages/destination-actions/src/destinations/taguchi/__tests__/index.test.ts b/packages/destination-actions/src/destinations/taguchi/__tests__/index.test.ts new file mode 100644 index 0000000000..12b31f0a04 --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/__tests__/index.test.ts @@ -0,0 +1,18 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) + +describe('Taguchi', () => { + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + nock('https://your.destination.endpoint').get('*').reply(200, {}) + + // This should match your authentication.fields + const authData = {} + + await expect(testDestination.testAuthentication(authData)).resolves.not.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/taguchi/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/taguchi/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..58fb8bc96c --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-taguchi' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/taguchi/generated-types.ts b/packages/destination-actions/src/destinations/taguchi/generated-types.ts new file mode 100644 index 0000000000..4d87286607 --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/generated-types.ts @@ -0,0 +1,16 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Taguchi API Key used to authenticate requests to the Taguchi API. + */ + apiKey: string + /** + * The Taguchi URL Segment will send data to. This should be created in the Taguchi User Interface by navigating to 'Taguchi Integrations' then 'Integrations Setup.' + */ + integrationURL: string + /** + * The Taguchi ID of the organization to which this Subscriber belongs. + */ + organizationId: string +} diff --git a/packages/destination-actions/src/destinations/taguchi/index.ts b/packages/destination-actions/src/destinations/taguchi/index.ts new file mode 100644 index 0000000000..601632a87c --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/index.ts @@ -0,0 +1,48 @@ +import { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' +import syncAudience from './syncAudience' + +const destination: DestinationDefinition = { + name: 'Taguchi', + slug: 'actions-taguchi', + mode: 'cloud', + description: 'Sync user profile details, including Audience and Computed Trait details to Taguchi.', + authentication: { + scheme: 'custom', + fields: { + apiKey: { + label: 'API Key', + description: 'Taguchi API Key used to authenticate requests to the Taguchi API.', + type: 'string', + required: true + }, + integrationURL: { + label: 'Integration URL', + description: "The Taguchi URL Segment will send data to. This should be created in the Taguchi User Interface by navigating to 'Taguchi Integrations' then 'Integrations Setup.'", + type: 'string', + required: true + }, + organizationId: { + label: 'Organization ID', + description: 'The Taguchi ID of the organization to which this Subscriber belongs.', + type: 'string', + required: true + } + }, + testAuthentication: (request, {settings}) => { + // Return a request that tests/validates the user's credentials. + // If you do not have a way to validate the authentication fields safely, + // you can remove the `testAuthentication` function, though discouraged. + } + }, + onDelete: async (request, { settings, payload }) => { + // Return a request that performs a GDPR delete for the provided Segment userId or anonymousId + // provided in the payload. If your destination does not support GDPR deletion you should not + // implement this function and should remove it completely. + }, + actions: { + syncAudience + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/taguchi/syncAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/taguchi/syncAudience/__tests__/index.test.ts new file mode 100644 index 0000000000..74c56c9e42 --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/syncAudience/__tests__/index.test.ts @@ -0,0 +1,9 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('Taguchi.syncAudience', () => { + // TODO: Test your action +}) diff --git a/packages/destination-actions/src/destinations/taguchi/syncAudience/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/taguchi/syncAudience/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..784769d8a1 --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/syncAudience/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +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 = 'syncAudience' +const destinationSlug = 'Taguchi' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + 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/taguchi/syncAudience/generated-types.ts b/packages/destination-actions/src/destinations/taguchi/syncAudience/generated-types.ts new file mode 100644 index 0000000000..75de897007 --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/syncAudience/generated-types.ts @@ -0,0 +1,89 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * At least one identifier is required. Any identifiers sent will then become required for fugure updates to that Subscriber. + */ + identifiers: { + /** + * A unique identifier for the Subscriber. + */ + ref?: string + /** + * Email address of the Subscriber. + */ + email?: string + /** + * Phone number of the Subscriber. + */ + phone?: string + /** + * The internal Taguchi ID of the Subscriber. usually not visible ourside the Taguchi platform. + */ + id?: number + } + /** + * Standard traits for the Subscriber. All text fields. No specific formats for any of them. + */ + traits?: { + /** + * Title of the Subscriber. + */ + title?: string + /** + * First name of the Subscriber. + */ + firstname?: string + /** + * Last name of the Subscriber. + */ + lastname?: string + /** + * Date of birth of the Subscriber in ISO 8601 format (YYYY-MM-DD). + */ + dob?: string + /** + * Primary address line for the Subscriber. + */ + address?: string + /** + * Secondary address line for the Subscriber. + */ + address2?: string + /** + * Tertiary address line for the Subscriber. + */ + address3?: string + /** + * Suburb of the Subscriber. + */ + suburb?: string + /** + * State of the Subscriber. + */ + state?: string + /** + * Country of the Subscriber. + */ + country?: string + /** + * Postcode of the Subscriber. + */ + postcode?: string + /** + * Gender of the Subscriber. + */ + gender?: string + [k: string]: unknown + } + /** + * Array or comma delimited list of Taguchi List IDs to subscribe the Subscriber to. + */ + subscribeLists?: string[] + /** + * Array or comma delimited list of Taguchi List IDs to unsubscribe the Subscriber from. + */ + unsubscribeLists?: string[] + + timestamp: string +} diff --git a/packages/destination-actions/src/destinations/taguchi/syncAudience/index.ts b/packages/destination-actions/src/destinations/taguchi/syncAudience/index.ts new file mode 100644 index 0000000000..3b924cf3cb --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/syncAudience/index.ts @@ -0,0 +1,165 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { send } from './utils' + +const action: ActionDefinition = { + title: 'Sync Audience', + description: 'Sync Segment Audiences, Computed Traits and user profile traits Taguchi Lists.', + fields: { + identifiers: { + label: 'Subscriber Identifiers', + description: 'At least one identifier is required. Any identifiers sent will then become required for fugure updates to that Subscriber.', + type: 'object', + required: true, + properties: { + ref: { + label: 'Reference', + description: 'A unique identifier for the Subscriber.', + type: 'string' + }, + email: { + label: 'Email', + description: 'Email address of the Subscriber.', + type: 'string', + format: 'email' + }, + phone: { + label: 'Phone Number', + description: 'Phone number of the Subscriber.', + type: 'string' + }, + id: { + label: 'ID', + description: 'The internal Taguchi ID of the Subscriber. usually not visible ourside the Taguchi platform.', + type: 'integer' + }, + } + }, + traits: { + label: 'Traits', + description: 'Standard traits for the Subscriber. All text fields. No specific formats for any of them.', + type: 'object', + required: false, + additionalProperties: true, + properties: { + title: { + label: 'Title', + description: 'Title of the Subscriber.', + type: 'string', + required: false + }, + firstname: { + label: 'First Name', + description: 'First name of the Subscriber.', + type: 'string', + required: false + }, + lastname: { + label: 'Last Name', + description: 'Last name of the Subscriber.', + type: 'string', + required: false + }, + dob: { + label: 'Date of Birth', + description: + "Date of birth of the Subscriber in ISO 8601 format (YYYY-MM-DD).", + type: 'string', + format: 'date-time', + required: false + }, + address: { + label: 'Address Line 1', + description: + "Primary address line for the Subscriber.", + type: 'string', + required: false + }, + address2:{ + label:'Address Line 2', + description:'Secondary address line for the Subscriber.', + type:'string', + required:false + }, + address3:{ + label:'Address Line 3', + description:'Tertiary address line for the Subscriber.', + type:'string', + required:false + }, + suburb: { + label: 'Suburb', + description: 'Suburb of the Subscriber.', + type: 'string', + required: false + }, + state: { + label: 'State', + description: 'State of the Subscriber.', + type: 'string', + required: false + }, + country: { + label: 'Country', + description: 'Country of the Subscriber.', + type: 'string', + required: false + }, + postcode: { + label: 'Postcode', + description: 'Postcode of the Subscriber.', + type: 'string', + required: false + }, + gender: { + label: 'Gender', + description: 'Gender of the Subscriber.', + type: 'string', + required: false + } + }, + default: { + title: { '@path': '$.traits.title' }, + firstname: { '@path': '$.traits.first_name' }, + lastname: { '@path': '$.traits.last_name' }, + dob: { '@path': '$.traits.birthday' }, + address: { '@path': '$.traits.street' }, + address2: { '@path': '$.traits.address2' }, + address3: { '@path': '$.traits.address3' }, + suburb: { '@path': '$.traits.city' }, + state: { '@path': '$.traits.state' }, + country: { '@path': '$.traits.country' }, + postcode: { '@path': '$.traits.postal_code' }, + } + }, + subscribeLists: { + label: 'Lists to subscribe to', + description: 'Array or comma delimited list of Taguchi List IDs to subscribe the Subscriber to.', + type: 'string', + multiple: true + }, + unsubscribeLists: { + label: 'Lists to unsubscribe from', + description: 'Array or comma delimited list of Taguchi List IDs to unsubscribe the Subscriber from.', + type: 'string', + multiple: true + }, + timestamp: { + label: 'Timestamp', + description: 'The timestamp of the event in ISO 8601 format (YYYY-MM-DDTHH:mm:ssZ). Defaults to the current time if not provided.', + type: 'string', + format: 'date-time', + required: true, + default: { '@path': '$.timestamp' } + } + }, + perform: async (request, {payload, settings}) => { + await send(request, [payload], settings, false) + }, + performBatch: async (request, { payload, settings }) => { + await send(request, payload, settings, true) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/taguchi/syncAudience/types.ts b/packages/destination-actions/src/destinations/taguchi/syncAudience/types.ts new file mode 100644 index 0000000000..f8bb8266e3 --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/syncAudience/types.ts @@ -0,0 +1,48 @@ +export interface JSONItem { + profile: { + // The Taguchi ID of the organization to which this subscriber belongs. Must be an integer + organizationId: number + + // Customer must select which of the 4 id types below to send. That ID type then becomes required. More than one ID can be sent at a time. + ref?: string + email?: string + phone?: string + id?: number + + // Standard traits. All text fields. No specific formats for any of them. + title?: string + firstname?: string + lastname?: string + dob?: string + address?: string + address2?: string + address3?: string + suburb?: string + state?: string + country?: string + postcode?: string + gender?: string + + + // Custom traits. Including Computed Traits. + custom?: { + [key: string]: unknown + } + + // Audience details. Optional. + lists?: { + listId: number + subscribedTimestamp?: string + unsubscribedTimestamp?: string + subscriptionOption: string | null // A customer-defined text or serialized JSON field. e.g. List description + }[] + } +} + +export type JSON = JSONItem[] + +export type ResponseJSON = { + code: number + name: string + description: string +}[] \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/taguchi/syncAudience/utils.ts b/packages/destination-actions/src/destinations/taguchi/syncAudience/utils.ts new file mode 100644 index 0000000000..aa6232c439 --- /dev/null +++ b/packages/destination-actions/src/destinations/taguchi/syncAudience/utils.ts @@ -0,0 +1,115 @@ +import { Payload } from './generated-types' +import { Settings } from '../generated-types' +import { RequestClient, PayloadValidationError, MultiStatusResponse, JSONLikeObject } from '@segment/actions-core' +import { JSON, JSONItem, ResponseJSON } from './types' + +export function validate(payloads: Payload[]): Payload[]{ + if (payloads.length === 1) { + const p = payloads[0] + if (!p.identifiers || Object.keys(p.identifiers).length === 0) { + throw new PayloadValidationError('At least one identifier is required.') + } + } + return payloads +} + +export async function send(request: RequestClient, payloads: Payload[], settings: Settings, isBatch: boolean) { + + validate(payloads) + + const json: JSON = payloads.map(payload => buildJSON(payload, settings.organizationId)) + + const response = await request(`${settings.integrationURL}/subscriber`, { + method: 'POST', + json, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${settings.apiKey}` + } + }) + + if(isBatch){ + const multiStatusResponse = new MultiStatusResponse() + response.data.forEach((res, index) => { + if (res.code >= 200 && res.code < 300) { + multiStatusResponse.setSuccessResponseAtIndex(index, { + status: res.code, + sent: json[index] as unknown as JSONLikeObject, + body: res as unknown as JSONLikeObject + }); + } else { + multiStatusResponse.setErrorResponseAtIndex(index, { + status: res.code, + sent: json[index] as unknown as JSONLikeObject, + body: res as unknown as JSONLikeObject, + errormessage: res.description + }) + } + }) + return multiStatusResponse + } + + return response +} + +function buildJSON(payload: Payload, organizationId: string): JSONItem { + const { + identifiers, + traits: { + title, + firstname, + lastname, + dob, + address, + address2, + address3, + suburb, + state, + country, + postcode, + gender, + ...customTraits + } = {} + } = payload + + const json: JSONItem = { + profile: { + organizationId: Number(organizationId), + ...identifiers, + ...buildCustom(customTraits), + ...buildLists(payload) + } + } + return json +} + +function buildCustom(customTraits: Payload['traits']): JSONItem['profile']['custom'] | undefined { + if (customTraits && Object.keys(customTraits).length === 0) { + return { + custom: customTraits + } + } + return undefined +} + +function buildLists(payload: Payload): JSONItem['profile']['lists'] | undefined { + const lists = [] + + if (payload.subscribeLists && payload.subscribeLists.length > 0) { + lists.push(...payload.subscribeLists.map(list => ({ + listId: Number(list), + subscribedTimestamp: payload.timestamp, + subscriptionOption: null + }))) + } + + if (payload.unsubscribeLists && payload.unsubscribeLists.length > 0) { + lists.push(...payload.unsubscribeLists.map(list => ({ + listId: Number(list), + unsubscribedTimestamp: payload.timestamp, + subscriptionOption: null + }))) + } + + return lists.length > 0 ? lists : undefined +} \ No newline at end of file