Skip to content
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

feat: onboarding clicksend destination #3486

Merged
merged 9 commits into from
Jul 1, 2024
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
10 changes: 10 additions & 0 deletions src/cdk/v2/destinations/clicksend/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const SMS_SEND_ENDPOINT = 'https://rest.clicksend.com/v3/sms/send';
const SMS_CAMPAIGN_ENDPOINT = 'https://rest.clicksend.com/v3/sms-campaigns/send';
const COMMON_CONTACT_DOMAIN = 'https://rest.clicksend.com/v3/lists';

module.exports = {
SMS_SEND_ENDPOINT,
SMS_CAMPAIGN_ENDPOINT,
MAX_BATCH_SIZE: 1000,
COMMON_CONTACT_DOMAIN,
};
98 changes: 98 additions & 0 deletions src/cdk/v2/destinations/clicksend/procWorkflow.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
bindings:
- name: EventType
path: ../../../../constants
- path: ../../bindings/jsontemplate
- name: defaultRequestConfig
path: ../../../../v0/util
- name: removeUndefinedAndNullValues
path: ../../../../v0/util
- name: isDefinedAndNotNull
path: ../../../../v0/util
- path: ./utils
- name: getDestinationExternalID
path: ../../../../v0/util
- name: base64Convertor
path: ../../../../v0/util
- path: ./config

steps:
- name: messageType
template: |
.message.type.toLowerCase();
- name: validateInput
template: |
let messageType = $.outputs.messageType;
$.assert(messageType, "message Type is not present. Aborting");
$.assert(messageType in {{$.EventType.([.TRACK,.IDENTIFY])}}, "message type " + messageType + " is not supported");
$.assertConfig(.destination.Config.clicksendUsername, "Clicksend user name is not present. Aborting");
$.assertConfig(.destination.Config.clicksendPassword, "Click send password is not present. Aborting");
- name: prepareIdentifyPayload
condition: $.outputs.messageType === {{$.EventType.IDENTIFY}}
template: |
const payload = .message.({
email: {{{{$.getGenericPaths("emailOnly")}}}},
phone_number: {{{{$.getGenericPaths("phone")}}}},
first_name: {{{{$.getGenericPaths("firstName")}}}},
last_name: {{{{$.getGenericPaths("lastName")}}}},
address_line_1: .traits.address_line_1 || .context.traits.address_line_1 || JSON.stringify({{{{$.getGenericPaths("address")}}}}) ,
address_line_2: .traits.address_line_2 || .context.traits.address_line_2 || JSON.stringify({{{{$.getGenericPaths("address")}}}}) ,
city: {{{{$.getGenericPaths("city")}}}},
state: {{{{$.getGenericPaths("state")}}}},
country: {{{{$.getGenericPaths("country")}}}},
fax_number: .traits.fax_number || .context.traits.fax_number,
organization_name: .traits.fax_number || .context.traits.fax_number,
});
$.validateIdentifyPayload(payload);
payload.contact_id = $.getDestinationExternalID(.message,'CLICKSEND_CONTACT_ID');
const contactList = $.getDestinationExternalID(.message,'CLICKSEND_CONTACT_LIST_ID');
$.assert(contactList, "externalId does not contain contact list Id of Clicksend. Aborting.");
$.context.endPoint = $.getEndIdentifyPoint(payload.contact_id, contactList);
$.context.payload = $.removeUndefinedAndNullValues(payload);
$.context.method = payload.contact_id ? 'PUT' : 'POST';
- name: prepareTrackPayload
condition: $.outputs.messageType === {{$.EventType.TRACK}}
steps:
- name: sendSmsCampaignPayload
condition: $.isDefinedAndNotNull($.getDestinationExternalID(^.message,'CLICKSEND_CONTACT_LIST_ID'))
template: |
const sendCampaignPayload = .message.({
list_id : parseInt($.getDestinationExternalID(^.message,'CLICKSEND_CONTACT_LIST_ID'), 10),
name : String(.properties.name),
body : String(.properties.body),
from : $.getDestinationExternalID(^.message,'CLICKSEND_SENDER_EMAIL') || ^.destination.Config.defaultSenderEmail,
schedule : $.deduceSchedule(.properties.schedule,{{{{$.getGenericPaths("timestamp")}}}}, ^.destination.Config)
});
$.assert(!Number.isNaN(sendCampaignPayload.list_id), "list_id must be an integer");
$.validateTrackSMSCampaignPayload(sendCampaignPayload);
$.context.payload = $.removeUndefinedAndNullValues(sendCampaignPayload);
$.context.endPoint = $.SMS_CAMPAIGN_ENDPOINT;
$.context.method = 'POST';
else:
name: sendSmsPayload
template: |
const sendSmsPayload = .message.({
from: $.getDestinationExternalID(^.message,'CLICKSEND_SENDER_EMAIL') || ^.destination.Config.defaultSenderEmail,
email: {{{{$.getGenericPaths("emailOnly")}}}},
to: {{{{$.getGenericPaths("phone")}}}},
body: .properties.body,
source: .properties.source || ^.destination.Config.defaultSource,
schedule: $.deduceSchedule(.properties.schedule, {{{{$.getGenericPaths("timestamp")}}}}, ^.destination.Config),
custom_string: .properties.custom_string,
country: {{{{$.getGenericPaths("country")}}}},
from_email: .properties.from_email
});
$.assert((sendSmsPayload.from && sendSmsPayload.to && sendSmsPayload.body), "all of sender email, phone and body needs to be present for track call");
$.context.payload = $.removeUndefinedAndNullValues(sendSmsPayload);
$.context.endPoint = $.SMS_SEND_ENDPOINT;
$.context.method = 'POST';
- name: buildResponse
template: |
const response = $.defaultRequestConfig();
response.body.JSON = $.context.payload;
response.endpoint = $.context.endPoint;
response.method = $.context.method;
response.headers = {
Authorization : "Basic " + $.base64Convertor(.destination.Config.clicksendUsername + ":" + .destination.Config.clicksendPassword),
"Content-Type" : "application/json",
};
response
38 changes: 38 additions & 0 deletions src/cdk/v2/destinations/clicksend/rtWorkflow.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
bindings:
- path: ./utils
- name: handleRtTfSingleEventError
path: ../../../../v0/util/index

