diff --git a/composite-envelope-builder/deploy/mdapi/README.md b/composite-envelope-builder/deploy/mdapi/README.md new file mode 100644 index 0000000..269e001 --- /dev/null +++ b/composite-envelope-builder/deploy/mdapi/README.md @@ -0,0 +1,24 @@ +MDAPI package for deploying Docusign Composite Envelope Builder + +Contents: +- `classes/` — Apex classes and test classes +- `flows/` — Flow file for DocuSign Envelope Templates (V4) +- `package.xml` — package manifest + +Usage (Salesforce CLI, Metadata API format): + +1. Ensure you have the Salesforce CLI installed and authenticated to the target org. + +2. Deploy the package directory (from repository root): + +```bash +# convert source if needed, or deploy mdapi directly +sfdx force:mdapi:deploy -d deploy/mdapi -u -w 10 +``` + +3. If you prefer ANT, zip the `deploy/mdapi` directory and use the Metadata API/ANT deployment procedure. + +Notes: +- The `flows` file included is the V4 flow file (`Docusign_Envelope_Templates_V4.flow-meta.xml`). The Flow API name is `DocuSign_Envelope_Templates` — ensure the Flow member name in `package.xml` matches the flow's API name. +- The implementation depends on the managed-package `dfsle` (Docusign for Salesforce) toolkit and its `dfsle__EnvelopeConfiguration__c` records. Ensure the managed package and its configuration/data exist in Production before invoking the Flow. +- This package intentionally excludes `Docusign_Configuration__c` (legacy metadata) — include it only if you intentionally want to keep that object in Production. diff --git a/composite-envelope-builder/deploy/mdapi/classes/DocusignCompositeEnvelopeBuilder.cls b/composite-envelope-builder/deploy/mdapi/classes/DocusignCompositeEnvelopeBuilder.cls new file mode 100644 index 0000000..0ac4e5b --- /dev/null +++ b/composite-envelope-builder/deploy/mdapi/classes/DocusignCompositeEnvelopeBuilder.cls @@ -0,0 +1,434 @@ +/** + * @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 }; + } +} diff --git a/composite-envelope-builder/deploy/mdapi/classes/DocusignCompositeEnvelopeBuilder.cls-meta.xml b/composite-envelope-builder/deploy/mdapi/classes/DocusignCompositeEnvelopeBuilder.cls-meta.xml new file mode 100644 index 0000000..651b172 --- /dev/null +++ b/composite-envelope-builder/deploy/mdapi/classes/DocusignCompositeEnvelopeBuilder.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + diff --git a/composite-envelope-builder/deploy/mdapi/classes/DocusignCompositeEnvelopeBuilderTest.cls b/composite-envelope-builder/deploy/mdapi/classes/DocusignCompositeEnvelopeBuilderTest.cls new file mode 100644 index 0000000..2e9331e --- /dev/null +++ b/composite-envelope-builder/deploy/mdapi/classes/DocusignCompositeEnvelopeBuilderTest.cls @@ -0,0 +1,376 @@ +/** + * @description Test class for DocusignCompositeEnvelopeBuilder (dfsle Apex Toolkit) + * @author Paul Huliganga + * @date 2026-02-25 + */ +@isTest +private class DocusignCompositeEnvelopeBuilderTest { + + @isTest + static void testSuccessfulCompositeEnvelope() { + // Arrange + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); + + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List{ + '01234567-abcd-ef01-2345-6789abcdef01', + '01234567-abcd-ef01-2345-6789abcdef02', + '01234567-abcd-ef01-2345-6789abcdef03' + }; + req.recordId = '001000000ABC123'; + req.language = 'en'; + req.emailSubject = 'Please sign these forms'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); + Test.stopTest(); + + // Assert + System.assertEquals(1, results.size(), 'Should return 1 result'); + System.assertEquals(true, results[0].success, 'Should be successful'); + System.assertNotEquals(null, results[0].envelopeId, 'Should have envelope ID'); + System.assertEquals(null, results[0].errorMessage, 'Should have no error'); + } + + @isTest + static void testSingleTemplate() { + // Arrange + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); + + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List{'01234567-abcd-ef01-2345-6789abcdef01'}; + req.recordId = '001000000ABC123'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); + Test.stopTest(); + + // Assert + System.assertEquals(true, results[0].success, 'Should succeed with 1 template'); + } + + @isTest + static void testMaximumTemplates() { + // Arrange + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); + + List templateIds = new List(); + for (Integer i = 1; i <= 14; i++) { + templateIds.add('01234567-abcd-ef01-2345-6789abcdef' + String.valueOf(i).leftPad(2, '0')); + } + + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = templateIds; + req.recordId = '001000000ABC123'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); + Test.stopTest(); + + // Assert + System.assertEquals(true, results[0].success, 'Should succeed with 14 templates'); + } + + @isTest + static void testDuplicateTemplatesDeduped() { + // Arrange + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); + + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List{ + '01234567-abcd-ef01-2345-6789abcdef01', + '01234567-abcd-ef01-2345-6789abcdef02', + '01234567-abcd-ef01-2345-6789abcdef01' // duplicate + }; + req.recordId = '001000000ABC123'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); + Test.stopTest(); + + // Assert + System.assertEquals(true, results[0].success, 'Should handle duplicates'); + } + + @isTest + static void testNullRequest() { + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(null); + Test.stopTest(); + + // Assert + System.assertEquals(false, results[0].success, 'Should fail with null request'); + System.assertEquals('No request provided', results[0].errorMessage, 'Should have error message'); + } + + @isTest + static void testEmptyRequest() { + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List() + ); + Test.stopTest(); + + // Assert + System.assertEquals(false, results[0].success, 'Should fail with empty request'); + } + + @isTest + static void testValidationNoTemplates() { + // Arrange + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List(); + req.recordId = '001000000ABC123'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); + Test.stopTest(); + + // Assert + System.assertEquals(false, results[0].success, 'Should fail validation'); + System.assert(results[0].errorMessage.containsIgnoreCase('template'), 'Should mention templates'); + } + + @isTest + static void testValidationTooManyTemplates() { + // Arrange + List templateIds = new List(); + for (Integer i = 1; i <= 15; i++) { + templateIds.add('01234567-abcd-ef01-2345-6789abcdef' + String.valueOf(i).leftPad(2, '0')); + } + + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = templateIds; + req.recordId = '001000000ABC123'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); + Test.stopTest(); + + // Assert + System.assertEquals(false, results[0].success, 'Should fail validation'); + System.assert(results[0].errorMessage.contains('Maximum 14'), 'Should mention limit'); + } + + @isTest + static void testValidationNoRecordId() { + // Arrange + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List{'01234567-abcd-ef01-2345-6789abcdef01'}; + req.recordId = ''; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); + Test.stopTest(); + + // Assert + System.assertEquals(false, results[0].success, 'Should fail validation'); + System.assert(results[0].errorMessage.contains('record ID'), 'Should mention record ID'); + } + + @isTest + static void testValidationBlankTemplateId() { + // Arrange + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List{'01234567-abcd-ef01-2345-6789abcdef01', ''}; + req.recordId = '001000000ABC123'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); + Test.stopTest(); + + // Assert + System.assertEquals(false, results[0].success, 'Should fail validation'); + System.assert(results[0].errorMessage.contains('blank'), 'Should mention blank'); + } + + @isTest + static void testWithEmailSubject() { + // Arrange + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); + + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List{'01234567-abcd-ef01-2345-6789abcdef01'}; + req.recordId = '001000000ABC123'; + req.emailSubject = 'Custom: Please review and sign'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); + Test.stopTest(); + + // Assert + System.assertEquals(true, results[0].success, 'Should succeed with custom subject'); + } + + @isTest + static void testWithoutEmailSubject() { + // Arrange + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); + + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List{'01234567-abcd-ef01-2345-6789abcdef01'}; + req.recordId = '001000000ABC123'; + req.emailSubject = null; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); + Test.stopTest(); + + // Assert + System.assertEquals(true, results[0].success, 'Should succeed without subject'); + } + + @isTest + static void testEmailSubjectTruncation() { + // Arrange + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); + + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + // Create multiple templates to make combined name > 100 chars + req.templateIds = new List{ + '01234567-abcd-ef01-2345-6789abcdef01', + '01234567-abcd-ef01-2345-6789abcdef02', + '01234567-abcd-ef01-2345-6789abcdef03', + '01234567-abcd-ef01-2345-6789abcdef04', + '01234567-abcd-ef01-2345-6789abcdef05' + }; + req.recordId = '001000000ABC123'; + + // Act + Test.startTest(); + List results = + DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope( + new List{req} + ); + Test.stopTest(); + + // Assert + System.assertEquals(true, results[0].success, 'Should succeed with truncated subject'); + } + + @isTest + static void testSmsDeliveryPath() { + // Arrange - create service coordinator (with email) and recipient (no email) + Contact sc = new Contact(LastName='SC', Email='sc@example.com'); + insert sc; + + Contact dr = new Contact(LastName='DR'); + insert dr; + + // Create a Client Case record linking the lookups used by resolver + Client_Case__c cc = new Client_Case__c(Name='Test Case SMS', Service_Coordinator__c = sc.Id, Docusign_Recipient_1__c = dr.Id); + insert cc; + + // Arrange request with SMS phone for recipient #1 + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List{'01234567-abcd-ef01-2345-6789abcdef01'}; + req.recordId = cc.Id; + req.recipientSmsPhone = '+15551234567'; + + // Act + Test.startTest(); + List results = DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + Test.stopTest(); + + // Assert - should succeed and produce an envelope id + System.assertEquals(true, results[0].success, 'SMS path should succeed'); + System.assertNotEquals(null, results[0].envelopeId, 'Should have envelope ID for SMS envelope'); + } + + @isTest + static void testSpanishGreetingPath() { + // Arrange - basic happy path but with language set to Spanish + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List{'01234567-abcd-ef01-2345-6789abcdef01'}; + req.recordId = '001000000ABC123'; + req.language = 'es'; + + // Act + Test.startTest(); + List results = DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + Test.stopTest(); + + // Assert - succeeded; language handling exercised (no exception) + System.assertEquals(true, results[0].success, 'Spanish language path should succeed'); + } + + @isTest + static void testMultiCopyExpansion() { + // Arrange - insert a dfsle configuration that matches the MULTI_COPY_TEMPLATE_NAME + // so the multi-copy expansion logic will detect and duplicate template IDs. + dfsle__EnvelopeConfiguration__c cfg = new dfsle__EnvelopeConfiguration__c( + Name = 'Authorization to Release Information - English', + dfsle__DocuSignId__c = '01234567-abcd-ef01-2345-6789abcdefMC', + dfsle__EmailMessage__c = 'Please sign this release.' + ); + insert cfg; + + // Create contacts and case for recipients + Contact sc = new Contact(LastName='SC', Email='sc@example.com'); + insert sc; + Contact dr = new Contact(LastName='DR', Email='dr@example.com'); + insert dr; + Client_Case__c cc = new Client_Case__c(Name='Test Case Multi', Service_Coordinator__c = sc.Id, Docusign_Recipient_1__c = dr.Id); + insert cc; + + dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock()); + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List{cfg.dfsle__DocuSignId__c}; + req.recordId = cc.Id; + req.authReleaseFormCopies = 3; + + // Act + Test.startTest(); + List results = DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List{req}); + Test.stopTest(); + + // Assert - ensure envelope creation succeeded when multi-copy expansion is requested + System.assertEquals(true, results[0].success, 'Multi-copy expansion should succeed'); + } +} diff --git a/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequest.cls b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequest.cls new file mode 100644 index 0000000..2fda242 --- /dev/null +++ b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequest.cls @@ -0,0 +1,49 @@ +/** + * @description Input parameters for DocusignCompositeEnvelopeBuilder invocable method + * @author Paul Huliganga + * @date 2026-02-25 + */ +global class DocusignEnvelopeRequest { + + @InvocableVariable( + label='Template IDs' + description='List of Docusign template IDs to combine' + required=true + ) + global List templateIds; + + @InvocableVariable( + label='Salesforce Record ID' + description='ID of the Salesforce record to attach documents to' + required=true + ) + global String recordId; + + @InvocableVariable( + label='Language' + description='Language code (en or es)' + required=false + ) + global String language; + + @InvocableVariable( + label='Email Subject' + description='Subject line for envelope email' + required=false + ) + global String emailSubject; + + @InvocableVariable( + label='Authorization to Release Form Copies' + description='Number of times to include the Authorization to Release Information template (1-5). Only used when that template is selected.' + required=false + ) + global Integer authReleaseFormCopies; + + @InvocableVariable( + label='Recipient SMS Phone' + description='Mobile phone number for SMS delivery when the primary recipient (Docusign Recipient #1) has no email address. Uses dfsle Recipient.withSmsDelivery(). A placeholder email is substituted automatically so the envelope can be created. Format: +15551234567 (E.164 preferred).' + required=false + ) + global String recipientSmsPhone; +} diff --git a/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequest.cls-meta.xml b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequest.cls-meta.xml new file mode 100644 index 0000000..f5e18fd --- /dev/null +++ b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 60.0 + Active + diff --git a/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequestHandler.cls b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequestHandler.cls new file mode 100644 index 0000000..24eaab5 --- /dev/null +++ b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequestHandler.cls @@ -0,0 +1,32 @@ +/** + * @description Validates request parameters for Docusign composite envelopes + * @author Paul Huliganga + * @date 2026-02-25 + */ +global with sharing class DocusignEnvelopeRequestHandler { + + /** + * @description Validates composite envelope request parameters + * @param req Request object to validate + * @throws IllegalArgumentException if validation fails + */ + public static void validateRequest(DocusignEnvelopeRequest req) { + if (req.templateIds == null || req.templateIds.isEmpty()) { + throw new IllegalArgumentException('At least one template ID is required'); + } + + if (req.templateIds.size() > 14) { + throw new IllegalArgumentException('Maximum 14 templates allowed per envelope'); + } + + if (String.isBlank(req.recordId)) { + throw new IllegalArgumentException('Salesforce record ID is required'); + } + + for (String templateId : req.templateIds) { + if (String.isBlank(templateId)) { + throw new IllegalArgumentException('Template ID cannot be blank'); + } + } + } +} diff --git a/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequestHandler.cls-meta.xml b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequestHandler.cls-meta.xml new file mode 100644 index 0000000..f5e18fd --- /dev/null +++ b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequestHandler.cls-meta.xml @@ -0,0 +1,5 @@ + + + 60.0 + Active + diff --git a/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequestHandlerTest.cls b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequestHandlerTest.cls new file mode 100644 index 0000000..e94aa9d --- /dev/null +++ b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequestHandlerTest.cls @@ -0,0 +1,80 @@ +/** + * @description Tests for DocusignEnvelopeRequestHandler + * @author Paul Huliganga + * @date 2026-02-25 + */ +@isTest +public class DocusignEnvelopeRequestHandlerTest { + + @isTest + static void testValidateRequest_Success() { + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List{ 'template1', 'template2' }; + req.recordId = '001xx000003DHf'; + + Test.startTest(); + DocusignEnvelopeRequestHandler.validateRequest(req); + Test.stopTest(); + + Assert.isTrue(true, 'Validation should pass'); + } + + @isTest + static void testValidateRequest_NoTemplateIds() { + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List(); + req.recordId = '001xx000003DHf'; + + try { + DocusignEnvelopeRequestHandler.validateRequest(req); + Assert.fail('Should throw IllegalArgumentException'); + } catch (IllegalArgumentException e) { + Assert.isTrue(e.getMessage().contains('At least one template ID'), 'Correct error'); + } + } + + @isTest + static void testValidateRequest_TooManyTemplates() { + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List(); + for (Integer i = 0; i < 15; i++) { + req.templateIds.add('template' + i); + } + req.recordId = '001xx000003DHf'; + + try { + DocusignEnvelopeRequestHandler.validateRequest(req); + Assert.fail('Should throw IllegalArgumentException'); + } catch (IllegalArgumentException e) { + Assert.isTrue(e.getMessage().contains('Maximum 14 templates'), 'Correct error'); + } + } + + @isTest + static void testValidateRequest_NoRecordId() { + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List{ 'template1' }; + req.recordId = ''; + + try { + DocusignEnvelopeRequestHandler.validateRequest(req); + Assert.fail('Should throw IllegalArgumentException'); + } catch (IllegalArgumentException e) { + Assert.isTrue(e.getMessage().contains('Salesforce record ID'), 'Correct error'); + } + } + + @isTest + static void testValidateRequest_BlankTemplateId() { + DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); + req.templateIds = new List{ 'template1', '', 'template3' }; + req.recordId = '001xx000003DHf'; + + try { + DocusignEnvelopeRequestHandler.validateRequest(req); + Assert.fail('Should throw IllegalArgumentException'); + } catch (IllegalArgumentException e) { + Assert.isTrue(e.getMessage().contains('Template ID cannot be blank'), 'Correct error'); + } + } +} diff --git a/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequestHandlerTest.cls-meta.xml b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequestHandlerTest.cls-meta.xml new file mode 100644 index 0000000..f5e18fd --- /dev/null +++ b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeRequestHandlerTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 60.0 + Active + diff --git a/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeResult.cls b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeResult.cls new file mode 100644 index 0000000..9cd5c53 --- /dev/null +++ b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeResult.cls @@ -0,0 +1,25 @@ +/** + * @description Output parameters for DocusignCompositeEnvelopeBuilder invocable method + * @author Paul Huliganga + * @date 2026-02-25 + */ +global class DocusignEnvelopeResult { + + @InvocableVariable( + label='Envelope ID' + description='Docusign envelope ID' + ) + global String envelopeId; + + @InvocableVariable( + label='Success' + description='True if envelope was created successfully' + ) + global Boolean success; + + @InvocableVariable( + label='Error Message' + description='Error message if envelope creation failed' + ) + global String errorMessage; +} diff --git a/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeResult.cls-meta.xml b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeResult.cls-meta.xml new file mode 100644 index 0000000..f5e18fd --- /dev/null +++ b/composite-envelope-builder/deploy/mdapi/classes/DocusignEnvelopeResult.cls-meta.xml @@ -0,0 +1,5 @@ + + + 60.0 + Active + diff --git a/composite-envelope-builder/deploy/mdapi/flows/Docusign_Envelope_Templates_V4.flow-meta.xml b/composite-envelope-builder/deploy/mdapi/flows/Docusign_Envelope_Templates_V4.flow-meta.xml new file mode 100644 index 0000000..115dacf --- /dev/null +++ b/composite-envelope-builder/deploy/mdapi/flows/Docusign_Envelope_Templates_V4.flow-meta.xml @@ -0,0 +1,62 @@ + + + + Send_Composite_Envelope + + 182 + 2498 + DocusignCompositeEnvelopeBuilder + apex + + Check_Envelope_Result + + Automatic + + templateIds + + compositeTemplateIds + + + + recordId + + recordId + + + + language + + Get_Records.Docusign_Envelope_Language__c + + + + authReleaseFormCopies + + authReleaseFormCopies + + + + recipientSmsPhone + + recipientSmsPhone + + + DocusignCompositeEnvelopeBuilder + 0 + + envelopeId + envelopeId + + + envelopeSuccess + success + + + envelopeErrorMessage + errorMessage + + + 60.0 + false + + diff --git a/composite-envelope-builder/deploy/mdapi/package.xml b/composite-envelope-builder/deploy/mdapi/package.xml new file mode 100644 index 0000000..991a43b --- /dev/null +++ b/composite-envelope-builder/deploy/mdapi/package.xml @@ -0,0 +1,17 @@ + + + + DocusignCompositeEnvelopeBuilder + DocusignCompositeEnvelopeBuilderTest + DocusignEnvelopeRequest + DocusignEnvelopeRequestHandler + DocusignEnvelopeRequestHandlerTest + DocusignEnvelopeResult + ApexClass + + + DocuSign_Envelope_Templates + Flow + + 60.0 +