Skip to content

[Dotdigital] Add actions for Add Contact to List, Remove Contact From List, and Enrol Contact #2871

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
Original file line number Diff line number Diff line change
@@ -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/[email protected]?merge-option=overwrite`)
.reply(200, { contactId: 123 })

const event = createTestEvent({
type: 'identify',
context: {
traits: {
email: '[email protected]'
}
}
})

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()
})
})

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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<Settings, Payload> = {
title: 'Add Contact to List',
description: '',
defaultSubscription: 'type = "identify"',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be worth considering if an identify() call is the best way to do this. I would recommend a defaultSubscription that looks for a track() event. For example:

defaultSubscription: 'type = "track" and event="Added To List"',

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
Comment on lines +25 to +27
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi @pvpcookie - I think there's some conflicting configuration here.

defaultObjectUI: 'keyvalue:only' - this means that the UI will only let the customer configure key:value pairs in the object, rather than passing the entire object in a single go.

additionalProperties: false - this will prevent the customer from adding new key:value items.

The net result of this is that only a single key:value pair will be configurable - that is assuming the UI will actually implement the config as expected.

I think this the following is what you actually want:

  • allow the customer to add many key:value pairs to the dataFields object, one at a time. Each time the customer selects to add a new item the dynamic function will be called and will populate the list of keys the customer can select from.

To achieve this I think you chould remove the additionalProperties: false configuration.

}
},
dynamicFields: {
listId: async (request: RequestClient, { settings }): Promise<DynamicFieldResponse> => {
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')
}
Comment on lines +47 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Probably not needed. Payloads without a listId value will get dropped before the perform() function is called.


if (!identifierValue) {
throw new PayloadValidationError(
channelIdentifier === 'email' ? 'Email address is required' : 'Mobile number is required'
)
}
Comment on lines +51 to +55
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Same with this - there should always be an identifierValue as email the email or mobile fields will be populated. So this check probably not needed.


await dataFieldsApi.validateDataFields(payload)
return contactApi.upsertContact(payload)
}
}

export default action
Original file line number Diff line number Diff line change
@@ -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<T>(endpoint: string, params?: T): Promise<ModifiedResponse> {
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<T>(endpoint: string, data: T): Promise<ModifiedResponse> {
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<ModifiedResponse> {
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<T>(endpoint: string, data: T): Promise<ModifiedResponse> {
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
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -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<Contact> {
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<T>(channelIdentifier: ChannelIdentifier, _data: T): Promise<Contact> {
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<Contact>} A promise resolving to the contact data.
*/
public async upsertContact(payload: Payload): Promise<Contact> {
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
Loading
Loading