steps:
- name: validateInput
template: |
$.assert(Array.isArray(^) && ^.length > 0, "Invalid event array")
- name: transform
externalWorkflow:
path: ./procWorkflow.yaml
bindings:
- name: batchMode
value: true
loopOverInput: true
- name: successfulEvents
template: |
$.outputs.transform#idx.output.({
"message": .[],
"destination": ^ [idx].destination,
"metadata": ^ [idx].metadata
})[]
- name: failedEvents
template: |
$.outputs.transform#idx.error.(
$.handleRtTfSingleEventError(^[idx], .originalError ?? ., {})
)[]
- name: batchSuccessfulEvents
description: Batches the successfulEvents
template: |
$.context.batchedPayload = $.batchResponseBuilder($.outputs.successfulEvents);
- name: finalPayload
template: |
[...$.outputs.failedEvents, ...$.context.batchedPayload]
127 changes: 127 additions & 0 deletions src/cdk/v2/destinations/clicksend/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
const { InstrumentationError } = require('@rudderstack/integrations-lib');
const lodash = require('lodash');
const { BatchUtils } = require('@rudderstack/workflow-engine');
const { SMS_SEND_ENDPOINT, MAX_BATCH_SIZE, COMMON_CONTACT_DOMAIN } = require('./config');
const { isDefinedAndNotNullAndNotEmpty, isDefinedAndNotNull } = require('../../../../v0/util');

const getEndIdentifyPoint = (contactId, contactListId) =>
`${COMMON_CONTACT_DOMAIN}/${contactListId}/contacts${isDefinedAndNotNullAndNotEmpty(contactId) ? `/${contactId}` : ''}`;

