415 lines
19 KiB
OpenEdge ABL
415 lines
19 KiB
OpenEdge ABL
/**
|
|
* @description Sends a Docusign composite envelope via direct REST API call when the
|
|
* primary recipient (Docusign Recipient #1) does not have an email address
|
|
* and requires SMS delivery instead.
|
|
*
|
|
* The dfsle Apex Toolkit does not support the Docusign
|
|
* additionalNotifications/secondaryDeliveryMethod property needed for
|
|
* SMS-only recipients, so this service bypasses the toolkit and calls
|
|
* the Docusign REST API directly.
|
|
*
|
|
* Prerequisites:
|
|
* - Named Credential "DocusignAPI" must be configured in the org.
|
|
* - Docusign account must have SMS delivery enabled.
|
|
* - Docusign_Configuration__c custom setting must have Account_Id__c populated.
|
|
*
|
|
* @author Paul Huliganga
|
|
* @date 2026-02-25
|
|
*/
|
|
global with sharing class DocusignSmsEnvelopeService {
|
|
|
|
// Named Credential for Docusign REST API callouts
|
|
@TestVisible
|
|
private static final String NAMED_CREDENTIAL = 'callout:DocusignAPI';
|
|
|
|
// Endpoint path template — {0} is replaced with the accountId
|
|
@TestVisible
|
|
private static final String ENVELOPES_PATH = '/accounts/{0}/envelopes';
|
|
|
|
// Dummy email used for SMS-only recipients. Docusign requires an email address
|
|
// on every recipient even when delivery is via SMS. This placeholder satisfies
|
|
// that requirement without routing any actual email.
|
|
@TestVisible
|
|
private static final String SMS_PLACEHOLDER_EMAIL = 'placeholder_email@docusign.com';
|
|
|
|
// Country code assumed when the caller supplies a bare 10-digit US number.
|
|
// The flow enforces E.164 format (e.g. +15551234567) so this is a safety fallback.
|
|
private static final String DEFAULT_COUNTRY_CODE = '1';
|
|
|
|
/**
|
|
* @description Builds and sends a composite Docusign envelope with SMS delivery for
|
|
* the primary recipient. Mirrors the logic in DocusignCompositeEnvelopeBuilder
|
|
* but constructs the JSON payload manually so that additionalNotifications
|
|
* can be included on the recipient object.
|
|
*
|
|
* @param recordId The Client_Case__c record ID (used to look up recipients)
|
|
* @param sortedIds Template IDs in sorted order (may contain duplicates for multi-copy)
|
|
* @param docNames Document labels in same order as sortedIds
|
|
* @param displayNames Deduplicated display labels for email subject / body
|
|
* @param envelopeSubject Email subject line (already formatted and truncated)
|
|
* @param envelopeBody Email body text (already formatted and deduplicated)
|
|
* @param smsPhone Mobile phone number for SMS delivery (E.164 preferred, e.g. +15551234567)
|
|
* @return Docusign envelope ID string on success
|
|
* @throws IllegalArgumentException when recipients or configuration data are missing
|
|
* @throws CalloutException on HTTP errors
|
|
*/
|
|
global static String sendEnvelope(
|
|
String recordId,
|
|
List<String> sortedIds,
|
|
List<String> docNames,
|
|
List<String> displayNames,
|
|
String envelopeSubject,
|
|
String envelopeBody,
|
|
String smsPhone
|
|
) {
|
|
// ─── Fetch Docusign account ID from custom setting ───────────────────────────
|
|
Docusign_Configuration__c config = Docusign_Configuration__c.getInstance();
|
|
if (config == null || String.isBlank(config.Account_Id__c)) {
|
|
throw new IllegalArgumentException(
|
|
'Docusign_Configuration__c is not configured. '
|
|
+ 'Please set Account_Id__c in the Docusign Configuration custom setting.'
|
|
);
|
|
}
|
|
String accountId = config.Account_Id__c;
|
|
|
|
// ─── Resolve recipients from Client_Case__c ───────────────────────────────────
|
|
RecipientInfo serviceCoordinator = null;
|
|
RecipientInfo docusignRecipient = null;
|
|
|
|
String query = 'SELECT Id, Service_Coordinator__c, Docusign_Recipient_1__c '
|
|
+ 'FROM Client_Case__c WHERE Id = :recordId LIMIT 1';
|
|
Client_Case__c caseRecord = Database.query(query);
|
|
|
|
Id scId = (Id) caseRecord.get('Service_Coordinator__c');
|
|
Id drId = (Id) caseRecord.get('Docusign_Recipient_1__c');
|
|
|
|
if (scId != null) {
|
|
serviceCoordinator = resolveRecipient(scId, 'Service Coordinator', 1, null, false);
|
|
}
|
|
if (drId != null) {
|
|
// SMS phone provided — substitute placeholder email for the Docusign Recipient
|
|
docusignRecipient = resolveRecipient(drId, 'Docusign Recipient #1', 2, smsPhone, true);
|
|
}
|
|
|
|
if (serviceCoordinator == null && docusignRecipient == null) {
|
|
throw new IllegalArgumentException(
|
|
'No recipients found on the Client Case record. '
|
|
+ 'Please ensure Service Coordinator and Docusign Recipient #1 are populated.'
|
|
);
|
|
}
|
|
|
|
// ─── Build JSON body ─────────────────────────────────────────────────────────
|
|
String jsonBody = buildEnvelopeJson(
|
|
sortedIds,
|
|
docNames,
|
|
envelopeSubject,
|
|
envelopeBody,
|
|
serviceCoordinator,
|
|
docusignRecipient,
|
|
recordId
|
|
);
|
|
|
|
// ─── Call the Docusign REST API ───────────────────────────────────────────────
|
|
String endpoint = NAMED_CREDENTIAL
|
|
+ String.format(ENVELOPES_PATH, new List<Object>{ accountId });
|
|
|
|
HttpRequest httpReq = new HttpRequest();
|
|
httpReq.setEndpoint(endpoint);
|
|
httpReq.setMethod('POST');
|
|
httpReq.setHeader('Content-Type', 'application/json');
|
|
httpReq.setHeader('Accept', 'application/json');
|
|
httpReq.setBody(jsonBody);
|
|
|
|
Http http = new Http();
|
|
HttpResponse httpResp = http.send(httpReq);
|
|
|
|
Integer statusCode = httpResp.getStatusCode();
|
|
String responseBody = httpResp.getBody();
|
|
|
|
if (statusCode == 200 || statusCode == 201) {
|
|
// Parse envelopeId from response
|
|
Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(responseBody);
|
|
Object envelopeIdObj = responseMap.get('envelopeId');
|
|
if (envelopeIdObj == null) {
|
|
throw new CalloutException(
|
|
'Docusign API returned success but no envelopeId in response: ' + responseBody
|
|
);
|
|
}
|
|
return String.valueOf(envelopeIdObj);
|
|
} else {
|
|
// Try to extract a meaningful error message from the response JSON
|
|
String errorDetail = parseDocusignError(responseBody);
|
|
throw new CalloutException(
|
|
'Docusign API returned HTTP ' + statusCode + ': ' + errorDetail
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────────
|
|
// PRIVATE HELPERS
|
|
// ─────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* @description Resolves a recipient's name and email from a Contact or User record.
|
|
* When applySmsPlaceholder is true and the record has no email,
|
|
* the SMS_PLACEHOLDER_EMAIL constant is used instead.
|
|
*/
|
|
private static RecipientInfo resolveRecipient(
|
|
Id recipientId,
|
|
String roleName,
|
|
Integer routingOrder,
|
|
String smsPhone,
|
|
Boolean applySmsPlaceholder
|
|
) {
|
|
String objectType = recipientId.getSObjectType().getDescribe().getName();
|
|
String name;
|
|
String email;
|
|
|
|
if (objectType == 'Contact') {
|
|
Contact c = [SELECT Id, Name, Email FROM Contact WHERE Id = :recipientId LIMIT 1];
|
|
name = c.Name;
|
|
email = c.Email;
|
|
} else if (objectType == 'User') {
|
|
User u = [SELECT Id, Name, Email FROM User WHERE Id = :recipientId LIMIT 1];
|
|
name = u.Name;
|
|
email = u.Email;
|
|
} else {
|
|
throw new IllegalArgumentException(
|
|
'Unsupported recipient type: ' + objectType + '. Expected Contact or User.'
|
|
);
|
|
}
|
|
|
|
if (String.isBlank(email)) {
|
|
if (applySmsPlaceholder) {
|
|
email = SMS_PLACEHOLDER_EMAIL;
|
|
} else {
|
|
throw new IllegalArgumentException(
|
|
'No email found for ' + roleName + ' (' + name + '). '
|
|
+ 'Please ensure the recipient has a valid email address.'
|
|
);
|
|
}
|
|
}
|
|
|
|
RecipientInfo info = new RecipientInfo();
|
|
info.name = name;
|
|
info.email = email;
|
|
info.roleName = roleName;
|
|
info.routingOrder = routingOrder;
|
|
info.smsPhone = applySmsPlaceholder ? smsPhone : null;
|
|
return info;
|
|
}
|
|
|
|
/**
|
|
* @description Constructs the full envelope JSON payload for the Docusign REST API.
|
|
* Uses compositeTemplates so that all selected templates are merged into
|
|
* a single envelope, matching what the dfsle Toolkit does internally.
|
|
*
|
|
* Docusign compositeTemplates reference:
|
|
* https://developers.docusign.com/docs/esign-rest-api/reference/envelopes/envelopes/create/
|
|
*/
|
|
@TestVisible
|
|
private static String buildEnvelopeJson(
|
|
List<String> sortedIds,
|
|
List<String> docNames,
|
|
String envelopeSubject,
|
|
String envelopeBody,
|
|
RecipientInfo serviceCoordinator,
|
|
RecipientInfo docusignRecipient,
|
|
String sourceRecordId
|
|
) {
|
|
// Build the recipients object (shared across all compositeTemplates)
|
|
// Each recipient gets a unique recipientId (sequential string integer).
|
|
List<Object> signers = new List<Object>();
|
|
Integer recipientIdCounter = 1;
|
|
|
|
if (serviceCoordinator != null) {
|
|
signers.add(buildSignerMap(serviceCoordinator, String.valueOf(recipientIdCounter++)));
|
|
}
|
|
if (docusignRecipient != null) {
|
|
signers.add(buildSignerMap(docusignRecipient, String.valueOf(recipientIdCounter++)));
|
|
}
|
|
|
|
Map<String, Object> recipientsMap = new Map<String, Object>{
|
|
'signers' => signers
|
|
};
|
|
|
|
// Build compositeTemplates array — one entry per document (template ID + label)
|
|
List<Object> compositeTemplates = new List<Object>();
|
|
for (Integer i = 0; i < sortedIds.size(); i++) {
|
|
String templateId = sortedIds[i];
|
|
String docLabel = docNames[i];
|
|
|
|
Map<String, Object> serverTemplate = new Map<String, Object>{
|
|
'sequence' => '1',
|
|
'templateId' => templateId
|
|
};
|
|
|
|
// Inline template overrides — set the document label and wire up role assignments
|
|
List<Object> inlineRecipientSigners = new List<Object>();
|
|
Integer irCounter = 1;
|
|
if (serviceCoordinator != null) {
|
|
inlineRecipientSigners.add(new Map<String, Object>{
|
|
'recipientId' => String.valueOf(irCounter++),
|
|
'roleName' => serviceCoordinator.roleName,
|
|
'name' => serviceCoordinator.name,
|
|
'email' => serviceCoordinator.email,
|
|
'routingOrder' => String.valueOf(serviceCoordinator.routingOrder)
|
|
});
|
|
}
|
|
if (docusignRecipient != null) {
|
|
Map<String, Object> drSigner = new Map<String, Object>{
|
|
'recipientId' => String.valueOf(irCounter++),
|
|
'roleName' => docusignRecipient.roleName,
|
|
'name' => docusignRecipient.name,
|
|
'email' => docusignRecipient.email,
|
|
'routingOrder' => String.valueOf(docusignRecipient.routingOrder)
|
|
};
|
|
// Add SMS additionalNotifications for this recipient
|
|
if (String.isNotBlank(docusignRecipient.smsPhone)) {
|
|
String[] phoneParts = parsePhone(docusignRecipient.smsPhone);
|
|
drSigner.put('additionalNotifications', new List<Object>{
|
|
new Map<String, Object>{
|
|
'secondaryDeliveryMethod' => 'SMS',
|
|
'phoneNumber' => new Map<String, Object>{
|
|
'countryCode' => phoneParts[0],
|
|
'number' => phoneParts[1]
|
|
}
|
|
}
|
|
});
|
|
}
|
|
inlineRecipientSigners.add(drSigner);
|
|
}
|
|
|
|
Map<String, Object> inlineTemplate = new Map<String, Object>{
|
|
'sequence' => '2',
|
|
'recipients' => new Map<String, Object>{
|
|
'signers' => inlineRecipientSigners
|
|
},
|
|
'document' => new Map<String, Object>{
|
|
'name' => docLabel,
|
|
'documentId' => String.valueOf(i + 1),
|
|
'transformPdfFields' => 'true'
|
|
}
|
|
};
|
|
|
|
compositeTemplates.add(new Map<String, Object>{
|
|
'serverTemplates' => new List<Object>{ serverTemplate },
|
|
'inlineTemplates' => new List<Object>{ inlineTemplate },
|
|
'recipients' => recipientsMap
|
|
});
|
|
}
|
|
|
|
Map<String, Object> envelopeDefinition = new Map<String, Object>{
|
|
'status' => 'sent',
|
|
'emailSubject' => envelopeSubject,
|
|
'emailBlurb' => envelopeBody,
|
|
'compositeTemplates' => compositeTemplates
|
|
};
|
|
|
|
return JSON.serialize(envelopeDefinition);
|
|
}
|
|
|
|
/**
|
|
* @description Builds the signer map for a recipient in the envelope-level recipients object.
|
|
* Adds additionalNotifications for SMS recipients.
|
|
*/
|
|
private static Map<String, Object> buildSignerMap(RecipientInfo info, String recipientId) {
|
|
Map<String, Object> signer = new Map<String, Object>{
|
|
'recipientId' => recipientId,
|
|
'name' => info.name,
|
|
'email' => info.email,
|
|
'roleName' => info.roleName,
|
|
'routingOrder' => String.valueOf(info.routingOrder)
|
|
};
|
|
|
|
if (String.isNotBlank(info.smsPhone)) {
|
|
String[] phoneParts = parsePhone(info.smsPhone);
|
|
signer.put('additionalNotifications', new List<Object>{
|
|
new Map<String, Object>{
|
|
'secondaryDeliveryMethod' => 'SMS',
|
|
'phoneNumber' => new Map<String, Object>{
|
|
'countryCode' => phoneParts[0],
|
|
'number' => phoneParts[1]
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return signer;
|
|
}
|
|
|
|
/**
|
|
* @description Parses a phone number into [countryCode, nationalNumber].
|
|
* Accepts E.164 format (+15551234567) or a bare 10-digit number.
|
|
* Returns [DEFAULT_COUNTRY_CODE, strippedNumber] for bare numbers.
|
|
*
|
|
* @param phone Raw phone string from the flow input
|
|
* @return String array: index 0 = country code, index 1 = national number (digits only)
|
|
*/
|
|
@TestVisible
|
|
private static String[] parsePhone(String phone) {
|
|
if (String.isBlank(phone)) {
|
|
return new String[]{ DEFAULT_COUNTRY_CODE, '' };
|
|
}
|
|
|
|
String stripped = phone.replaceAll('[^\\d+]', ''); // keep digits and leading +
|
|
|
|
if (stripped.startsWith('+')) {
|
|
// E.164: determine country code length heuristically
|
|
// We only support 1-digit (+1) and 2-digit (+XX) country codes for now
|
|
String digits = stripped.substring(1); // remove leading +
|
|
if (digits.length() == 11 && digits.startsWith('1')) {
|
|
// +1XXXXXXXXXX (North American Numbering Plan)
|
|
return new String[]{ '1', digits.substring(1) };
|
|
} else if (digits.length() == 12) {
|
|
// +XXXXXXXXXXXX (2-digit country code + 10 digit number)
|
|
return new String[]{ digits.left(2), digits.substring(2) };
|
|
} else {
|
|
// Fall back: treat first digit(s) as country code not supported; use full digits
|
|
return new String[]{ DEFAULT_COUNTRY_CODE, digits };
|
|
}
|
|
}
|
|
|
|
// Bare digits — assume DEFAULT_COUNTRY_CODE
|
|
return new String[]{ DEFAULT_COUNTRY_CODE, stripped };
|
|
}
|
|
|
|
/**
|
|
* @description Attempts to extract a human-readable error message from a Docusign
|
|
* REST API error response JSON.
|
|
* @param responseBody Raw HTTP response body
|
|
* @return Error string with errorCode and message if available, otherwise raw body
|
|
*/
|
|
private static String parseDocusignError(String responseBody) {
|
|
if (String.isBlank(responseBody)) {
|
|
return '(no response body)';
|
|
}
|
|
try {
|
|
Map<String, Object> errorMap = (Map<String, Object>) JSON.deserializeUntyped(responseBody);
|
|
String errorCode = errorMap.containsKey('errorCode') ? String.valueOf(errorMap.get('errorCode')) : null;
|
|
String message = errorMap.containsKey('message') ? String.valueOf(errorMap.get('message')) : null;
|
|
if (String.isNotBlank(errorCode) || String.isNotBlank(message)) {
|
|
return (errorCode != null ? '[' + errorCode + '] ' : '') + (message != null ? message : '');
|
|
}
|
|
} catch (Exception ex) {
|
|
// Non-JSON response — return raw body below
|
|
}
|
|
return responseBody.left(500); // cap at 500 chars to avoid log spam
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────────
|
|
// INNER CLASSES
|
|
// ─────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* @description Lightweight holder for resolved recipient data.
|
|
*/
|
|
private class RecipientInfo {
|
|
String name;
|
|
String email;
|
|
String roleName;
|
|
Integer routingOrder;
|
|
String smsPhone; // null for email recipients; phone string for SMS recipients
|
|
}
|
|
}
|