-
Notifications
You must be signed in to change notification settings - Fork 267
[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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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"', | ||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
There was a problem hiding this comment.
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"',