const validateIdentifyPayload = (payload) => {
if (
!(
isDefinedAndNotNullAndNotEmpty(payload.phone_number) ||
isDefinedAndNotNullAndNotEmpty(payload.email) ||
isDefinedAndNotNullAndNotEmpty(payload.fax_number)
)
) {
throw new InstrumentationError(
'Either phone number or email or fax_number is mandatory for contact creation',
);
}
};

const validateTrackSMSCampaignPayload = (payload) => {
if (!(payload.body && payload.name && payload.list_id && payload.from)) {
throw new InstrumentationError(
'All of contact list Id, name, body and from are required to trigger an sms campaign',
);
}
};

const deduceSchedule = (eventLevelSchedule, timestamp, destConfig) => {
if (isDefinedAndNotNull(eventLevelSchedule) && !Number.isNaN(eventLevelSchedule)) {
return eventLevelSchedule;
}
const { defaultCampaignScheduleUnit = 'minute', defaultCampaignSchedule = 0 } = destConfig;
const date = new Date(timestamp);

if (defaultCampaignScheduleUnit === 'day') {
date.setDate(date.getDate() + defaultCampaignSchedule);
} else if (defaultCampaignScheduleUnit === 'minute') {
date.setMinutes(date.getMinutes() + defaultCampaignSchedule);
} else {
throw new Error("Invalid delta unit. Use 'day' or 'minute'.");
}

return Math.floor(date.getTime() / 1000);
};

const mergeMetadata = (batch) => batch.map((event) => event.metadata);

const getMergedEvents = (batch) => batch.map((event) => event.message[0].body.JSON);

const getHttpMethodForEndpoint = (endpoint) => {
const contactIdPattern = /\/contacts\/[^/]+$/;
return contactIdPattern.test(endpoint) ? 'PUT' : 'POST';
};

const buildBatchedRequest = (batch, constants, endpoint) => ({
batchedRequest: {
body: {
JSON:
endpoint === SMS_SEND_ENDPOINT
? { messages: getMergedEvents(batch) }
: batch[0].message[0].body.JSON,
JSON_ARRAY: {},
XML: {},
FORM: {},
},
version: '1',
type: 'REST',
method: getHttpMethodForEndpoint(endpoint),
endpoint,
headers: constants.headers,
params: {},
files: {},
},
metadata: mergeMetadata(batch),
batched: endpoint === SMS_SEND_ENDPOINT,
statusCode: 200,
destination: batch[0].destination,
});

const initializeConstants = (successfulEvents) => {
if (successfulEvents.length === 0) return null;
return {
version: successfulEvents[0].message[0].version,
type: successfulEvents[0].message[0].type,
headers: successfulEvents[0].message[0].headers,
destination: successfulEvents[0].destination,
endPoint: successfulEvents[0].message[0].endpoint,
};
};

const batchResponseBuilder = (events) => {
const response = [];
const constants = initializeConstants(events);
if (!constants) return [];
const typedEventGroups = lodash.groupBy(events, (event) => event.message[0].endpoint);

Object.keys(typedEventGroups).forEach((eventEndPoint) => {
if (eventEndPoint === SMS_SEND_ENDPOINT) {
const batchesOfSMSEvents = BatchUtils.chunkArrayBySizeAndLength(
typedEventGroups[eventEndPoint],
{ maxItems: MAX_BATCH_SIZE },
);
batchesOfSMSEvents.items.forEach((batch) => {
response.push(buildBatchedRequest(batch, constants, eventEndPoint));
});
} else {
response.push(
buildBatchedRequest([typedEventGroups[eventEndPoint][0]], constants, eventEndPoint),
);
}
});

return response;
};

module.exports = {
batchResponseBuilder,
getEndIdentifyPoint,
validateIdentifyPayload,
validateTrackSMSCampaignPayload,
deduceSchedule,
getHttpMethodForEndpoint,
};
Loading
Loading