From fd8ef8912eeb12a3cbb71d8afdfce436265e09c0 Mon Sep 17 00:00:00 2001 From: Shaun Hogan Date: Tue, 15 Apr 2025 13:10:30 +0200 Subject: [PATCH 1/4] Initial commit --- .../src/destinations/dotdigital/README.md | 20 ++++ .../dotdigital/__tests__/index.test.ts | 43 +++++++ .../addContactToList/__tests__/index.test.ts | 76 ++++++++++++ .../addContactToList/generated-types.ts | 26 +++++ .../dotdigital/addContactToList/index.ts | 62 ++++++++++ .../dotdigital/api/dotdigital-api.ts | 89 ++++++++++++++ .../src/destinations/dotdigital/api/index.ts | 4 + .../api/resources/dotdigital-contact-api.ts | 85 ++++++++++++++ .../resources/dotdigital-datafields-api.ts | 109 ++++++++++++++++++ .../api/resources/dotdigital-enrolment-api.ts | 68 +++++++++++ .../api/resources/dotdigital-lists-api.ts | 94 +++++++++++++++ .../src/destinations/dotdigital/api/types.ts | 92 +++++++++++++++ .../enrolContact/__tests__/index.test.ts | 85 ++++++++++++++ .../enrolContact/generated-types.ts | 20 ++++ .../dotdigital/enrolContact/index.ts | 50 ++++++++ .../dotdigital/generated-types.ts | 16 +++ .../src/destinations/dotdigital/index.ts | 58 ++++++++++ .../input-fields/contact-identifier.ts | 61 ++++++++++ .../dotdigital/input-fields/index.ts | 1 + .../dotdigital/input-fields/types.ts | 7 ++ .../__tests__/index.test.ts | 87 ++++++++++++++ .../removeContactFromList/generated-types.ts | 20 ++++ .../dotdigital/removeContactFromList/index.ts | 42 +++++++ 23 files changed, 1215 insertions(+) create mode 100644 packages/destination-actions/src/destinations/dotdigital/README.md create mode 100644 packages/destination-actions/src/destinations/dotdigital/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/addContactToList/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/addContactToList/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/addContactToList/index.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/api/dotdigital-api.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/api/index.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-contact-api.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-datafields-api.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-enrolment-api.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-lists-api.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/api/types.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/enrolContact/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/enrolContact/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/enrolContact/index.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/index.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/input-fields/contact-identifier.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/input-fields/index.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/input-fields/types.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/removeContactFromList/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/removeContactFromList/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/removeContactFromList/index.ts diff --git a/packages/destination-actions/src/destinations/dotdigital/README.md b/packages/destination-actions/src/destinations/dotdigital/README.md new file mode 100644 index 0000000000..0ca446aab9 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/README.md @@ -0,0 +1,20 @@ +# Introduction +TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project. + +# Getting Started +TODO: Guide users through getting your code up and running on their own system. In this section you can talk about: +1. Installation process +2. Software dependencies +3. Latest releases +4. API references + +# Build and Test +TODO: Describe and show how to build your code and run the tests. + +# Contribute +TODO: Explain how other users and developers can contribute to make your code better. + +If you want to learn more about creating good readme files then refer the following [guidelines](https://docs.microsoft.com/en-us/azure/devops/repos/git/create-a-readme?view=azure-devops). You can also seek inspiration from the below readme files: +- [ASP.NET Core](https://github.com/aspnet/Home) +- [Visual Studio Code](https://github.com/Microsoft/vscode) +- [Chakra Core](https://github.com/Microsoft/ChakraCore) \ No newline at end of file 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 0000000000..042d962bd2 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/__tests__/index.test.ts @@ -0,0 +1,43 @@ +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 0000000000..bab1751846 --- /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 0000000000..a879ac8b91 --- /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 remove the contact from. + */ + listId: number + /** + * An object containing key/value pairs for any data fields assigned to this contact, custom data fields needs to exists 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 0000000000..c5c6caa2d0 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/addContactToList/index.ts @@ -0,0 +1,62 @@ +import { ActionDefinition, DynamicFieldResponse, RequestClient, PayloadValidationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { DotdigitalContactApi, DotdigitalListsApi, DotdigitalDataFieldsApi } from '../api' +import { contactIdentifier } from '../input-fields' + +const action: ActionDefinition = { + title: 'Add Contact to List', + description: '', + defaultSubscription: 'type = "identify"', + fields: { + ...contactIdentifier, + listId: { + label: 'List', + description: `The list to add the contact to.`, + type: 'number', + required: true, + dynamic: true + }, + dataFields: { + label: 'Data Fields', + description: `An object containing key/value pairs for any data fields assigned to this contact, custom data fields needs to exists in Dotdigital.`, + type: 'object', + required: false, + defaultObjectUI: 'keyvalue:only', + additionalProperties: false, + dynamic: true + } + }, + dynamicFields: { + listId: async (request: RequestClient, { settings }): Promise => { + return new DotdigitalListsApi(settings, request).getLists() + }, + dataFields: { + __keys__: async (request, { settings }) => { + return new DotdigitalDataFieldsApi(settings, request).getDataFields() + } + } + }, + + perform: async (request, { settings, payload }) => { + const contactApi = new DotdigitalContactApi(settings, request) + const dataFieldsApi = new DotdigitalDataFieldsApi(settings, request) + const { channelIdentifier, emailIdentifier, mobileNumberIdentifier, listId } = payload + const identifierValue = channelIdentifier === 'email' ? emailIdentifier : mobileNumberIdentifier + + if (!listId) { + throw new PayloadValidationError('List id is required') + } + + if (!identifierValue) { + throw new PayloadValidationError( + channelIdentifier === 'email' ? 'Email address is required' : 'Mobile number is required' + ) + } + + await dataFieldsApi.validateDataFields(payload) + return contactApi.upsertContact(payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/dotdigital/api/dotdigital-api.ts b/packages/destination-actions/src/destinations/dotdigital/api/dotdigital-api.ts new file mode 100644 index 0000000000..a3e5154c2a --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/api/dotdigital-api.ts @@ -0,0 +1,89 @@ +import { APIError, RequestClient, ModifiedResponse } from '@segment/actions-core' +import type { Settings } from '../generated-types' + +abstract class DotdigitalApi { + private readonly apiUrl: string + private readonly client: RequestClient + + protected constructor(settings: Settings, client: RequestClient) { + this.apiUrl = settings.api_host + this.client = client + } + + /** + * Generic GET method + * @param endpoint - The API endpoint to call. + * @param params - An object containing query parameters. + * + * @returns A promise that resolves to a DecoratedResponse. + */ + protected async get(endpoint: string, params?: T): Promise { + try { + const url = new URL(`${this.apiUrl}${endpoint}`) + if (params) { + url.search = new URLSearchParams(params).toString(); + } + return await this.client(`${url}`, { + method: 'GET' + }) + } catch (error) { + throw (error as APIError) ?? 'GET request failed' + } + } + + /** + * Generic POST method + * @param endpoint - The API endpoint to call. + * @param data - The data to send in the client body. + * + * @returns A promise that resolves to a DecoratedResponse. + */ + protected async post(endpoint: string, data: T): Promise { + try { + return await this.client(`${this.apiUrl}${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + throw (error as APIError) ?? 'POST request failed' + } + } + + /** + * Generic DELETE method + * @param endpoint - The API endpoint to call. + * + * @returns A promise that resolves to a DecoratedResponse. + */ + protected async delete(endpoint: string): Promise { + try { + return await this.client(`${this.apiUrl}${endpoint}`, { + method: 'DELETE' + }) + } catch (error) { + throw (error as APIError) ?? 'DELETE request failed' + } + } + + /** + * Generic PATCH method + * @param endpoint - The API endpoint to call. + * @param data - The data to send in the client body. + * + * @returns A promise that resolves to a DecoratedResponse. + */ + protected async patch(endpoint: string, data: T): Promise { + try { + return await this.client(`${this.apiUrl}${endpoint}`, { + method: 'PATCH', + body: JSON.stringify(data), + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + throw (error as APIError) ?? 'PATCH request failed' + } + } +} + +export default DotdigitalApi 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 0000000000..a2d910251c --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/api/index.ts @@ -0,0 +1,4 @@ +export { default as DotdigitalContactApi } from './resources/dotdigital-contact-api' +export { default as DotdigitalListsApi } from './resources/dotdigital-lists-api' +export { default as DotdigitalEnrolmentAPi } from './resources/dotdigital-enrolment-api' +export { default as DotdigitalDataFieldsApi } from './resources/dotdigital-datafields-api' diff --git a/packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-contact-api.ts b/packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-contact-api.ts new file mode 100644 index 0000000000..eba546946f --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-contact-api.ts @@ -0,0 +1,85 @@ +import { + APIError, + ModifiedResponse, + RequestClient +} from '@segment/actions-core'; +import type { Settings } from '../../generated-types'; +import DotdigitalApi from '../dotdigital-api'; +import { Contact, ChannelIdentifier, Identifiers, ChannelProperties } from '../types' +import type { Payload } from '../../addContactToList/generated-types' + +class DotdigitalContactApi extends DotdigitalApi { + constructor(settings: Settings, client: RequestClient) { + super(settings, client); + } + + /** + * Fetches a contact from Dotdigital API. + * + * @param contactIdentifier - The type of identifier (e.g., email, mobile number). + * @param identifierValue - The value of the identifier. + * + * @returns A promise that resolves to a ContactResponse. + */ + async getContact( + contactIdentifier: string, + identifierValue: string | undefined + ): Promise { + try { + const response: ModifiedResponse = await this.get(`/contacts/v3/${contactIdentifier}/${identifierValue}`); + return JSON.parse(response.content) as Contact; + } catch (error) { + throw error as APIError ?? 'Failed to fetch contact'; + } + } + + /** + * 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 [[contactIdentifier, identifierValue]] = Object.entries(channelIdentifier); + try { + const response: ModifiedResponse = await this.patch(`/contacts/v3/${contactIdentifier}/${identifierValue}`, _data); + return JSON.parse(response.content) as Contact; + } catch (error) { + throw error as APIError ?? 'Failed to update contact'; + } + } + + /** + * 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 identifierValue = channelIdentifier === 'email' ? emailIdentifier : mobileNumberIdentifier + const identifiers:Identifiers = {} + if (emailIdentifier) identifiers.email = emailIdentifier + if (mobileNumberIdentifier) identifiers.mobileNumber = mobileNumberIdentifier + + const channelProperties:ChannelProperties = {} + if (emailIdentifier) channelProperties.email = { status: 'subscribed', emailType: 'html', optInType: 'single' } + if (mobileNumberIdentifier) channelProperties.sms = { status: 'subscribed' } + + const data = { + identifiers, + channelProperties, + lists: [listId], + dataFields: dataFields + } + + const response: ModifiedResponse = await this.patch( + `/contacts/v3/${channelIdentifier}/${identifierValue}?merge-option=overwrite`, + data + ) + return JSON.parse(response.content) as Contact + } +} + +export default DotdigitalContactApi diff --git a/packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-datafields-api.ts b/packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-datafields-api.ts new file mode 100644 index 0000000000..63a58dfd9a --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-datafields-api.ts @@ -0,0 +1,109 @@ +import { + RequestClient, + DynamicFieldResponse, + ModifiedResponse, + APIError, + PayloadValidationError +} from '@segment/actions-core' +import type { Settings } from '../../generated-types' +import DotdigitalApi from '../dotdigital-api' +import { DataField, DataFieldType } from '../types' +import type { Payload } from '../../addContactToList/generated-types' + +class DotdigitalDataFieldsApi extends DotdigitalApi { + 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 content: DataField[] = JSON.parse(response.content); + choices.push(...content.map((dataField: DataField) => ({ + value: dataField.name, + label: dataField.name, + type: dataField.type as DataFieldType | undefined + }))); + + return {choices} + } catch (error) { + return { + choices: [], + nextPage: '', + error: { + message: (error as APIError).message ?? 'Unknown error', + code: (error as APIError).status + '' ?? 'Unknown error' + } + } + } + } + + isNumeric(value: unknown): boolean { + const type = typeof value; + return ((type === "number" || type === "string") && !isNaN(Number(value))); + } + + async validateDataFields(payload: Payload) { + if (!payload.dataFields) { + return payload.dataFields + } + const response: ModifiedResponse = await this.get('/v2/data-fields/') + const dotdigitalDataFields: DataField[] = JSON.parse(response.content) + + for (const [key, value] of Object.entries(payload.dataFields)) { + let formattedValue = value; + const dotdigitalDataField = dotdigitalDataFields.find((obj) => obj.name === key) + if (!dotdigitalDataField) { + throw new PayloadValidationError(`Data field ${key} not found in Dotdigital`) + } + + switch (dotdigitalDataField.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`) + } + formattedValue = 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 === 'true') { + formattedValue = true + } else if (typeof value === 'string' && value === 'false') { + formattedValue = false + } + formattedValue = Boolean(formattedValue) + break + case 'String': + if (typeof value !== 'string') { + throw new PayloadValidationError(`Data field ${key} value ${value} is not a valid string`) + } + formattedValue = String(value).trim() + break + } + + payload.dataFields[key] = formattedValue + } + return payload.dataFields + } +} + +export default DotdigitalDataFieldsApi diff --git a/packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-enrolment-api.ts b/packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-enrolment-api.ts new file mode 100644 index 0000000000..3f34eb94b6 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-enrolment-api.ts @@ -0,0 +1,68 @@ +import { + APIError, + ModifiedResponse, + RequestClient, + DynamicFieldResponse + } from '@segment/actions-core'; + import type { Settings } from '../../generated-types'; + import DotdigitalApi from '../dotdigital-api'; + import { Contact, Program, ProgramStatus, ProgramEnrolment } from '../types'; + + /** + * Class representing the Dotdigital Enrolment API. + * Extends the base Dotdigital API class. + */ + class DotdigitalEnrolmentAPi extends DotdigitalApi { + 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: Program[] = JSON.parse(response.content); + 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: (error as APIError).message ?? 'Unknown error', + code: (error as APIError).status?.toString() ?? 'Unknown code' + } + }; + } + } + + /** + * 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 response: ModifiedResponse = await this.post( + '/v2/programs/enrolments', + { + contacts: [contact.contactId], + programId + } + ); + return JSON.parse(response.content) as ProgramEnrolment; + } + } + + export default DotdigitalEnrolmentAPi; \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-lists-api.ts b/packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-lists-api.ts new file mode 100644 index 0000000000..acb194bb51 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/api/resources/dotdigital-lists-api.ts @@ -0,0 +1,94 @@ +import { + APIError, + ModifiedResponse, + RequestClient, + DynamicFieldResponse +} from '@segment/actions-core'; +import type { Settings } from '../../generated-types'; +import DotdigitalApi from '../dotdigital-api'; +import { List } from '../types' + +class DotdigitalListsApi extends DotdigitalApi { + constructor(settings: Settings, client: RequestClient) { + super(settings, client); + } + + /** + * 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) { + 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: ModifiedResponse = await this.getListsPaging(select, skip); + const content: List[] = JSON.parse(response.content); + // Explicitly type parsedContent + 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: unknown) { + let errorMessage = 'Unknown error'; + let errorCode = 'Unknown error'; + + if (error instanceof APIError) { + errorMessage = error.message ?? 'Unknown error'; + errorCode = error.status ? error.status.toString() : 'Unknown error'; + } + + return { + choices: [], + nextPage: '', + error: { + message: errorMessage, + code: errorCode + } + }; + } + } + 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 + ): Promise { + try { + await this.delete(`/v2/address-books/${listId}/contacts/${contactId}`); + } catch (error) { + throw error as APIError ?? 'Failed to delete contact from list'; + } + } +} + +export default DotdigitalListsApi; 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 0000000000..aa7a1e9336 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/api/types.ts @@ -0,0 +1,92 @@ +// types.ts + +export interface DynamicFieldResponse { + choices: Array<{ value: number; label: string }>; + nextPage?: string; + error?: { + message: string; + code: string; + }; +} + + +export interface List { + id: number; + name: string; + status: string; +} +export interface DataField { + name: string; + type: string; + visibility: string; + defaultValue: string | null; +} + +export type DataFieldType = 'string' | 'number' | 'datetime' | 'boolean' + + +export interface Identifiers { + email?: string; + mobileNumber?: string; +} + +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 type ChannelIdentifier = + | { 'email': string; 'mobile-number'?: never } + | { 'mobile-number': string; 'email'?: never }; 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 0000000000..5e66599b61 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/enrolContact/__tests__/index.test.ts @@ -0,0 +1,85 @@ +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() + }) +}) \ No newline at end of file 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 0000000000..bb91057030 --- /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 0000000000..a4dc60ee76 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/enrolContact/index.ts @@ -0,0 +1,50 @@ +import type { ActionDefinition, RequestClient, DynamicFieldResponse } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { contactIdentifier } from '../input-fields' +import { DotdigitalEnrolmentAPi, DotdigitalContactApi } from '../api' +import { ChannelIdentifier, Identifiers, Contact } from '../api/types' + +const action: ActionDefinition = { + title: 'Enrol Contact to Program', + description: 'Creates a program enrolment', + 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 DotdigitalEnrolmentAPi(settings, request).getPrograms() + } + }, + + perform: async (request, { settings, payload }) => { + const dotdigitalContact = new DotdigitalContactApi(settings, request) + const DotdigitalEnrolments = new DotdigitalEnrolmentAPi(settings, request) + const { channelIdentifier, emailIdentifier, mobileNumberIdentifier, programId }: Payload = payload + + const resolvedChannelIdentifier: ChannelIdentifier = channelIdentifier === 'email' + ? { 'email': emailIdentifier as string } + : { 'mobile-number': mobileNumberIdentifier as string } + + const _ContactIdentifiers: Identifiers = { + email: emailIdentifier ?? '', + mobileNumber: mobileNumberIdentifier ?? '' + } + + const contact: Contact = await dotdigitalContact.fetchOrCreateContact(resolvedChannelIdentifier, { + identifiers: _ContactIdentifiers + }) + + return DotdigitalEnrolments.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 0000000000..c4f787ca05 --- /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 0000000000..2fa729c7be --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/index.ts @@ -0,0 +1,58 @@ +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', + 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' } + ], + required: true + }, + username: { + label: 'Username', + description: 'Your Dotdigital username', + type: 'string', + required: true + }, + password: { + label: 'Password', + description: 'Your Dotdigital password.', + type: 'string', + 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 0000000000..dc628c8f6a --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/input-fields/contact-identifier.ts @@ -0,0 +1,61 @@ +import { ContactIdentifier } from './types'; +import { InputField } from '@segment/actions-core' + +const emailIdentifier: InputField = { + label: 'Email Address', + description: "The contact's email address.", + type: 'string', + unsafe_hidden: true, + default: { + '@if': { + exists: { '@path': '$.traits.email' }, + then: { '@path': '$.traits.email' }, + else: { '@path': '$.properties.email' } + } + }, + depends_on: { + conditions: [{ fieldKey: 'contactIdentifier', operator: 'is', value: 'email' }] + }, + required: { + conditions: [{ fieldKey: 'contactIdentifier', operator: 'is', value: 'email' }] + } +} + +const mobileNumberIdentifier: InputField = { + label: 'Mobile Number', + description: "The contact's mobile number.", + type: 'string', + unsafe_hidden: true, + default: { + '@if': { + exists: { '@path': '$.traits.phone' }, + then: { '@path': '$.traits.phone' }, + else: { '@path': '$.properties.phone' } + } + }, + depends_on: { + conditions: [{ fieldKey: 'contactIdentifier', operator: 'is', value: 'mobile-number' }] + }, + required: { + conditions: [{ fieldKey: 'contactIdentifier', operator: 'is', value: 'mobile-number' }] + } +} + +const channelIdentifier: InputField = { + label: 'Contact Identifier', + 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' } + ] +} + + +export const contactIdentifier:ContactIdentifier = { + channelIdentifier: channelIdentifier, + emailIdentifier: emailIdentifier, + mobileNumberIdentifier: 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 0000000000..11b1e6e648 --- /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 0000000000..bf0ba01b37 --- /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 0000000000..bef876cb15 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/removeContactFromList/__tests__/index.test.ts @@ -0,0 +1,87 @@ +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 0000000000..b87d4e8ba8 --- /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 0000000000..ea6d1e31ec --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/removeContactFromList/index.ts @@ -0,0 +1,42 @@ +import { + ActionDefinition, + DynamicFieldResponse, + RequestClient +} from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { DotdigitalContactApi, DotdigitalListsApi } from '../api' +import { contactIdentifier } from '../input-fields' + +const action: ActionDefinition = { + title: 'Remove Contact from List', + description: '', + defaultSubscription: 'type = "identify"', + 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 DotdigitalListsApi(settings, request).getLists() + } + }, + + perform: async (request, { settings, payload }) => { + const dotdigitalContact = new DotdigitalContactApi(settings, request) + const dotdigitalLists = new DotdigitalListsApi(settings, request) + const { channelIdentifier, emailIdentifier, mobileNumberIdentifier, listId } = payload + const identifierValue = channelIdentifier === 'email' ? emailIdentifier : mobileNumberIdentifier + const contactResponse = await dotdigitalContact.getContact(channelIdentifier, identifierValue) + return dotdigitalLists.deleteContactFromList(listId, contactResponse.contactId) + } +} + +export default action From 5511dff3979546312c03e660b8162bbbeb53dafb Mon Sep 17 00:00:00 2001 From: Simon Letch Date: Tue, 15 Apr 2025 11:38:52 +0000 Subject: [PATCH 2/4] azure-pipelines-segment-io-github-sync: sync from dd-segment-io-destination at 2025-04-15T11:38:52Z by:Shaun Hogan --- .../dotdigital/dotdigital/README.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/README.md diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/README.md b/packages/destination-actions/src/destinations/dotdigital/dotdigital/README.md new file mode 100644 index 0000000000..0ca446aab9 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/README.md @@ -0,0 +1,20 @@ +# Introduction +TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project. + +# Getting Started +TODO: Guide users through getting your code up and running on their own system. In this section you can talk about: +1. Installation process +2. Software dependencies +3. Latest releases +4. API references + +# Build and Test +TODO: Describe and show how to build your code and run the tests. + +# Contribute +TODO: Explain how other users and developers can contribute to make your code better. + +If you want to learn more about creating good readme files then refer the following [guidelines](https://docs.microsoft.com/en-us/azure/devops/repos/git/create-a-readme?view=azure-devops). You can also seek inspiration from the below readme files: +- [ASP.NET Core](https://github.com/aspnet/Home) +- [Visual Studio Code](https://github.com/Microsoft/vscode) +- [Chakra Core](https://github.com/Microsoft/ChakraCore) \ No newline at end of file From 33424f175aafce2d809733884f166c2c4e4d759b Mon Sep 17 00:00:00 2001 From: Shaun Hogan Date: Tue, 15 Apr 2025 14:24:25 +0200 Subject: [PATCH 3/4] deelte unused files --- .../src/destinations/dotdigital/README.md | 20 ------------------- .../dotdigital/dotdigital/README.md | 20 ------------------- 2 files changed, 40 deletions(-) delete mode 100644 packages/destination-actions/src/destinations/dotdigital/README.md delete mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/README.md diff --git a/packages/destination-actions/src/destinations/dotdigital/README.md b/packages/destination-actions/src/destinations/dotdigital/README.md deleted file mode 100644 index 0ca446aab9..0000000000 --- a/packages/destination-actions/src/destinations/dotdigital/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Introduction -TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project. - -# Getting Started -TODO: Guide users through getting your code up and running on their own system. In this section you can talk about: -1. Installation process -2. Software dependencies -3. Latest releases -4. API references - -# Build and Test -TODO: Describe and show how to build your code and run the tests. - -# Contribute -TODO: Explain how other users and developers can contribute to make your code better. - -If you want to learn more about creating good readme files then refer the following [guidelines](https://docs.microsoft.com/en-us/azure/devops/repos/git/create-a-readme?view=azure-devops). You can also seek inspiration from the below readme files: -- [ASP.NET Core](https://github.com/aspnet/Home) -- [Visual Studio Code](https://github.com/Microsoft/vscode) -- [Chakra Core](https://github.com/Microsoft/ChakraCore) \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/README.md b/packages/destination-actions/src/destinations/dotdigital/dotdigital/README.md deleted file mode 100644 index 0ca446aab9..0000000000 --- a/packages/destination-actions/src/destinations/dotdigital/dotdigital/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Introduction -TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project. - -# Getting Started -TODO: Guide users through getting your code up and running on their own system. In this section you can talk about: -1. Installation process -2. Software dependencies -3. Latest releases -4. API references - -# Build and Test -TODO: Describe and show how to build your code and run the tests. - -# Contribute -TODO: Explain how other users and developers can contribute to make your code better. - -If you want to learn more about creating good readme files then refer the following [guidelines](https://docs.microsoft.com/en-us/azure/devops/repos/git/create-a-readme?view=azure-devops). You can also seek inspiration from the below readme files: -- [ASP.NET Core](https://github.com/aspnet/Home) -- [Visual Studio Code](https://github.com/Microsoft/vscode) -- [Chakra Core](https://github.com/Microsoft/ChakraCore) \ No newline at end of file From 3f87b1c1f725b810c097a5178370d0291f31388b Mon Sep 17 00:00:00 2001 From: Simon Letch Date: Thu, 15 May 2025 15:02:12 +0000 Subject: [PATCH 4/4] master: sync from dd-segment-io-destination at 2025-05-15T15:02:12Z by:Shaun Hogan --- .../dotdigital/__tests__/index.test.ts | 43 +++++++ .../addContactToList/__tests__/index.test.ts | 76 ++++++++++++ .../addContactToList/generated-types.ts | 26 +++++ .../dotdigital/addContactToList/index.ts | 62 ++++++++++ .../dotdigital/api/dotdigital-api.ts | 89 ++++++++++++++ .../dotdigital/dotdigital/api/index.ts | 4 + .../api/resources/dotdigital-contact-api.ts | 85 ++++++++++++++ .../resources/dotdigital-datafields-api.ts | 109 ++++++++++++++++++ .../api/resources/dotdigital-enrolment-api.ts | 68 +++++++++++ .../api/resources/dotdigital-lists-api.ts | 94 +++++++++++++++ .../dotdigital/dotdigital/api/types.ts | 92 +++++++++++++++ .../enrolContact/__tests__/index.test.ts | 85 ++++++++++++++ .../enrolContact/generated-types.ts | 20 ++++ .../dotdigital/enrolContact/index.ts | 50 ++++++++ .../dotdigital/dotdigital/generated-types.ts | 16 +++ .../dotdigital/dotdigital/index.ts | 58 ++++++++++ .../input-fields/contact-identifier.ts | 61 ++++++++++ .../dotdigital/input-fields/index.ts | 1 + .../dotdigital/input-fields/types.ts | 7 ++ .../__tests__/index.test.ts | 87 ++++++++++++++ .../removeContactFromList/generated-types.ts | 20 ++++ .../dotdigital/removeContactFromList/index.ts | 42 +++++++ 22 files changed, 1195 insertions(+) create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/addContactToList/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/addContactToList/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/addContactToList/index.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/api/dotdigital-api.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/api/index.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-contact-api.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-datafields-api.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-enrolment-api.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-lists-api.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/api/types.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/enrolContact/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/enrolContact/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/enrolContact/index.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/index.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/input-fields/contact-identifier.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/input-fields/index.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/input-fields/types.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/removeContactFromList/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/removeContactFromList/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/dotdigital/dotdigital/removeContactFromList/index.ts diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/__tests__/index.test.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/__tests__/index.test.ts new file mode 100644 index 0000000000..042d962bd2 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/__tests__/index.test.ts @@ -0,0 +1,43 @@ +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/dotdigital/addContactToList/__tests__/index.test.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/addContactToList/__tests__/index.test.ts new file mode 100644 index 0000000000..bab1751846 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/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/dotdigital/addContactToList/generated-types.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/addContactToList/generated-types.ts new file mode 100644 index 0000000000..a879ac8b91 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/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 remove the contact from. + */ + listId: number + /** + * An object containing key/value pairs for any data fields assigned to this contact, custom data fields needs to exists in Dotdigital. + */ + dataFields?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/addContactToList/index.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/addContactToList/index.ts new file mode 100644 index 0000000000..c5c6caa2d0 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/addContactToList/index.ts @@ -0,0 +1,62 @@ +import { ActionDefinition, DynamicFieldResponse, RequestClient, PayloadValidationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { DotdigitalContactApi, DotdigitalListsApi, DotdigitalDataFieldsApi } from '../api' +import { contactIdentifier } from '../input-fields' + +const action: ActionDefinition = { + title: 'Add Contact to List', + description: '', + defaultSubscription: 'type = "identify"', + fields: { + ...contactIdentifier, + listId: { + label: 'List', + description: `The list to add the contact to.`, + type: 'number', + required: true, + dynamic: true + }, + dataFields: { + label: 'Data Fields', + description: `An object containing key/value pairs for any data fields assigned to this contact, custom data fields needs to exists in Dotdigital.`, + type: 'object', + required: false, + defaultObjectUI: 'keyvalue:only', + additionalProperties: false, + dynamic: true + } + }, + dynamicFields: { + listId: async (request: RequestClient, { settings }): Promise => { + return new DotdigitalListsApi(settings, request).getLists() + }, + dataFields: { + __keys__: async (request, { settings }) => { + return new DotdigitalDataFieldsApi(settings, request).getDataFields() + } + } + }, + + perform: async (request, { settings, payload }) => { + const contactApi = new DotdigitalContactApi(settings, request) + const dataFieldsApi = new DotdigitalDataFieldsApi(settings, request) + const { channelIdentifier, emailIdentifier, mobileNumberIdentifier, listId } = payload + const identifierValue = channelIdentifier === 'email' ? emailIdentifier : mobileNumberIdentifier + + if (!listId) { + throw new PayloadValidationError('List id is required') + } + + if (!identifierValue) { + throw new PayloadValidationError( + channelIdentifier === 'email' ? 'Email address is required' : 'Mobile number is required' + ) + } + + await dataFieldsApi.validateDataFields(payload) + return contactApi.upsertContact(payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/dotdigital-api.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/dotdigital-api.ts new file mode 100644 index 0000000000..a3e5154c2a --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/dotdigital-api.ts @@ -0,0 +1,89 @@ +import { APIError, RequestClient, ModifiedResponse } from '@segment/actions-core' +import type { Settings } from '../generated-types' + +abstract class DotdigitalApi { + private readonly apiUrl: string + private readonly client: RequestClient + + protected constructor(settings: Settings, client: RequestClient) { + this.apiUrl = settings.api_host + this.client = client + } + + /** + * Generic GET method + * @param endpoint - The API endpoint to call. + * @param params - An object containing query parameters. + * + * @returns A promise that resolves to a DecoratedResponse. + */ + protected async get(endpoint: string, params?: T): Promise { + try { + const url = new URL(`${this.apiUrl}${endpoint}`) + if (params) { + url.search = new URLSearchParams(params).toString(); + } + return await this.client(`${url}`, { + method: 'GET' + }) + } catch (error) { + throw (error as APIError) ?? 'GET request failed' + } + } + + /** + * Generic POST method + * @param endpoint - The API endpoint to call. + * @param data - The data to send in the client body. + * + * @returns A promise that resolves to a DecoratedResponse. + */ + protected async post(endpoint: string, data: T): Promise { + try { + return await this.client(`${this.apiUrl}${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + throw (error as APIError) ?? 'POST request failed' + } + } + + /** + * Generic DELETE method + * @param endpoint - The API endpoint to call. + * + * @returns A promise that resolves to a DecoratedResponse. + */ + protected async delete(endpoint: string): Promise { + try { + return await this.client(`${this.apiUrl}${endpoint}`, { + method: 'DELETE' + }) + } catch (error) { + throw (error as APIError) ?? 'DELETE request failed' + } + } + + /** + * Generic PATCH method + * @param endpoint - The API endpoint to call. + * @param data - The data to send in the client body. + * + * @returns A promise that resolves to a DecoratedResponse. + */ + protected async patch(endpoint: string, data: T): Promise { + try { + return await this.client(`${this.apiUrl}${endpoint}`, { + method: 'PATCH', + body: JSON.stringify(data), + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + throw (error as APIError) ?? 'PATCH request failed' + } + } +} + +export default DotdigitalApi diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/index.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/index.ts new file mode 100644 index 0000000000..a2d910251c --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/index.ts @@ -0,0 +1,4 @@ +export { default as DotdigitalContactApi } from './resources/dotdigital-contact-api' +export { default as DotdigitalListsApi } from './resources/dotdigital-lists-api' +export { default as DotdigitalEnrolmentAPi } from './resources/dotdigital-enrolment-api' +export { default as DotdigitalDataFieldsApi } from './resources/dotdigital-datafields-api' diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-contact-api.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-contact-api.ts new file mode 100644 index 0000000000..eba546946f --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-contact-api.ts @@ -0,0 +1,85 @@ +import { + APIError, + ModifiedResponse, + RequestClient +} from '@segment/actions-core'; +import type { Settings } from '../../generated-types'; +import DotdigitalApi from '../dotdigital-api'; +import { Contact, ChannelIdentifier, Identifiers, ChannelProperties } from '../types' +import type { Payload } from '../../addContactToList/generated-types' + +class DotdigitalContactApi extends DotdigitalApi { + constructor(settings: Settings, client: RequestClient) { + super(settings, client); + } + + /** + * Fetches a contact from Dotdigital API. + * + * @param contactIdentifier - The type of identifier (e.g., email, mobile number). + * @param identifierValue - The value of the identifier. + * + * @returns A promise that resolves to a ContactResponse. + */ + async getContact( + contactIdentifier: string, + identifierValue: string | undefined + ): Promise { + try { + const response: ModifiedResponse = await this.get(`/contacts/v3/${contactIdentifier}/${identifierValue}`); + return JSON.parse(response.content) as Contact; + } catch (error) { + throw error as APIError ?? 'Failed to fetch contact'; + } + } + + /** + * 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 [[contactIdentifier, identifierValue]] = Object.entries(channelIdentifier); + try { + const response: ModifiedResponse = await this.patch(`/contacts/v3/${contactIdentifier}/${identifierValue}`, _data); + return JSON.parse(response.content) as Contact; + } catch (error) { + throw error as APIError ?? 'Failed to update contact'; + } + } + + /** + * 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 identifierValue = channelIdentifier === 'email' ? emailIdentifier : mobileNumberIdentifier + const identifiers:Identifiers = {} + if (emailIdentifier) identifiers.email = emailIdentifier + if (mobileNumberIdentifier) identifiers.mobileNumber = mobileNumberIdentifier + + const channelProperties:ChannelProperties = {} + if (emailIdentifier) channelProperties.email = { status: 'subscribed', emailType: 'html', optInType: 'single' } + if (mobileNumberIdentifier) channelProperties.sms = { status: 'subscribed' } + + const data = { + identifiers, + channelProperties, + lists: [listId], + dataFields: dataFields + } + + const response: ModifiedResponse = await this.patch( + `/contacts/v3/${channelIdentifier}/${identifierValue}?merge-option=overwrite`, + data + ) + return JSON.parse(response.content) as Contact + } +} + +export default DotdigitalContactApi diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-datafields-api.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-datafields-api.ts new file mode 100644 index 0000000000..63a58dfd9a --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-datafields-api.ts @@ -0,0 +1,109 @@ +import { + RequestClient, + DynamicFieldResponse, + ModifiedResponse, + APIError, + PayloadValidationError +} from '@segment/actions-core' +import type { Settings } from '../../generated-types' +import DotdigitalApi from '../dotdigital-api' +import { DataField, DataFieldType } from '../types' +import type { Payload } from '../../addContactToList/generated-types' + +class DotdigitalDataFieldsApi extends DotdigitalApi { + 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 content: DataField[] = JSON.parse(response.content); + choices.push(...content.map((dataField: DataField) => ({ + value: dataField.name, + label: dataField.name, + type: dataField.type as DataFieldType | undefined + }))); + + return {choices} + } catch (error) { + return { + choices: [], + nextPage: '', + error: { + message: (error as APIError).message ?? 'Unknown error', + code: (error as APIError).status + '' ?? 'Unknown error' + } + } + } + } + + isNumeric(value: unknown): boolean { + const type = typeof value; + return ((type === "number" || type === "string") && !isNaN(Number(value))); + } + + async validateDataFields(payload: Payload) { + if (!payload.dataFields) { + return payload.dataFields + } + const response: ModifiedResponse = await this.get('/v2/data-fields/') + const dotdigitalDataFields: DataField[] = JSON.parse(response.content) + + for (const [key, value] of Object.entries(payload.dataFields)) { + let formattedValue = value; + const dotdigitalDataField = dotdigitalDataFields.find((obj) => obj.name === key) + if (!dotdigitalDataField) { + throw new PayloadValidationError(`Data field ${key} not found in Dotdigital`) + } + + switch (dotdigitalDataField.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`) + } + formattedValue = 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 === 'true') { + formattedValue = true + } else if (typeof value === 'string' && value === 'false') { + formattedValue = false + } + formattedValue = Boolean(formattedValue) + break + case 'String': + if (typeof value !== 'string') { + throw new PayloadValidationError(`Data field ${key} value ${value} is not a valid string`) + } + formattedValue = String(value).trim() + break + } + + payload.dataFields[key] = formattedValue + } + return payload.dataFields + } +} + +export default DotdigitalDataFieldsApi diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-enrolment-api.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-enrolment-api.ts new file mode 100644 index 0000000000..3f34eb94b6 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-enrolment-api.ts @@ -0,0 +1,68 @@ +import { + APIError, + ModifiedResponse, + RequestClient, + DynamicFieldResponse + } from '@segment/actions-core'; + import type { Settings } from '../../generated-types'; + import DotdigitalApi from '../dotdigital-api'; + import { Contact, Program, ProgramStatus, ProgramEnrolment } from '../types'; + + /** + * Class representing the Dotdigital Enrolment API. + * Extends the base Dotdigital API class. + */ + class DotdigitalEnrolmentAPi extends DotdigitalApi { + 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: Program[] = JSON.parse(response.content); + 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: (error as APIError).message ?? 'Unknown error', + code: (error as APIError).status?.toString() ?? 'Unknown code' + } + }; + } + } + + /** + * 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 response: ModifiedResponse = await this.post( + '/v2/programs/enrolments', + { + contacts: [contact.contactId], + programId + } + ); + return JSON.parse(response.content) as ProgramEnrolment; + } + } + + export default DotdigitalEnrolmentAPi; \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-lists-api.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-lists-api.ts new file mode 100644 index 0000000000..acb194bb51 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/resources/dotdigital-lists-api.ts @@ -0,0 +1,94 @@ +import { + APIError, + ModifiedResponse, + RequestClient, + DynamicFieldResponse +} from '@segment/actions-core'; +import type { Settings } from '../../generated-types'; +import DotdigitalApi from '../dotdigital-api'; +import { List } from '../types' + +class DotdigitalListsApi extends DotdigitalApi { + constructor(settings: Settings, client: RequestClient) { + super(settings, client); + } + + /** + * 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) { + 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: ModifiedResponse = await this.getListsPaging(select, skip); + const content: List[] = JSON.parse(response.content); + // Explicitly type parsedContent + 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: unknown) { + let errorMessage = 'Unknown error'; + let errorCode = 'Unknown error'; + + if (error instanceof APIError) { + errorMessage = error.message ?? 'Unknown error'; + errorCode = error.status ? error.status.toString() : 'Unknown error'; + } + + return { + choices: [], + nextPage: '', + error: { + message: errorMessage, + code: errorCode + } + }; + } + } + 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 + ): Promise { + try { + await this.delete(`/v2/address-books/${listId}/contacts/${contactId}`); + } catch (error) { + throw error as APIError ?? 'Failed to delete contact from list'; + } + } +} + +export default DotdigitalListsApi; diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/types.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/types.ts new file mode 100644 index 0000000000..aa7a1e9336 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/api/types.ts @@ -0,0 +1,92 @@ +// types.ts + +export interface DynamicFieldResponse { + choices: Array<{ value: number; label: string }>; + nextPage?: string; + error?: { + message: string; + code: string; + }; +} + + +export interface List { + id: number; + name: string; + status: string; +} +export interface DataField { + name: string; + type: string; + visibility: string; + defaultValue: string | null; +} + +export type DataFieldType = 'string' | 'number' | 'datetime' | 'boolean' + + +export interface Identifiers { + email?: string; + mobileNumber?: string; +} + +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 type ChannelIdentifier = + | { 'email': string; 'mobile-number'?: never } + | { 'mobile-number': string; 'email'?: never }; diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/enrolContact/__tests__/index.test.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/enrolContact/__tests__/index.test.ts new file mode 100644 index 0000000000..5e66599b61 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/enrolContact/__tests__/index.test.ts @@ -0,0 +1,85 @@ +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() + }) +}) \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/enrolContact/generated-types.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/enrolContact/generated-types.ts new file mode 100644 index 0000000000..bb91057030 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/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/dotdigital/enrolContact/index.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/enrolContact/index.ts new file mode 100644 index 0000000000..a4dc60ee76 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/enrolContact/index.ts @@ -0,0 +1,50 @@ +import type { ActionDefinition, RequestClient, DynamicFieldResponse } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { contactIdentifier } from '../input-fields' +import { DotdigitalEnrolmentAPi, DotdigitalContactApi } from '../api' +import { ChannelIdentifier, Identifiers, Contact } from '../api/types' + +const action: ActionDefinition = { + title: 'Enrol Contact to Program', + description: 'Creates a program enrolment', + 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 DotdigitalEnrolmentAPi(settings, request).getPrograms() + } + }, + + perform: async (request, { settings, payload }) => { + const dotdigitalContact = new DotdigitalContactApi(settings, request) + const DotdigitalEnrolments = new DotdigitalEnrolmentAPi(settings, request) + const { channelIdentifier, emailIdentifier, mobileNumberIdentifier, programId }: Payload = payload + + const resolvedChannelIdentifier: ChannelIdentifier = channelIdentifier === 'email' + ? { 'email': emailIdentifier as string } + : { 'mobile-number': mobileNumberIdentifier as string } + + const _ContactIdentifiers: Identifiers = { + email: emailIdentifier ?? '', + mobileNumber: mobileNumberIdentifier ?? '' + } + + const contact: Contact = await dotdigitalContact.fetchOrCreateContact(resolvedChannelIdentifier, { + identifiers: _ContactIdentifiers + }) + + return DotdigitalEnrolments.enrolContact(programId, contact) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/generated-types.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/generated-types.ts new file mode 100644 index 0000000000..c4f787ca05 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/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/dotdigital/index.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/index.ts new file mode 100644 index 0000000000..2fa729c7be --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/index.ts @@ -0,0 +1,58 @@ +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', + 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' } + ], + required: true + }, + username: { + label: 'Username', + description: 'Your Dotdigital username', + type: 'string', + required: true + }, + password: { + label: 'Password', + description: 'Your Dotdigital password.', + type: 'string', + 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/dotdigital/input-fields/contact-identifier.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/input-fields/contact-identifier.ts new file mode 100644 index 0000000000..dc628c8f6a --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/input-fields/contact-identifier.ts @@ -0,0 +1,61 @@ +import { ContactIdentifier } from './types'; +import { InputField } from '@segment/actions-core' + +const emailIdentifier: InputField = { + label: 'Email Address', + description: "The contact's email address.", + type: 'string', + unsafe_hidden: true, + default: { + '@if': { + exists: { '@path': '$.traits.email' }, + then: { '@path': '$.traits.email' }, + else: { '@path': '$.properties.email' } + } + }, + depends_on: { + conditions: [{ fieldKey: 'contactIdentifier', operator: 'is', value: 'email' }] + }, + required: { + conditions: [{ fieldKey: 'contactIdentifier', operator: 'is', value: 'email' }] + } +} + +const mobileNumberIdentifier: InputField = { + label: 'Mobile Number', + description: "The contact's mobile number.", + type: 'string', + unsafe_hidden: true, + default: { + '@if': { + exists: { '@path': '$.traits.phone' }, + then: { '@path': '$.traits.phone' }, + else: { '@path': '$.properties.phone' } + } + }, + depends_on: { + conditions: [{ fieldKey: 'contactIdentifier', operator: 'is', value: 'mobile-number' }] + }, + required: { + conditions: [{ fieldKey: 'contactIdentifier', operator: 'is', value: 'mobile-number' }] + } +} + +const channelIdentifier: InputField = { + label: 'Contact Identifier', + 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' } + ] +} + + +export const contactIdentifier:ContactIdentifier = { + channelIdentifier: channelIdentifier, + emailIdentifier: emailIdentifier, + mobileNumberIdentifier: mobileNumberIdentifier, +} diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/input-fields/index.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/input-fields/index.ts new file mode 100644 index 0000000000..11b1e6e648 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/input-fields/index.ts @@ -0,0 +1 @@ +export { contactIdentifier } from './contact-identifier'; diff --git a/packages/destination-actions/src/destinations/dotdigital/dotdigital/input-fields/types.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/input-fields/types.ts new file mode 100644 index 0000000000..bf0ba01b37 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/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/dotdigital/removeContactFromList/__tests__/index.test.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/removeContactFromList/__tests__/index.test.ts new file mode 100644 index 0000000000..bef876cb15 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/removeContactFromList/__tests__/index.test.ts @@ -0,0 +1,87 @@ +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/dotdigital/removeContactFromList/generated-types.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/removeContactFromList/generated-types.ts new file mode 100644 index 0000000000..b87d4e8ba8 --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/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/dotdigital/removeContactFromList/index.ts b/packages/destination-actions/src/destinations/dotdigital/dotdigital/removeContactFromList/index.ts new file mode 100644 index 0000000000..ea6d1e31ec --- /dev/null +++ b/packages/destination-actions/src/destinations/dotdigital/dotdigital/removeContactFromList/index.ts @@ -0,0 +1,42 @@ +import { + ActionDefinition, + DynamicFieldResponse, + RequestClient +} from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { DotdigitalContactApi, DotdigitalListsApi } from '../api' +import { contactIdentifier } from '../input-fields' + +const action: ActionDefinition = { + title: 'Remove Contact from List', + description: '', + defaultSubscription: 'type = "identify"', + 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 DotdigitalListsApi(settings, request).getLists() + } + }, + + perform: async (request, { settings, payload }) => { + const dotdigitalContact = new DotdigitalContactApi(settings, request) + const dotdigitalLists = new DotdigitalListsApi(settings, request) + const { channelIdentifier, emailIdentifier, mobileNumberIdentifier, listId } = payload + const identifierValue = channelIdentifier === 'email' ? emailIdentifier : mobileNumberIdentifier + const contactResponse = await dotdigitalContact.getContact(channelIdentifier, identifierValue) + return dotdigitalLists.deleteContactFromList(listId, contactResponse.contactId) + } +} + +export default action