Skip to content
Merged
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,42 @@
import nock from 'nock'
import { createTestIntegration } from '@segment/actions-core'
import Definition from '../index'

const testDestination = createTestIntegration(Definition)

describe('Dotdigital', () => {
describe('testAuthentication', () => {
it('should validate valid api key', async () => {
nock('https://r1-api.dotdigital.com')
.get('/v2/data-fields/')
.matchHeader('Authorization', 'Basic YXBpX3VzZXJuYW1lOmFwaV9wYXNzd29yZA==')
.reply(200)

const settings = {
api_host: 'https://r1-api.dotdigital.com',
username: 'api_username',
password: 'api_password'
}
await expect(testDestination.testAuthentication(settings)).resolves.not.toThrowError()
expect(nock.isDone()).toBe(true)
})

it('should not validate invalid api key', async () => {
nock('https://r1-api.dotdigital.com')
.get('/v2/data-fields/')
.matchHeader('Authorization', 'Basic YXBpX3VzZXJuYW1lOmFwaV9wYXNzd29yZA==')
.reply(401, {
message: 'Authorization has been denied for this request.'
})

const settings = {
api_host: 'https://r1-api.dotdigital.com',
username: 'api_username',
password: 'api_password'
}

await expect(testDestination.testAuthentication(settings)).rejects.toThrowError()
expect(nock.isDone()).toBe(true)
})
})
})
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,53 @@
import { ActionDefinition, DynamicFieldResponse, RequestClient } from '@segment/actions-core'
import type { Settings } from '../generated-types'
import type { Payload } from './generated-types'
import { DDContactApi, DDListsApi, DDDataFieldsApi } from '../api'
import { contactIdentifier } from '../input-fields'

const action: ActionDefinition<Settings, Payload> = {
title: 'Add Contact to List',
description: 'Adds a Contact to a list.',
defaultSubscription: 'type = "track" and event = "Add Contact to List"',
fields: {
...contactIdentifier,
listId: {
label: 'List',
description: `The list to add the contact to.`,
type: 'number',
required: true,
allowNull: false,
disabledInputMethods: ['literal', 'variable', 'function', 'freeform', 'enrichment'],
dynamic: true
},
dataFields: {
label: 'Data Fields',
description: `An object containing key/value pairs for data fields assigned to this Contact. Custom Data Fields must already be defined in Dotdigital.`,
type: 'object',
required: false,
disabledInputMethods: ['literal', 'variable', 'function', 'freeform', 'enrichment'],
defaultObjectUI: 'keyvalue:only',
additionalProperties: false,
dynamic: true
}
},
dynamicFields: {
listId: async (request: RequestClient, { settings }): Promise<DynamicFieldResponse> => {
return new DDListsApi(settings, request).getLists()
},
dataFields: {
__keys__: async (request, { settings }) => {
return new DDDataFieldsApi(settings, request).getDataFields()
}
}
},

perform: async (request, { settings, payload }) => {
const fieldsAPI = new DDDataFieldsApi(settings, request)
await fieldsAPI.validateDataFields(payload)

const contactApi = new DDContactApi(settings, request)
return contactApi.upsertContact(payload)
}
}

export default action
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { RequestClient } from '@segment/actions-core'
import type { Settings } from '../generated-types'

abstract class DDApi {
private readonly apiUrl: string
private readonly request: RequestClient

protected constructor(settings: Settings, request: RequestClient) {
this.apiUrl = settings.api_host
this.request = request
}

/**
* Generic GET method
* @param endpoint - The API endpoint to call.
* @param params - An object containing query parameters.
*
* @returns A an object of type TResponse containing the response data.
*/
protected async get<TResponse>(endpoint: string, params?: Record<string, unknown>) {
const url = new URL(`${this.apiUrl}${endpoint}`)
if (params) {
url.search = new URLSearchParams(
Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]))
).toString()
}
return await this.request<TResponse>(`${url}`, {
method: 'GET'
})
}

