/** * @description Combines multiple Docusign templates into a single composite envelope * using the dfsle Apex Toolkit (Docusign for Salesforce managed package). * Recipients are resolved from Client_Case__c lookup fields. * @author Paul Huliganga * @date 2026-02-25 */ global with sharing class DocusignCompositeEnvelopeBuilder { // ============================================================ // CONFIGURATION: Update these constants if field/role names change // ============================================================ // API names of the lookup fields on Client_Case__c that point to recipient records // These are the "Select Lookup Field" values from the Docusign template recipient config private static final String FIELD_SERVICE_COORDINATOR = 'Service_Coordinator__c'; private static final String FIELD_DOCUSIGN_RECIPIENT = 'Docusign_Recipient_1__c'; // Role names must match EXACTLY what's configured in the Docusign templates private static final String ROLE_SERVICE_COORDINATOR = 'Service Coordinator'; private static final String ROLE_DOCUSIGN_RECIPIENT = 'Docusign Recipient #1'; // ============================================================ // MULTI-COPY TEMPLATE: Update this if the template name changes. // This is matched against the dfsle__EnvelopeConfiguration__c Name // field using a case-insensitive contains check. // Both English and Spanish versions share this base name. // ============================================================ @TestVisible private static final String MULTI_COPY_TEMPLATE_NAME = 'Authorization to Release Information'; // ============================================================ // SMS DELIVERY: Placeholder email used when the primary recipient // (Docusign Recipient #1) has no email address and SMS delivery is // requested via recipientSmsPhone. Docusign requires an email on // every recipient even when dfsle.Recipient.withSmsDelivery() is used; // this placeholder satisfies that requirement without routing any // actual email — delivery occurs entirely via SMS. // ============================================================ @TestVisible private static final String SMS_PLACEHOLDER_EMAIL = 'placeholder_email@docusign.com'; @InvocableMethod( label='Send Composite Docusign Envelope' description='Combines multiple Docusign templates into a single envelope using dfsle Apex Toolkit' category='Docusign' ) public static List sendCompositeEnvelope(List requests) { List results = new List(); if (requests == null || requests.isEmpty()) { return buildErrorResult('No request provided'); } DocusignEnvelopeRequest req = requests[0]; DocusignEnvelopeResult result = new DocusignEnvelopeResult(); try { // Validate request DocusignEnvelopeRequestHandler.validateRequest(req); // Create empty envelope linked to the source record dfsle.Envelope myEnvelope = dfsle.EnvelopeService.getEmptyEnvelope( new dfsle.Entity(req.recordId) ); // Expand multi-copy templates before deduplication. // If the user selected the Authorization to Release Information template and // requested more than 1 copy, insert additional copies of its template ID into // the list now so the deduplication step handles all IDs uniformly. List expandedTemplateIds = new List(req.templateIds); Integer copies = (req.authReleaseFormCopies != null && req.authReleaseFormCopies > 1) ? Math.min(req.authReleaseFormCopies, 5) : 1; if (copies > 1) { // Find which template ID(s) correspond to the multi-copy template List multiCopyIds = new List(); for (dfsle__EnvelopeConfiguration__c config : [ SELECT dfsle__DocuSignId__c FROM dfsle__EnvelopeConfiguration__c WHERE dfsle__DocuSignId__c IN :req.templateIds AND Name LIKE :('%' + MULTI_COPY_TEMPLATE_NAME + '%') ]) { multiCopyIds.add(config.dfsle__DocuSignId__c); } // Add (copies - 1) additional entries for each matched template ID for (String multiId : multiCopyIds) { for (Integer i = 1; i < copies; i++) { expandedTemplateIds.add(multiId); } } } // Build document list from templates. // NOTE: We intentionally do NOT deduplicate here so that multiple copies of // the same template ID produce distinct documents in the envelope. // We sort by label after resolving names instead. List sortedTemplateIds = new List(expandedTemplateIds); sortedTemplateIds.sort(); // Query template names for document labels (shows in Docusign Status) // Uses Short_Name__c if populated, otherwise falls back to Name (with language suffix stripped) Map templateNames = new Map(); Map templateShortNames = new Map(); for (dfsle__EnvelopeConfiguration__c config : [ SELECT dfsle__DocuSignId__c, Name, Short_Name__c FROM dfsle__EnvelopeConfiguration__c WHERE dfsle__DocuSignId__c IN :sortedTemplateIds ]) { templateNames.put(config.dfsle__DocuSignId__c, config.Name); if (String.isNotBlank(config.Short_Name__c)) { templateShortNames.put(config.dfsle__DocuSignId__c, config.Short_Name__c); } } List documents = new List(); List docNames = new List(); // Use a list of label+id pairs to correctly handle duplicate template IDs // (e.g. multiple copies of Authorization to Release Information) List labelIdPairs = new List(); Map labelCounters = new Map(); for (String templateId : sortedTemplateIds) { String label; if (templateShortNames.containsKey(templateId)) { label = templateShortNames.get(templateId); } else if (templateNames.containsKey(templateId)) { label = stripLanguageSuffix(templateNames.get(templateId)); } else { label = templateId; } // If the same label appears more than once, append " (Copy N)" to distinguish if (labelCounters.containsKey(label)) { Integer count = labelCounters.get(label) + 1; labelCounters.put(label, count); label = label + ' (Copy ' + count + ')'; } else { labelCounters.put(label, 1); } labelIdPairs.add(new String[]{ label, templateId }); docNames.add(label); } // Sort by label for consistent ordering docNames.sort(); // Re-order labelIdPairs to match sorted docNames Map labelToId = new Map(); for (String[] pair : labelIdPairs) { labelToId.put(pair[0], pair[1]); } List sortedIds = new List(); for (String label : docNames) { sortedIds.add(labelToId.get(label)); } for (Integer i = 0; i < sortedIds.size(); i++) { String templateId = sortedIds[i]; String label = docNames[i]; documents.add( dfsle.Document.fromTemplate( dfsle.UUID.parse(templateId), label ) ); } myEnvelope = myEnvelope.withDocuments(documents); // Build a deduplicated display list for the email subject and body. // Where a template appears more than once (multi-copy), show the base label // once with a " [x N]" count suffix, e.g. "Authorization to Release Information [x 3]". // This keeps the subject and body clean while the envelope still contains all copies. Map baseNameCounts = new Map(); List baseNameOrder = new List(); for (String label : docNames) { // Strip the " (Copy N)" suffix to recover the base label String baseName = label.replaceAll(' \\(Copy \\d+\\)$', ''); if (!baseNameCounts.containsKey(baseName)) { baseNameCounts.put(baseName, 0); baseNameOrder.add(baseName); } baseNameCounts.put(baseName, baseNameCounts.get(baseName) + 1); } List displayNames = new List(); for (String baseName : baseNameOrder) { Integer cnt = baseNameCounts.get(baseName); displayNames.add(cnt > 1 ? baseName + ' (' + cnt + ')' : baseName); } // Set combined template names as the envelope document name // (shows in Docusign Status "Document Name" column) String combinedName = String.join(displayNames, ', '); if (combinedName.length() > 255) { combinedName = combinedName.left(252) + '...'; } // Use combined name as the first document label so it appears in Status if (!documents.isEmpty()) { documents[0] = dfsle.Document.fromTemplate( dfsle.UUID.parse(sortedIds[0]), combinedName ); myEnvelope = myEnvelope.withDocuments(documents); } // Resolve recipients from Client_Case__c lookup fields List recipients = resolveRecipients(req.recordId, req.recipientSmsPhone); myEnvelope = myEnvelope.withRecipients(recipients); // Set envelope subject to combined display names (deduplicated, with copy counts). // Query for EmailMessage__c — use a deduplicated set of template IDs so each // template's body text is included only once even when multi-copy is in effect. Set uniqueTemplateIds = new Set(sortedIds); Map templateBodies = new Map(); for (dfsle__EnvelopeConfiguration__c config : [ SELECT dfsle__DocuSignId__c, dfsle__EmailMessage__c FROM dfsle__EnvelopeConfiguration__c WHERE dfsle__DocuSignId__c IN :uniqueTemplateIds ]) { if (String.isNotBlank(config.dfsle__EmailMessage__c)) { templateBodies.put(config.dfsle__DocuSignId__c, config.dfsle__EmailMessage__c); } } // Build body using one entry per unique template ID (preserving sorted order) Set bodyIdsAdded = new Set(); List bodyParts = new List(); for (String templateId : sortedIds) { if (templateBodies.containsKey(templateId) && !bodyIdsAdded.contains(templateId)) { bodyParts.add(templateBodies.get(templateId)); bodyIdsAdded.add(templateId); } } // Prefix the envelope subject so recipients see the source immediately String envelopeSubject = 'Docusign: ' + combinedName; // Truncate subject to 100 characters maximum as required by Docusign if (envelopeSubject.length() > 100) { envelopeSubject = envelopeSubject.left(97) + '...'; } // Compose body: greeting → template bodies separated by a visual divider → sign-off String DIVIDER = '\n\n' + '─'.repeat(40) + '\n\n'; // Support English (default) and Spanish greetings/signoffs based on the // optional `language` input parameter. Flow consumers may pass locale // codes like 'es', 'es-CO', or user-friendly strings like 'Spanish' or // 'Español'. Normalize and accept common Spanish forms. String GREETING; String SIGNOFF; String lang = req.language == null ? '' : req.language.toLowerCase(); if (lang.startsWith('es') || lang.contains('spanish') || lang.contains('espa')) { // Spanish GREETING = 'Hola,\n\nPor favor, firme la solicitud de DocuSign de parte de Intervención Temprana Colorado.\n\n'; SIGNOFF = '\n\nGracias,\nIntervención Temprana Colorado'; } else { // Default to English GREETING = 'Hello,\n\nPlease complete the DocuSign signature request from Early Intervention Colorado.\n\n'; SIGNOFF = '\n\nThank you,\nEarly Intervention Colorado'; } String envelopeBody; if (bodyParts.isEmpty()) { envelopeBody = GREETING + SIGNOFF; } else { envelopeBody = GREETING + String.join(bodyParts, DIVIDER) + SIGNOFF; } myEnvelope = myEnvelope.withEmail(envelopeSubject, envelopeBody); // Send the envelope myEnvelope = dfsle.EnvelopeService.sendEnvelope(myEnvelope, true); // Success result.envelopeId = String.valueOf(myEnvelope.docuSignId); result.success = true; result.errorMessage = null; logResult(sortedTemplateIds.size(), result.envelopeId, 'Success (' + String.join(displayNames, ', ') + ')', null); } catch (Exception e) { result.success = false; result.errorMessage = e.getMessage(); result.envelopeId = null; logResult( req.templateIds != null ? req.templateIds.size() : 0, null, 'Error', e.getMessage() + '\n' + e.getStackTraceString() ); if (e instanceof System.LimitException) { throw e; } } results.add(result); return results; } /** * @description Resolves recipients from Client_Case__c lookup fields. * Queries the case record and related contacts to get name/email. * @param recordId The Client_Case__c record ID * @param smsPhone Optional SMS phone for the primary recipient. When provided, * the Docusign Recipient #1 is configured for SMS delivery via * dfsle.Recipient.withSmsDelivery() and a placeholder email is * substituted if the recipient has no email address. * @return List of dfsle.Recipient objects with role mappings */ private static List resolveRecipients(String recordId, String smsPhone) { // Query the Client_Case__c record with recipient lookup fields // NOTE: Adjust field API names if they differ in your org String query = 'SELECT Id, ' + FIELD_SERVICE_COORDINATOR + ', ' + FIELD_DOCUSIGN_RECIPIENT + ' FROM Client_Case__c WHERE Id = :recordId LIMIT 1'; Client_Case__c caseRecord = Database.query(query); List recipients = new List(); Integer routingOrder = 1; // Recipient 1: Service Coordinator (always email delivery) Id serviceCoordinatorId = (Id) caseRecord.get(FIELD_SERVICE_COORDINATOR); if (serviceCoordinatorId != null) { recipients.add(buildRecipient(serviceCoordinatorId, ROLE_SERVICE_COORDINATOR, routingOrder++, recordId, null)); } // Recipient 2: Docusign Recipient #1 (SMS delivery when smsPhone is provided) Id docusignRecipientId = (Id) caseRecord.get(FIELD_DOCUSIGN_RECIPIENT); if (docusignRecipientId != null) { recipients.add(buildRecipient(docusignRecipientId, ROLE_DOCUSIGN_RECIPIENT, routingOrder++, recordId, smsPhone)); } if (recipients.isEmpty()) { throw new IllegalArgumentException('No recipients found on the Client Case record. ' + 'Please ensure Service Coordinator and Docusign Recipient #1 are populated.'); } return recipients; } /** * @description Builds a dfsle.Recipient from a Contact/User lookup ID. * When smsPhone is provided the recipient is configured for SMS * delivery via dfsle.Recipient.withSmsDelivery(). If the recipient * has no email address and SMS delivery is requested, SMS_PLACEHOLDER_EMAIL * is substituted — Docusign requires an email field on every recipient * even when delivery is via SMS. * @param recipientId The Contact or User record ID * @param roleName The Docusign template role name (must match exactly) * @param routingOrder Signing order * @param sourceRecordId The source Client_Case__c record ID * @param smsPhone Optional phone number for SMS delivery. Null for email delivery. * @return dfsle.Recipient configured for the role */ private static dfsle.Recipient buildRecipient(Id recipientId, String roleName, Integer routingOrder, String sourceRecordId, String smsPhone) { // Determine if this is a Contact or User String objectType = recipientId.getSObjectType().getDescribe().getName(); String recipientName; String recipientEmail; if (objectType == 'Contact') { Contact c = [SELECT Id, Name, Email FROM Contact WHERE Id = :recipientId LIMIT 1]; recipientName = c.Name; recipientEmail = c.Email; } else if (objectType == 'User') { User u = [SELECT Id, Name, Email FROM User WHERE Id = :recipientId LIMIT 1]; recipientName = u.Name; recipientEmail = u.Email; } else { throw new IllegalArgumentException('Unsupported recipient type: ' + objectType + '. Expected Contact or User.'); } if (String.isBlank(recipientEmail)) { if (String.isNotBlank(smsPhone)) { // SMS delivery requested — substitute placeholder email so the dfsle // Toolkit can create the recipient. Actual delivery is via SMS only. recipientEmail = SMS_PLACEHOLDER_EMAIL; } else { throw new IllegalArgumentException('No email found for ' + roleName + ' (' + recipientName + '). ' + 'Please ensure the recipient has a valid email address.'); } } dfsle.Recipient recipient = dfsle.Recipient.fromSource( recipientName, recipientEmail, null, // phone (not used — SMS delivery set below) roleName, // must match template role exactly new dfsle.Entity(sourceRecordId) // source record for merge fields ); // Enable SMS delivery when a phone number is provided if (String.isNotBlank(smsPhone)) { recipient = recipient.withSmsDelivery(smsPhone); } return recipient; } private static void logResult(Integer templateCount, String envelopeId, String status, String errorMessage) { System.debug(LoggingLevel.INFO, '=== Docusign Composite Envelope ==='); System.debug(LoggingLevel.INFO, 'Templates: ' + templateCount); System.debug(LoggingLevel.INFO, 'Envelope ID: ' + envelopeId); System.debug(LoggingLevel.INFO, 'Status: ' + status); if (String.isNotBlank(errorMessage)) { System.debug(LoggingLevel.ERROR, 'Error: ' + errorMessage); } } /** * @description Strips language suffixes like " - English" or " - Spanish" from template names * @param name Template name * @return Cleaned template name */ @TestVisible private static String stripLanguageSuffix(String name) { if (String.isBlank(name)) return name; // Remove common language suffixes (case-insensitive) String cleaned = name; for (String suffix : new List{ ' - English', ' - Spanish', ' - French', ' - Anglais', ' - Espagnol', ' - Français' }) { if (cleaned.endsWithIgnoreCase(suffix)) { cleaned = cleaned.left(cleaned.length() - suffix.length()); break; } } return cleaned.trim(); } private static List buildErrorResult(String errorMessage) { DocusignEnvelopeResult result = new DocusignEnvelopeResult(); result.success = false; result.errorMessage = errorMessage; result.envelopeId = null; return new List{ result }; } }