salesforce-composite-envelo.../composite-envelope-builder/force-app/main/default/classes/DocusignSmsEnvelopeService.cls

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
}
}