/**
* Generic POST method
* @param endpoint - The API endpoint to call.
* @param data - The data to send in the client body.
*
* @returns an object of type TResponse containing the response data.
*/
protected async post<ResponseType, RequestJSON>(endpoint: string, data: RequestJSON) {
return await this.request<ResponseType>(`${this.apiUrl}${endpoint}`, {
method: 'POST',
json: data,
headers: { 'Content-Type': 'application/json' }
})
}

/**
* Generic DELETE method
* @param endpoint - The API endpoint to call.
*/
protected async delete(endpoint: string) {
return await this.request(`${this.apiUrl}${endpoint}`, {
method: 'DELETE'
})
}

/**
* Generic PATCH method
* @param endpoint - The API endpoint to call.
* @param data - The data to send in the client body.
*
* @returns an object of type TResponse containing the response data.
*/
protected async patch<ResponseType, RequestJSON>(endpoint: string, data: RequestJSON) {
return await this.request<ResponseType>(`${this.apiUrl}${endpoint}`, {
method: 'PATCH',
json: data,
headers: { 'Content-Type': 'application/json' }
})
}
}

export default DDApi
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { default as DDContactApi } from './resources/dd-contact-api'
export { default as DDListsApi } from './resources/dd-lists-api'
export { default as DDEnrolmentApi } from './resources/dd-enrolment-api'
export { default as DDDataFieldsApi } from './resources/dd-datafields-api'
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { ModifiedResponse, RequestClient } from '@segment/actions-core'
import type { Settings } from '../../generated-types'
import DDApi from '../dd-api'
import { Contact, ChannelIdentifier, Identifiers, ChannelProperties, UpsertContactJSON, DataFields } from '../types'
import type { Payload } from '../../addContactToList/generated-types'

class DDContactApi extends DDApi {
constructor(settings: Settings, client: RequestClient) {
super(settings, client)
}

/**
* Fetches a contact from Dotdigital API.
*
* @param idType - The type of identifier (e.g., email, mobile number).
* @param idValue - The value of the identifier.
*
* @returns A promise that resolves to a ContactResponse.
*/
async getContact(idType: string, idValue: string | undefined): Promise<Contact> {
const response: ModifiedResponse<Contact> = await this.get<Contact>(`/contacts/v3/${idType}/${idValue}`)
return response.data
}

/**
* Fetches a contact from Dotdigital API via means of Patch.
*
* @param channelIdentifier - The identifier of the contact channel.
* @param data - The data to be sent in the request body.
*
* @returns A promise that resolves to a ContactResponse.
*/
async fetchOrCreateContact<T>(channelIdentifier: ChannelIdentifier, data: T): Promise<Contact> {
const [[idType, idValue]] = Object.entries(channelIdentifier)
const response: ModifiedResponse<Contact> = await this.patch<Contact, T>(`/contacts/v3/${idType}/${idValue}`, data)
return response.data
}

/**
* 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 idValue = channelIdentifier === 'email' ? emailIdentifier : mobileNumberIdentifier

const identifiers: Identifiers = {
...(emailIdentifier && { email: emailIdentifier }),
...(mobileNumberIdentifier && { mobileNumber: mobileNumberIdentifier })
}

const channelProperties: ChannelProperties = {
...(emailIdentifier && {
email: {
status: 'subscribed',
emailType: 'html',
optInType: 'single'
}
}),
...(mobileNumberIdentifier && {
sms: { status: 'subscribed' }
})
}

const data: UpsertContactJSON = {
identifiers,
channelProperties,
lists: [listId],
dataFields: dataFields as DataFields
}

const response: ModifiedResponse<Contact> = await this.patch<Contact, UpsertContactJSON>(
`/contacts/v3/${channelIdentifier}/${idValue}?merge-option=overwrite`,
data
)

return response.data
}
}

export default DDContactApi
Loading
Loading