diff --git a/packages/destination-actions/src/destinations/dotdigital/__tests__/index.test.ts b/packages/destination-actions/src/destinations/dotdigital/__tests__/index.test.ts new file mode 100644 index 00000000000..e0efab70e30 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/__tests__/index.test.ts @@ -0,0 +1,42 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) + +describe('Dotdigital', () => { + describe('testAuthentication', () => { + it('should validate valid api key', async () => { + nock('https://r1-api.dotdigital.com') + .get('/v2/data-fields/') + .matchHeader('Authorization', 'Basic YXBpX3VzZXJuYW1lOmFwaV9wYXNzd29yZA==') + .reply(200) + + const settings = { + api_host: 'https://r1-api.dotdigital.com', + username: 'api_username', + password: 'api_password' + } + await expect(testDestination.testAuthentication(settings)).resolves.not.toThrowError() + expect(nock.isDone()).toBe(true) + }) + + it('should not validate invalid api key', async () => { + nock('https://r1-api.dotdigital.com') + .get('/v2/data-fields/') + .matchHeader('Authorization', 'Basic YXBpX3VzZXJuYW1lOmFwaV9wYXNzd29yZA==') + .reply(401, { + message: 'Authorization has been denied for this request.' + }) + + const settings = { + api_host: 'https://r1-api.dotdigital.com', + username: 'api_username', + password: 'api_password' + } + + await expect(testDestination.testAuthentication(settings)).rejects.toThrowError() + expect(nock.isDone()).toBe(true) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/dotdigital/addContactToList/__tests__/index.test.ts b/packages/destination-actions/src/destinations/dotdigital/addContactToList/__tests__/index.test.ts new file mode 100644 index 00000000000..bab17518467 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/addContactToList/__tests__/index.test.ts @@ -0,0 +1,76 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +export const settings = { + api_host: 'https://r1-api.dotdigital.com', + username: 'api_username', + password: 'api_password' +} + +describe('Add Contact To List', () => { + it('should add contact to list with email identifier', async () => { + // Mock upsertContact function + nock(settings.api_host) + .patch(`/contacts/v3/email/test@example.com?merge-option=overwrite`) + .reply(200, { contactId: 123 }) + + const event = createTestEvent({ + type: 'identify', + context: { + traits: { + email: 'test@example.com' + } + } + }) + + const mapping = { + listId: 123456, + channelIdentifier: 'email', + emailIdentifier: { + '@path': '$.context.traits.email' + } + } + await expect( + testDestination.testAction('addContactToList', { + event, + mapping, + settings + }) + ).resolves.not.toThrowError() + }) + + it('should add contact to list with mobile number identifier', async () => { + // Mock upsertContact function + nock(settings.api_host) + .patch(`/contacts/v3/mobile-number/1234567890?merge-option=overwrite`) + .reply(200, { contactId: 123 }) + + const event = createTestEvent({ + type: 'identify', + context: { + traits: { + phone: '1234567890' + } + } + }) + + const mapping = { + listId: 123456, + channelIdentifier: 'mobile-number', + mobileNumberIdentifier: { + '@path': '$.context.traits.phone' + } + } + + await expect( + testDestination.testAction('addContactToList', { + event, + mapping, + settings + }) + ).resolves.not.toThrowError() + }) +}) diff --git a/packages/destination-actions/src/destinations/dotdigital/addContactToList/generated-types.ts b/packages/destination-actions/src/destinations/dotdigital/addContactToList/generated-types.ts new file mode 100644 index 00000000000..bbc881c290b --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/addContactToList/generated-types.ts @@ -0,0 +1,26 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Select the field to identify contacts. + */ + channelIdentifier: string + /** + * The Contact's email address. + */ + emailIdentifier?: string + /** + * The Contact's mobile number. + */ + mobileNumberIdentifier?: string + /** + * The list to add the contact to. + */ + listId: number + /** + * An object containing key/value pairs for data fields assigned to this Contact. Custom Data Fields must already be defined in Dotdigital. + */ + dataFields?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/dotdigital/addContactToList/index.ts b/packages/destination-actions/src/destinations/dotdigital/addContactToList/index.ts new file mode 100644 index 00000000000..0da79f708cd --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/addContactToList/index.ts @@ -0,0 +1,53 @@ +import { ActionDefinition, DynamicFieldResponse, RequestClient } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { DDContactApi, DDListsApi, DDDataFieldsApi } from '../api' +import { contactIdentifier } from '../input-fields' + +const action: ActionDefinition = { + title: 'Add Contact to List', + description: 'Adds a Contact to a list.', + defaultSubscription: 'type = "track" and event = "Add Contact to List"', + fields: { + ...contactIdentifier, + listId: { + label: 'List', + description: `The list to add the contact to.`, + type: 'number', + required: true, + allowNull: false, + disabledInputMethods: ['literal', 'variable', 'function', 'freeform', 'enrichment'], + dynamic: true + }, + dataFields: { + label: 'Data Fields', + description: `An object containing key/value pairs for data fields assigned to this Contact. Custom Data Fields must already be defined in Dotdigital.`, + type: 'object', + required: false, + disabledInputMethods: ['literal', 'variable', 'function', 'freeform', 'enrichment'], + defaultObjectUI: 'keyvalue:only', + additionalProperties: false, + dynamic: true + } + }, + dynamicFields: { + listId: async (request: RequestClient, { settings }): Promise => { + return new DDListsApi(settings, request).getLists() + }, + dataFields: { + __keys__: async (request, { settings }) => { + return new DDDataFieldsApi(settings, request).getDataFields() + } + } + }, + + perform: async (request, { settings, payload }) => { + const fieldsAPI = new DDDataFieldsApi(settings, request) + await fieldsAPI.validateDataFields(payload) + + const contactApi = new DDContactApi(settings, request) + return contactApi.upsertContact(payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/dotdigital/api/dd-api.ts b/packages/destination-actions/src/destinations/dotdigital/api/dd-api.ts new file mode 100644 index 00000000000..4edca3e0765 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/api/dd-api.ts @@ -0,0 +1,73 @@ +import { RequestClient } from '@segment/actions-core' +import type { Settings } from '../generated-types' + +abstract class DDApi { + private readonly apiUrl: string + private readonly request: RequestClient + + protected constructor(settings: Settings, request: RequestClient) { + this.apiUrl = settings.api_host + this.request = request + } + + /** + * Generic GET method + * @param endpoint - The API endpoint to call. + * @param params - An object containing query parameters. + * + * @returns A an object of type TResponse containing the response data. + */ + protected async get(endpoint: string, params?: Record) { + const url = new URL(`${this.apiUrl}${endpoint}`) + if (params) { + url.search = new URLSearchParams( + Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])) + ).toString() + } + return await this.request(`${url}`, { + method: 'GET' + }) + } + + /** + * Generic POST method + * @param endpoint - The API endpoint to call. + * @param data - The data to send in the client body. + * + * @returns an object of type TResponse containing the response data. + */ + protected async post(endpoint: string, data: RequestJSON) { + return await this.request(`${this.apiUrl}${endpoint}`, { + method: 'POST', + json: data, + headers: { 'Content-Type': 'application/json' } + }) + } + + /** + * Generic DELETE method + * @param endpoint - The API endpoint to call. + */ + protected async delete(endpoint: string) { + return await this.request(`${this.apiUrl}${endpoint}`, { + method: 'DELETE' + }) + } + + /** + * Generic PATCH method + * @param endpoint - The API endpoint to call. + * @param data - The data to send in the client body. + * + * @returns an object of type TResponse containing the response data. + */ + protected async patch(endpoint: string, data: RequestJSON) { + return await this.request(`${this.apiUrl}${endpoint}`, { + method: 'PATCH', + json: data, + headers: { 'Content-Type': 'application/json' } + }) + } +} + +export default DDApi diff --git a/packages/destination-actions/src/destinations/dotdigital/api/index.ts b/packages/destination-actions/src/destinations/dotdigital/api/index.ts new file mode 100644 index 00000000000..1ba4d17fa20 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/api/index.ts @@ -0,0 +1,4 @@ +export { default as DDContactApi } from './resources/dd-contact-api' +export { default as DDListsApi } from './resources/dd-lists-api' +export { default as DDEnrolmentApi } from './resources/dd-enrolment-api' +export { default as DDDataFieldsApi } from './resources/dd-datafields-api' diff --git a/packages/destination-actions/src/destinations/dotdigital/api/resources/dd-contact-api.ts b/packages/destination-actions/src/destinations/dotdigital/api/resources/dd-contact-api.ts new file mode 100644 index 00000000000..4e929de38a7 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/api/resources/dd-contact-api.ts @@ -0,0 +1,83 @@ +import { ModifiedResponse, RequestClient } from '@segment/actions-core' +import type { Settings } from '../../generated-types' +import DDApi from '../dd-api' +import { Contact, ChannelIdentifier, Identifiers, ChannelProperties, UpsertContactJSON, DataFields } from '../types' +import type { Payload } from '../../addContactToList/generated-types' + +class DDContactApi extends DDApi { + constructor(settings: Settings, client: RequestClient) { + super(settings, client) + } + + /** + * Fetches a contact from Dotdigital API. + * + * @param idType - The type of identifier (e.g., email, mobile number). + * @param idValue - The value of the identifier. + * + * @returns A promise that resolves to a ContactResponse. + */ + async getContact(idType: string, idValue: string | undefined): Promise { + const response: ModifiedResponse = await this.get(`/contacts/v3/${idType}/${idValue}`) + return response.data + } + + /** + * Fetches a contact from Dotdigital API via means of Patch. + * + * @param channelIdentifier - The identifier of the contact channel. + * @param data - The data to be sent in the request body. + * + * @returns A promise that resolves to a ContactResponse. + */ + async fetchOrCreateContact(channelIdentifier: ChannelIdentifier, data: T): Promise { + const [[idType, idValue]] = Object.entries(channelIdentifier) + const response: ModifiedResponse = await this.patch(`/contacts/v3/${idType}/${idValue}`, data) + return response.data + } + + /** + * Creates or updates a contact . + * @param {Payload} payload - The event payload. + * @returns {Promise} A promise resolving to the contact data. + */ + public async upsertContact(payload: Payload): Promise { + const { channelIdentifier, emailIdentifier, mobileNumberIdentifier, listId, dataFields } = payload + + const idValue = channelIdentifier === 'email' ? emailIdentifier : mobileNumberIdentifier + + const identifiers: Identifiers = { + ...(emailIdentifier && { email: emailIdentifier }), + ...(mobileNumberIdentifier && { mobileNumber: mobileNumberIdentifier }) + } + + const channelProperties: ChannelProperties = { + ...(emailIdentifier && { + email: { + status: 'subscribed', + emailType: 'html', + optInType: 'single' + } + }), + ...(mobileNumberIdentifier && { + sms: { status: 'subscribed' } + }) + } + + const data: UpsertContactJSON = { + identifiers, + channelProperties, + lists: [listId], + dataFields: dataFields as DataFields + } + + const response: ModifiedResponse = await this.patch( + `/contacts/v3/${channelIdentifier}/${idValue}?merge-option=overwrite`, + data + ) + + return response.data + } +} + +export default DDContactApi diff --git a/packages/destination-actions/src/destinations/dotdigital/api/resources/dd-datafields-api.ts b/packages/destination-actions/src/destinations/dotdigital/api/resources/dd-datafields-api.ts new file mode 100644 index 00000000000..7c75981fb54 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/api/resources/dd-datafields-api.ts @@ -0,0 +1,124 @@ +import { RequestClient, DynamicFieldResponse, ModifiedResponse, PayloadValidationError } from '@segment/actions-core' +import type { Settings } from '../../generated-types' +import DDApi from '../dd-api' +import { DataField } from '../types' +import type { Payload } from '../../addContactToList/generated-types' +import type { FieldTypeName } from '@segment/actions-core/destination-kittypes' + +class DDDataFieldsApi extends DDApi { + constructor(settings: Settings, client: RequestClient) { + super(settings, client) + } + + /** + * Fetches the list of data fields from Dotdigital API. + * + * @returns A promise that resolves to a DynamicFieldResponse. + */ + async getDataFields(): Promise { + try { + const choices = [] + const response: ModifiedResponse = await this.get('/v2/data-fields/') + const dataFields = response.data + + choices.push( + ...dataFields.map((dataField) => ({ + value: dataField.name, + label: dataField.name, + type: this.mapDataFieldType(dataField.type) + })) + ) + + return { choices } + } catch (error) { + return { + choices: [], + nextPage: '', + error: { + message: 'Failed to fetch data fields', + code: 'DATA_FIELDS_FETCH_ERROR' + } + } + } + } + + mapDataFieldType(fieldType: string): FieldTypeName { + switch (fieldType) { + case 'String': + return 'string' + case 'Numeric': + return 'number' + case 'Date': + return 'datetime' + case 'Boolean': + return 'boolean' + default: + throw new PayloadValidationError(`Invalid data field type: ${fieldType}`) + } + } + + isNumeric(value: unknown): boolean { + const type = typeof value + return (type === 'number' || type === 'string') && !isNaN(Number(value)) + } + + async validateDataFields(payload: Payload) { + if (!payload.dataFields) { + return + } + + const response: ModifiedResponse = await this.get('/v2/data-fields/') + const ddDataFields = response.data + + for (const [key, value] of Object.entries(payload.dataFields)) { + let validatedValue = value + const ddDataField = ddDataFields.find((obj) => obj.name === key) + + if (!ddDataField) { + throw new PayloadValidationError(`Data field ${key} not found in Dotdigital`) + } + + switch (ddDataField.type) { + case 'Date': + if (typeof value !== 'string') { + throw new PayloadValidationError(`Data field ${key} value ${value} is not a valid date`) + } else { + const date = new Date(value).toISOString() + if (date === undefined) { + throw new PayloadValidationError(`Data field ${key} value ${value} is not a valid date`) + } + validatedValue = date + } + break + case 'Numeric': + if (typeof value === 'string') { + if (!this.isNumeric(value)) { + throw new PayloadValidationError(`Data field ${key} value ${value} is not a valid number`) + } + } else if (typeof value !== 'number') { + throw new PayloadValidationError(`Data field ${key} value ${value} is not a valid number`) + } + break + case 'Boolean': + if (typeof value === 'string' && value.trim().toLocaleLowerCase() === 'true') { + validatedValue = true + } else if (typeof value === 'string' && value.trim().toLocaleLowerCase() === 'false') { + validatedValue = false + } + validatedValue = Boolean(validatedValue) + break + case 'String': + if (typeof value !== 'string') { + throw new PayloadValidationError(`Data field ${key} value ${value} is not a valid string`) + } + validatedValue = String(value).trim() + break + } + + payload.dataFields[key] = validatedValue + } + return payload.dataFields + } +} + +export default DDDataFieldsApi diff --git a/packages/destination-actions/src/destinations/dotdigital/api/resources/dd-enrolment-api.ts b/packages/destination-actions/src/destinations/dotdigital/api/resources/dd-enrolment-api.ts new file mode 100644 index 00000000000..916cd33e70b --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/api/resources/dd-enrolment-api.ts @@ -0,0 +1,61 @@ +import { ModifiedResponse, RequestClient, DynamicFieldResponse } from '@segment/actions-core' +import type { Settings } from '../../generated-types' +import DDApi from '../dd-api' +import { Contact, Program, ProgramStatus, ProgramEnrolment, ProgramEnrolementJSON } from '../types' + +/** + * Class representing the Dotdigital Enrolment API. + * Extends the base Dotdigital API class. + */ +class DDEnrolmentApi extends DDApi { + constructor(settings: Settings, client: RequestClient) { + super(settings, client) + } + + /** + * Fetches active programs from the Dotdigital API. + * @returns {Promise} A promise resolving to the list of active programs. + */ + public async getPrograms(): Promise { + try { + const response: ModifiedResponse = await this.get('/v2/programs') + const programs = response.data + const choices = programs + .filter((program: Program) => program.status === ProgramStatus.Active) + .map((program: Program) => ({ + value: program.id.toString(), + label: program.name + })) + return { choices } + } catch (error) { + return { + choices: [], + nextPage: '', + error: { + message: 'Failed to fetch Programs', + code: 'PROGRAM_FETCH_ERROR' + } + } + } + } + + /** + * Enrols a contact into a program. + * @param {string} programId - The ID of the program. + * @param {Contact} contact - The contact to enrol. + * @returns {Promise} A promise resolving to the program enrolment details. + */ + public async enrolContact(programId: string, contact: Contact): Promise { + const json: ProgramEnrolementJSON = { + contacts: [contact.contactId], + programId + } + const response: ModifiedResponse = await this.post( + '/v2/programs/enrolments', + json + ) + return response.data + } +} + +export default DDEnrolmentApi diff --git a/packages/destination-actions/src/destinations/dotdigital/api/resources/dd-lists-api.ts b/packages/destination-actions/src/destinations/dotdigital/api/resources/dd-lists-api.ts new file mode 100644 index 00000000000..faf02d961e9 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/api/resources/dd-lists-api.ts @@ -0,0 +1,75 @@ +import { ModifiedResponse, RequestClient, DynamicFieldResponse } from '@segment/actions-core' +import type { Settings } from '../../generated-types' +import DDApi from '../dd-api' +import { List } from '../types' + +class DDListsApi extends DDApi { + constructor(settings: Settings, request: RequestClient) { + super(settings, request) + } + + /** + * Gets address book lists. + * @param {number} select - Paging number of records to retrieve + * @param {number} skip - Paging number of records to skip + * @returns {Promise} A promise that resolves to the response of the update operation. + */ + async getListsPaging(select = 1000, skip = 0): Promise> { + return await this.get('/v2/address-books', { select, skip }) + } + + /** + * Fetches the list of lists from Dotdigital API. + * + * @returns A promise that resolves to a DynamicFieldResponse. + */ + async getLists(): Promise { + const choices = [] + const select = 200 + let skip = 0 + + let hasMoreData = true + while (hasMoreData) { + try { + const response = await this.getListsPaging(select, skip) + const content = response.data + if (content.length === 0) { + hasMoreData = false + break + } else { + choices.push( + ...content.map((list: List) => ({ + value: list.id.toString(), + label: list.name + })) + ) + skip += select + } + } catch (error) { + return { + choices: [], + nextPage: '', + error: { + message: 'Failed to fetch lists', + code: 'LIST_FETCH_ERROR' + } + } + } + } + return { choices: choices } + } + + /** + * Deletes a contact from a specified list in Dotdigital API. + * + * @param listId - The ID of the list. + * @param contactId - The ID of the contact to be deleted. + * + * @returns A promise that resolves when the contact is deleted. + */ + async deleteContactFromList(listId: number, contactId: number) { + await this.delete(`/v2/address-books/${listId}/contacts/${contactId}`) + } +} + +export default DDListsApi diff --git a/packages/destination-actions/src/destinations/dotdigital/api/types.ts b/packages/destination-actions/src/destinations/dotdigital/api/types.ts new file mode 100644 index 00000000000..3535f061af0 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/api/types.ts @@ -0,0 +1,87 @@ +export interface List { + id: number + name: string + status: string +} +export interface DataField { + name: string + type: 'String' | 'Numeric' | 'Date' | 'Boolean' + visibility: string + defaultValue: string | null +} + +export interface Identifiers { + email?: string + mobileNumber?: string +} + +export interface DataFields { + [key: string]: string | number | boolean | null +} + +export interface ChannelProperties { + email?: { + status: string + emailType: string + optInType: string + } + sms?: { + status: string + } +} + +interface ConsentRecord { + text: string + dateTimeConsented: string + url: string + ipAddress: string + userAgent: string +} + +export interface Contact { + contactId: number + status: string + created: string + updated: string + identifiers: Identifiers + dataFields: DataFields + channelProperties: ChannelProperties + lists: List[] + consentRecords: ConsentRecord[] +} + +export enum ProgramStatus { + Active = 'Active', + Draft = 'Draft', + Deactivated = 'Deactivated' +} + +export interface Program { + id: number + name: string + status: ProgramStatus + dateCreated: string // ISO date string +} + +export interface ProgramEnrolment { + id: string + programId: number + status: string + dateCreated: string + contacts: null | unknown + addressBooks: null | unknown +} + +export interface ProgramEnrolementJSON { + contacts: number[] + programId: string +} + +export type ChannelIdentifier = { email: string; 'mobile-number'?: never } | { 'mobile-number': string; email?: never } + +export interface UpsertContactJSON { + identifiers: Identifiers + channelProperties: ChannelProperties + lists: number[] + dataFields?: DataFields +} diff --git a/packages/destination-actions/src/destinations/dotdigital/enrolContact/__tests__/index.test.ts b/packages/destination-actions/src/destinations/dotdigital/enrolContact/__tests__/index.test.ts new file mode 100644 index 00000000000..975dc8edee1 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/enrolContact/__tests__/index.test.ts @@ -0,0 +1,81 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +export const settings = { + api_host: 'https://r1-api.dotdigital.com', + username: 'api_username', + password: 'api_password' +} + +describe('Enroll Contact to Program', () => { + it('should enroll contact to a program with email identifier', async () => { + // Mock API calls for enrolling contact + nock(settings.api_host).patch('/contacts/v3/email/test@example.com').reply(200, { contactId: '123' }) + + nock(settings.api_host) + .post('/v2/programs/enrolments', { contacts: ['123'], programId: '456' }) // Updated body + .reply(201, { success: true }) // Add a valid JSON response + + const event = createTestEvent({ + type: 'identify', + context: { + traits: { + email: 'test@example.com' + } + } + }) + + const mapping = { + programId: 456, + channelIdentifier: 'email', + emailIdentifier: { + '@path': '$.context.traits.email' + } + } + + await expect( + testDestination.testAction('enrolContact', { + event, + mapping, + settings + }) + ).resolves.not.toThrowError() + }) + + it('should enroll contact to a program with mobile number identifier', async () => { + // Mock API calls for enrolling contact + nock(settings.api_host).get('/contacts/v3/mobile-number/1234567890').reply(200, { contactId: '123' }) + + nock(settings.api_host) + .post('/v2/programs/enrolments', { contacts: ['123'], programId: '456' }) // Updated body + .reply(201, { success: true }) // Add a valid JSON response + + const event = createTestEvent({ + type: 'identify', + context: { + traits: { + phone: '1234567890' + } + } + }) + + const mapping = { + programId: 456, + channelIdentifier: 'mobile-number', + mobileNumberIdentifier: { + '@path': '$.context.traits.phone' + } + } + + await expect( + testDestination.testAction('enrollContact', { + event, + mapping, + settings + }) + ).resolves.not.toThrowError() + }) +}) diff --git a/packages/destination-actions/src/destinations/dotdigital/enrolContact/generated-types.ts b/packages/destination-actions/src/destinations/dotdigital/enrolContact/generated-types.ts new file mode 100644 index 00000000000..586ff52d1f8 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/enrolContact/generated-types.ts @@ -0,0 +1,20 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Select the field to identify contacts. + */ + channelIdentifier: string + /** + * The Contact's email address. + */ + emailIdentifier?: string + /** + * The Contact's mobile number. + */ + mobileNumberIdentifier?: string + /** + * List of active programs + */ + programId: string +} diff --git a/packages/destination-actions/src/destinations/dotdigital/enrolContact/index.ts b/packages/destination-actions/src/destinations/dotdigital/enrolContact/index.ts new file mode 100644 index 00000000000..d697d8a2d4e --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/enrolContact/index.ts @@ -0,0 +1,55 @@ +import { ActionDefinition, RequestClient, DynamicFieldResponse, PayloadValidationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { contactIdentifier } from '../input-fields' +import { DDEnrolmentApi, DDContactApi } from '../api' +import { ChannelIdentifier, Identifiers } from '../api/types' + +const action: ActionDefinition = { + title: 'Enrol Contact to Program', + description: 'Creates a program enrolment.', + defaultSubscription: 'type = "track" and event = "Enrol Contact to Program"', + fields: { + ...contactIdentifier, + programId: { + label: 'Program', + description: `List of active programs`, + type: 'string', + required: true, + dynamic: true + } + }, + + dynamicFields: { + programId: async (request: RequestClient, { settings }: { settings: Settings }): Promise => { + return new DDEnrolmentApi(settings, request).getPrograms() + } + }, + + perform: async (request, { settings, payload }) => { + const contactApi = new DDContactApi(settings, request) + const enrolmentApi = new DDEnrolmentApi(settings, request) + const { channelIdentifier, emailIdentifier, mobileNumberIdentifier, programId } = payload + + const identifiers: Identifiers = { + email: emailIdentifier ?? undefined, + mobileNumber: mobileNumberIdentifier ?? undefined + } + + let channel: ChannelIdentifier + + if (channelIdentifier === 'email' && typeof emailIdentifier === 'string') { + channel = { email: emailIdentifier } + } else if (channelIdentifier === 'mobile-number' && typeof mobileNumberIdentifier === 'string') { + channel = { 'mobile-number': mobileNumberIdentifier } + } else { + throw new PayloadValidationError('Invalid channel identifier') + } + + const contact = await contactApi.fetchOrCreateContact(channel, { identifiers }) + + return enrolmentApi.enrolContact(programId, contact) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/dotdigital/generated-types.ts b/packages/destination-actions/src/destinations/dotdigital/generated-types.ts new file mode 100644 index 00000000000..c4f787ca052 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/generated-types.ts @@ -0,0 +1,16 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * The region your account is in + */ + api_host: string + /** + * Your Dotdigital username + */ + username: string + /** + * Your Dotdigital password. + */ + password: string +} diff --git a/packages/destination-actions/src/destinations/dotdigital/index.ts b/packages/destination-actions/src/destinations/dotdigital/index.ts new file mode 100644 index 00000000000..a109b69bf5d --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/index.ts @@ -0,0 +1,59 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import removeContactFromList from './removeContactFromList' +import enrolContact from './enrolContact' +import addContactToList from './addContactToList' +const destination: DestinationDefinition = { + name: 'Dotdigital', + slug: 'actions-dotdigital', + description: 'Send Segment events and user profile data to Dotdigital.', + mode: 'cloud', + authentication: { + scheme: 'basic', + fields: { + api_host: { + label: 'Region', + description: 'The region your account is in', + type: 'string', + choices: [ + { value: 'https://r1-api.dotdigital.com', label: 'r1' }, + { value: 'https://r2-api.dotdigital.com', label: 'r2' }, + { value: 'https://r3-api.dotdigital.com', label: 'r3' } + ], + default: 'https://r1-api.dotdigital.com', + required: true + }, + username: { + label: 'Username', + description: 'Your Dotdigital username', + type: 'string', + required: true + }, + password: { + label: 'Password', + description: 'Your Dotdigital password.', + type: 'password', + required: true + } + }, + testAuthentication: async (request, { settings }) => { + return await request(`${settings.api_host}/v2/data-fields/`) + } + }, + + extendRequest({ settings }) { + return { + username: settings.username, + password: settings.password + } + }, + + actions: { + removeContactFromList, + enrolContact, + addContactToList + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/dotdigital/input-fields/contact-identifier.ts b/packages/destination-actions/src/destinations/dotdigital/input-fields/contact-identifier.ts new file mode 100644 index 00000000000..bfd03850691 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/input-fields/contact-identifier.ts @@ -0,0 +1,59 @@ +import { ContactIdentifier } from './types' +import { InputField } from '@segment/actions-core' + +const channelIdentifier: InputField = { + label: 'Contact Identifier type', + description: 'Select the field to identify contacts.', + type: 'string', + default: 'email', + required: true, + choices: [ + { label: 'Email address', value: 'email' }, + { label: 'Mobile number', value: 'mobile-number' } + ] +} + +const emailIdentifier: InputField = { + label: 'Email Address', + description: "The Contact's email address.", + type: 'string', + format: 'email', + default: { + '@if': { + exists: { '@path': '$.traits.email' }, + then: { '@path': '$.traits.email' }, + else: { '@path': '$.properties.email' } + } + }, + depends_on: { + conditions: [{ fieldKey: 'channelIdentifier', operator: 'is', value: 'email' }] + }, + required: { + conditions: [{ fieldKey: 'channelIdentifier', operator: 'is', value: 'email' }] + } +} + +const mobileNumberIdentifier: InputField = { + label: 'Mobile Number', + description: "The Contact's mobile number.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.phone' }, + then: { '@path': '$.traits.phone' }, + else: { '@path': '$.properties.phone' } + } + }, + depends_on: { + conditions: [{ fieldKey: 'channelIdentifier', operator: 'is', value: 'mobile-number' }] + }, + required: { + conditions: [{ fieldKey: 'channelIdentifier', operator: 'is', value: 'mobile-number' }] + } +} + +export const contactIdentifier: ContactIdentifier = { + channelIdentifier, + emailIdentifier, + mobileNumberIdentifier +} diff --git a/packages/destination-actions/src/destinations/dotdigital/input-fields/index.ts b/packages/destination-actions/src/destinations/dotdigital/input-fields/index.ts new file mode 100644 index 00000000000..46c2f84d736 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/input-fields/index.ts @@ -0,0 +1 @@ +export { contactIdentifier } from './contact-identifier' diff --git a/packages/destination-actions/src/destinations/dotdigital/input-fields/types.ts b/packages/destination-actions/src/destinations/dotdigital/input-fields/types.ts new file mode 100644 index 00000000000..7b94e4893c2 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/input-fields/types.ts @@ -0,0 +1,7 @@ +import { InputField } from '@segment/actions-core' + +export interface ContactIdentifier { + channelIdentifier: InputField + emailIdentifier: InputField + mobileNumberIdentifier: InputField +} diff --git a/packages/destination-actions/src/destinations/dotdigital/removeContactFromList/__tests__/index.test.ts b/packages/destination-actions/src/destinations/dotdigital/removeContactFromList/__tests__/index.test.ts new file mode 100644 index 00000000000..a952119cf96 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/removeContactFromList/__tests__/index.test.ts @@ -0,0 +1,83 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +export const settings = { + api_host: 'https://r1-api.dotdigital.com', + username: 'api_username', + password: 'api_password' +} + +describe('Remove contact from list', () => { + it('should remove contact from list with email identifier', async () => { + // Mock getContact function + nock(settings.api_host) + .get('/contacts/v3/email/test@example.com') + .reply(200, { contactId: '123', lists: [{ id: 123456 }] }) // Correct format for lists + + // Mock deleteContactFromList function + nock(settings.api_host).delete(`/v2/address-books/123456/contacts/123`).reply(204) + + const event = createTestEvent({ + type: 'identify', + context: { + traits: { + email: 'test@example.com' + } + } + }) + + const mapping = { + listId: 123456, + channelIdentifier: 'email', + emailIdentifier: { + '@path': '$.context.traits.email' + } + } + + await expect( + testDestination.testAction('removeContactFromList', { + event, + mapping, + settings + }) + ).resolves.not.toThrowError() + }) + + it('should remove contact from list with mobile number identifier', async () => { + // Mock getContact function + nock(settings.api_host) + .get('/contacts/v3/mobile-number/1234567890') + .reply(200, { contactId: '123', lists: [{ id: 123456 }] }) // Correct format for lists + + // Mock deleteContactFromList function + nock(settings.api_host).delete(`/v2/address-books/123456/contacts/123`).reply(204) + + const event = createTestEvent({ + type: 'identify', + context: { + traits: { + phone: '1234567890' + } + } + }) + + const mapping = { + listId: 123456, + channelIdentifier: 'mobile-number', + mobileNumberIdentifier: { + '@path': '$.context.traits.phone' + } + } + + await expect( + testDestination.testAction('removeContactFromList', { + event, + mapping, + settings + }) + ).resolves.not.toThrowError() + }) +}) diff --git a/packages/destination-actions/src/destinations/dotdigital/removeContactFromList/generated-types.ts b/packages/destination-actions/src/destinations/dotdigital/removeContactFromList/generated-types.ts new file mode 100644 index 00000000000..03ba81ea06c --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/removeContactFromList/generated-types.ts @@ -0,0 +1,20 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Select the field to identify contacts. + */ + channelIdentifier: string + /** + * The Contact's email address. + */ + emailIdentifier?: string + /** + * The Contact's mobile number. + */ + mobileNumberIdentifier?: string + /** + * The List to remove the Contact from. + */ + listId: number +} diff --git a/packages/destination-actions/src/destinations/dotdigital/removeContactFromList/index.ts b/packages/destination-actions/src/destinations/dotdigital/removeContactFromList/index.ts new file mode 100644 index 00000000000..aee77ce331a --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/removeContactFromList/index.ts @@ -0,0 +1,40 @@ +import { ActionDefinition, DynamicFieldResponse, RequestClient } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { DDContactApi, DDListsApi } from '../api' +import { contactIdentifier } from '../input-fields' + +const action: ActionDefinition = { + title: 'Remove Contact from List', + description: 'Removes a Contact from a List.', + defaultSubscription: 'type = "track" and event = "Remove Contact from List"', + fields: { + ...contactIdentifier, + listId: { + label: 'List', + description: `The List to remove the Contact from.`, + type: 'number', + required: true, + dynamic: true + } + }, + + dynamicFields: { + listId: async (request: RequestClient, { settings }): Promise => { + return new DDListsApi(settings, request).getLists() + } + }, + + perform: async (request, { settings, payload }) => { + const { channelIdentifier, emailIdentifier, mobileNumberIdentifier, listId } = payload + const identifierValue = channelIdentifier === 'email' ? emailIdentifier : mobileNumberIdentifier + + const contact = new DDContactApi(settings, request) + const response = await contact.getContact(channelIdentifier, identifierValue) + + const lists = new DDListsApi(settings, request) + return lists.deleteContactFromList(listId, response.contactId) + } +} + +export default action