Skip to content

Commit f85f71f

Browse files
[Dot Digital] new Destination (#2923)
* [Dot Digital] new Destination * adding default for field * more changes * refactoring some stuff * updating description
1 parent f6170d8 commit f85f71f

File tree

22 files changed

+1145
-0
lines changed

22 files changed

+1145
-0
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import nock from 'nock'
2+
import { createTestIntegration } from '@segment/actions-core'
3+
import Definition from '../index'
4+
5+
const testDestination = createTestIntegration(Definition)
6+
7+
describe('Dotdigital', () => {
8+
describe('testAuthentication', () => {
9+
it('should validate valid api key', async () => {
10+
nock('https://r1-api.dotdigital.com')
11+
.get('/v2/data-fields/')
12+
.matchHeader('Authorization', 'Basic YXBpX3VzZXJuYW1lOmFwaV9wYXNzd29yZA==')
13+
.reply(200)
14+
15+
const settings = {
16+
api_host: 'https://r1-api.dotdigital.com',
17+
username: 'api_username',
18+
password: 'api_password'
19+
}
20+
await expect(testDestination.testAuthentication(settings)).resolves.not.toThrowError()
21+
expect(nock.isDone()).toBe(true)
22+
})
23+
24+
it('should not validate invalid api key', async () => {
25+
nock('https://r1-api.dotdigital.com')
26+
.get('/v2/data-fields/')
27+
.matchHeader('Authorization', 'Basic YXBpX3VzZXJuYW1lOmFwaV9wYXNzd29yZA==')
28+
.reply(401, {
29+
message: 'Authorization has been denied for this request.'
30+
})
31+
32+
const settings = {
33+
api_host: 'https://r1-api.dotdigital.com',
34+
username: 'api_username',
35+
password: 'api_password'
36+
}
37+
38+
await expect(testDestination.testAuthentication(settings)).rejects.toThrowError()
39+
expect(nock.isDone()).toBe(true)
40+
})
41+
})
42+
})
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import nock from 'nock'
2+
import { createTestEvent, createTestIntegration } from '@segment/actions-core'
3+
import Destination from '../../index'
4+
5+
const testDestination = createTestIntegration(Destination)
6+
7+
export const settings = {
8+
api_host: 'https://r1-api.dotdigital.com',
9+
username: 'api_username',
10+
password: 'api_password'
11+
}
12+
13+
describe('Add Contact To List', () => {
14+
it('should add contact to list with email identifier', async () => {
15+
// Mock upsertContact function
16+
nock(settings.api_host)
17+
.patch(`/contacts/v3/email/[email protected]?merge-option=overwrite`)
18+
.reply(200, { contactId: 123 })
19+
20+
const event = createTestEvent({
21+
type: 'identify',
22+
context: {
23+
traits: {
24+
25+
}
26+
}
27+
})
28+
29+
const mapping = {
30+
listId: 123456,
31+
channelIdentifier: 'email',
32+
emailIdentifier: {
33+
'@path': '$.context.traits.email'
34+
}
35+
}
36+
await expect(
37+
testDestination.testAction('addContactToList', {
38+
event,
39+
mapping,
40+
settings
41+
})
42+
).resolves.not.toThrowError()
43+
})
44+
45+
it('should add contact to list with mobile number identifier', async () => {
46+
// Mock upsertContact function
47+
nock(settings.api_host)
48+
.patch(`/contacts/v3/mobile-number/1234567890?merge-option=overwrite`)
49+
.reply(200, { contactId: 123 })
50+
51+
const event = createTestEvent({
52+
type: 'identify',
53+
context: {
54+
traits: {
55+
phone: '1234567890'
56+
}
57+
}
58+
})
59+
60+
const mapping = {
61+
listId: 123456,
62+
channelIdentifier: 'mobile-number',
63+
mobileNumberIdentifier: {
64+
'@path': '$.context.traits.phone'
65+
}
66+
}
67+
68+
await expect(
69+
testDestination.testAction('addContactToList', {
70+
event,
71+
mapping,
72+
settings
73+
})
74+
).resolves.not.toThrowError()
75+
})
76+
})

packages/destination-actions/src/destinations/dotdigital/addContactToList/generated-types.ts

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { ActionDefinition, DynamicFieldResponse, RequestClient } from '@segment/actions-core'
2+
import type { Settings } from '../generated-types'
3+
import type { Payload } from './generated-types'
4+
import { DDContactApi, DDListsApi, DDDataFieldsApi } from '../api'
5+
import { contactIdentifier } from '../input-fields'
6+
7+
const action: ActionDefinition<Settings, Payload> = {
8+
title: 'Add Contact to List',
9+
description: 'Adds a Contact to a list.',
10+
defaultSubscription: 'type = "track" and event = "Add Contact to List"',
11+
fields: {
12+
...contactIdentifier,
13+
listId: {
14+
label: 'List',
15+
description: `The list to add the contact to.`,
16+
type: 'number',
17+
required: true,
18+
allowNull: false,
19+
disabledInputMethods: ['literal', 'variable', 'function', 'freeform', 'enrichment'],
20+
dynamic: true
21+
},
22+
dataFields: {
23+
label: 'Data Fields',
24+
description: `An object containing key/value pairs for data fields assigned to this Contact. Custom Data Fields must already be defined in Dotdigital.`,
25+
type: 'object',
26+
required: false,
27+
disabledInputMethods: ['literal', 'variable', 'function', 'freeform', 'enrichment'],
28+
defaultObjectUI: 'keyvalue:only',
29+
additionalProperties: false,
30+
dynamic: true
31+
}
32+
},
33+
dynamicFields: {
34+
listId: async (request: RequestClient, { settings }): Promise<DynamicFieldResponse> => {
35+
return new DDListsApi(settings, request).getLists()
36+
},
37+
dataFields: {
38+
__keys__: async (request, { settings }) => {
39+
return new DDDataFieldsApi(settings, request).getDataFields()
40+
}
41+
}
42+
},
43+
44+
perform: async (request, { settings, payload }) => {
45+
const fieldsAPI = new DDDataFieldsApi(settings, request)
46+
await fieldsAPI.validateDataFields(payload)
47+
48+
const contactApi = new DDContactApi(settings, request)
49+
return contactApi.upsertContact(payload)
50+
}
51+
}
52+
53+
export default action
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { RequestClient } from '@segment/actions-core'
2+
import type { Settings } from '../generated-types'
3+
4+
abstract class DDApi {
5+
private readonly apiUrl: string
6+
private readonly request: RequestClient
7+
8+
protected constructor(settings: Settings, request: RequestClient) {
9+
this.apiUrl = settings.api_host
10+
this.request = request
11+
}
12+
13+
/**
14+
* Generic GET method
15+
* @param endpoint - The API endpoint to call.
16+
* @param params - An object containing query parameters.
17+
*
18+
* @returns A an object of type TResponse containing the response data.
19+
*/
20+
protected async get<TResponse>(endpoint: string, params?: Record<string, unknown>) {
21+
const url = new URL(`${this.apiUrl}${endpoint}`)
22+
if (params) {
23+
url.search = new URLSearchParams(
24+
Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]))
25+
).toString()
26+
}
27+
return await this.request<TResponse>(`${url}`, {
28+
method: 'GET'
29+
})
30+
}
31+
32+
/**
33+
* Generic POST method
34+
* @param endpoint - The API endpoint to call.
35+
* @param data - The data to send in the client body.
36+
*
37+
* @returns an object of type TResponse containing the response data.
38+
*/
39+
protected async post<ResponseType, RequestJSON>(endpoint: string, data: RequestJSON) {
40+
return await this.request<ResponseType>(`${this.apiUrl}${endpoint}`, {
41+
method: 'POST',
42+
json: data,
43+
headers: { 'Content-Type': 'application/json' }
44+
})
45+
}
46+
47+
/**
48+
* Generic DELETE method
49+
* @param endpoint - The API endpoint to call.
50+
*/
51+
protected async delete(endpoint: string) {
52+
return await this.request(`${this.apiUrl}${endpoint}`, {
53+
method: 'DELETE'
54+
})
55+
}
56+
57+
/**
58+
* Generic PATCH method
59+
* @param endpoint - The API endpoint to call.
60+
* @param data - The data to send in the client body.
61+
*
62+
* @returns an object of type TResponse containing the response data.
63+
*/
64+
protected async patch<ResponseType, RequestJSON>(endpoint: string, data: RequestJSON) {
65+
return await this.request<ResponseType>(`${this.apiUrl}${endpoint}`, {
66+
method: 'PATCH',
67+
json: data,
68+
headers: { 'Content-Type': 'application/json' }
69+
})
70+
}
71+
}
72+
73+
export default DDApi
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { default as DDContactApi } from './resources/dd-contact-api'
2+
export { default as DDListsApi } from './resources/dd-lists-api'
3+
export { default as DDEnrolmentApi } from './resources/dd-enrolment-api'
4+
export { default as DDDataFieldsApi } from './resources/dd-datafields-api'
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { ModifiedResponse, RequestClient } from '@segment/actions-core'
2+
import type { Settings } from '../../generated-types'
3+
import DDApi from '../dd-api'
4+
import { Contact, ChannelIdentifier, Identifiers, ChannelProperties, UpsertContactJSON, DataFields } from '../types'
5+
import type { Payload } from '../../addContactToList/generated-types'
6+
7+
class DDContactApi extends DDApi {
8+
constructor(settings: Settings, client: RequestClient) {
9+
super(settings, client)
10+
}
11+
12+
/**
13+
* Fetches a contact from Dotdigital API.
14+
*
15+
* @param idType - The type of identifier (e.g., email, mobile number).
16+
* @param idValue - The value of the identifier.
17+
*
18+
* @returns A promise that resolves to a ContactResponse.
19+
*/
20+
async getContact(idType: string, idValue: string | undefined): Promise<Contact> {
21+
const response: ModifiedResponse<Contact> = await this.get<Contact>(`/contacts/v3/${idType}/${idValue}`)
22+
return response.data
23+
}
24+
25+
/**
26+
* Fetches a contact from Dotdigital API via means of Patch.
27+
*
28+
* @param channelIdentifier - The identifier of the contact channel.
29+
* @param data - The data to be sent in the request body.
30+
*
31+
* @returns A promise that resolves to a ContactResponse.
32+
*/
33+
async fetchOrCreateContact<T>(channelIdentifier: ChannelIdentifier, data: T): Promise<Contact> {
34+
const [[idType, idValue]] = Object.entries(channelIdentifier)
35+
const response: ModifiedResponse<Contact> = await this.patch<Contact, T>(`/contacts/v3/${idType}/${idValue}`, data)
36+
return response.data
37+
}
38+
39+
/**
40+
* Creates or updates a contact .
41+
* @param {Payload} payload - The event payload.
42+
* @returns {Promise<Contact>} A promise resolving to the contact data.
43+
*/
44+
public async upsertContact(payload: Payload): Promise<Contact> {
45+
const { channelIdentifier, emailIdentifier, mobileNumberIdentifier, listId, dataFields } = payload
46+
47+
const idValue = channelIdentifier === 'email' ? emailIdentifier : mobileNumberIdentifier
48+
49+
const identifiers: Identifiers = {
50+
...(emailIdentifier && { email: emailIdentifier }),
51+
...(mobileNumberIdentifier && { mobileNumber: mobileNumberIdentifier })
52+
}
53+
54+
const channelProperties: ChannelProperties = {
55+
...(emailIdentifier && {
56+
email: {
57+
status: 'subscribed',
58+
emailType: 'html',
59+
optInType: 'single'
60+
}
61+
}),
62+
...(mobileNumberIdentifier && {
63+
sms: { status: 'subscribed' }
64+
})
65+
}
66+
67+
const data: UpsertContactJSON = {
68+
identifiers,
69+
channelProperties,
70+
lists: [listId],
71+
dataFields: dataFields as DataFields
72+
}
73+
74+
const response: ModifiedResponse<Contact> = await this.patch<Contact, UpsertContactJSON>(
75+
`/contacts/v3/${channelIdentifier}/${idValue}?merge-option=overwrite`,
76+
data
77+
)
78+
79+
return response.data
80+
}
81+
}
82+
83+
export default DDContactApi

0 commit comments

Comments
 (0)