/** * @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 sortedIds, List docNames, List 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{ 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 responseMap = (Map) 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 sortedIds, List 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 signers = new List(); Integer recipientIdCounter = 1; if (serviceCoordinator != null) { signers.add(buildSignerMap(serviceCoordinator, String.valueOf(recipientIdCounter++))); } if (docusignRecipient != null) { signers.add(buildSignerMap(docusignRecipient, String.valueOf(recipientIdCounter++))); } Map recipientsMap = new Map{ 'signers' => signers }; // Build compositeTemplates array — one entry per document (template ID + label) List compositeTemplates = new List(); for (Integer i = 0; i < sortedIds.size(); i++) { String templateId = sortedIds[i]; String docLabel = docNames[i]; Map serverTemplate = new Map{ 'sequence' => '1', 'templateId' => templateId }; // Inline template overrides — set the document label and wire up role assignments List inlineRecipientSigners = new List(); Integer irCounter = 1; if (serviceCoordinator != null) { inlineRecipientSigners.add(new Map{ 'recipientId' => String.valueOf(irCounter++), 'roleName' => serviceCoordinator.roleName, 'name' => serviceCoordinator.name, 'email' => serviceCoordinator.email, 'routingOrder' => String.valueOf(serviceCoordinator.routingOrder) }); } if (docusignRecipient != null) { Map drSigner = new Map{ '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{ new Map{ 'secondaryDeliveryMethod' => 'SMS', 'phoneNumber' => new Map{ 'countryCode' => phoneParts[0], 'number' => phoneParts[1] } } }); } inlineRecipientSigners.add(drSigner); } Map inlineTemplate = new Map{ 'sequence' => '2', 'recipients' => new Map{ 'signers' => inlineRecipientSigners }, 'document' => new Map{ 'name' => docLabel, 'documentId' => String.valueOf(i + 1), 'transformPdfFields' => 'true' } }; compositeTemplates.add(new Map{ 'serverTemplates' => new List{ serverTemplate }, 'inlineTemplates' => new List{ inlineTemplate }, 'recipients' => recipientsMap }); } Map envelopeDefinition = new Map{ '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 buildSignerMap(RecipientInfo info, String recipientId) { Map signer = new Map{ '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{ new Map{ 'secondaryDeliveryMethod' => 'SMS', 'phoneNumber' => new Map{ '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 errorMap = (Map) 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 } }