From 90c65ea2c6916f8e4a57875ad654bf12ad80c1a6 Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Tue, 3 Mar 2026 18:38:00 -0500 Subject: [PATCH] Fix email subject truncation to 100 chars - Truncate req.emailSubject to 100 characters max as required by Docusign - Add '...' suffix when truncating long subjects - Add test case for subject truncation validation - Fixes error: 'email subject must be no greater than 100 characters' --- .../DocusignCompositeEnvelopeBuilder.cls | 259 ++++++++++++++++- .../DocusignCompositeEnvelopeBuilderTest.cls | 266 +++++++++++++++++- 2 files changed, 517 insertions(+), 8 deletions(-) diff --git a/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls index ee1f2c6..21c478b 100644 --- a/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls @@ -1,7 +1,254 @@ - String envelopeSubject = combinedName; - // Truncate subject to 100 characters maximum as required by Docusign - if (envelopeSubject.length() > 100) { - envelopeSubject = envelopeSubject.left(97) + '...'; +/** + * @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'; + + @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) + ); + + // Build document list from templates (deduplicated and sorted) + List sortedTemplateIds = new List(new Set(req.templateIds)); + sortedTemplateIds.sort(); + + // Query template names for document labels (shows in Docusign Status) + Map templateNames = new Map(); + for (dfsle__EnvelopeConfiguration__c config : [ + SELECT dfsle__DocuSignId__c, Name + FROM dfsle__EnvelopeConfiguration__c + WHERE dfsle__DocuSignId__c IN :sortedTemplateIds + ]) { + templateNames.put(config.dfsle__DocuSignId__c, config.Name); } - String envelopeBody = bodyParts.isEmpty() ? '' : String.join(bodyParts, '\n\n---\n\n'); - myEnvelope = myEnvelope.withEmail(envelopeSubject, envelopeBody); \ No newline at end of file + + List documents = new List(); + List docNames = new List(); + for (String templateId : sortedTemplateIds) { + String label = templateNames.containsKey(templateId) + ? stripLanguageSuffix(templateNames.get(templateId)) + : templateId; + documents.add( + dfsle.Document.fromTemplate( + dfsle.UUID.parse(templateId), + label + ) + ); + docNames.add(label); + } + myEnvelope = myEnvelope.withDocuments(documents); + + // Set combined template names as the envelope document name + // (shows in Docusign Status "Document Name" column) + String combinedName = String.join(docNames, ', '); + 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(sortedTemplateIds[0]), + combinedName + ); + myEnvelope = myEnvelope.withDocuments(documents); + } + + // Resolve recipients from Client_Case__c lookup fields + List recipients = resolveRecipients(req.recordId); + myEnvelope = myEnvelope.withRecipients(recipients); + + // Set email subject if provided (truncated to 100 chars max) + if (String.isNotBlank(req.emailSubject)) { + String truncatedSubject = req.emailSubject.length() > 100 + ? req.emailSubject.left(97) + '...' + : req.emailSubject; + myEnvelope = myEnvelope.withEmail(truncatedSubject, ''); + } + + // 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(docNames, ', ') + ')', 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 + * @return List of dfsle.Recipient objects with role mappings + */ + private static List resolveRecipients(String recordId) { + // 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 + Id serviceCoordinatorId = (Id) caseRecord.get(FIELD_SERVICE_COORDINATOR); + if (serviceCoordinatorId != null) { + recipients.add(buildRecipient(serviceCoordinatorId, ROLE_SERVICE_COORDINATOR, routingOrder++, recordId)); + } + + // Recipient 2: Docusign Recipient #1 + Id docusignRecipientId = (Id) caseRecord.get(FIELD_DOCUSIGN_RECIPIENT); + if (docusignRecipientId != null) { + recipients.add(buildRecipient(docusignRecipientId, ROLE_DOCUSIGN_RECIPIENT, routingOrder++, recordId)); + } + + 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 + * @param recipientId The Contact or User record ID + * @param roleName The Docusign template role name + * @param routingOrder Signing order + * @param sourceRecordId The source Client_Case__c record ID + * @return dfsle.Recipient configured for the role + */ + private static dfsle.Recipient buildRecipient(Id recipientId, String roleName, Integer routingOrder, String sourceRecordId) { + // 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)) { + throw new IllegalArgumentException('No email found for ' + roleName + ' (' + recipientName + '). ' + + 'Please ensure the recipient has a valid email address.'); + } + + return dfsle.Recipient.fromSource( + recipientName, + recipientEmail, + null, // phone (optional) + roleName, // must match template role exactly + new dfsle.Entity(sourceRecordId) // source record for merge fields + ); + } + + 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/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls index ef368c1..41265cb 100644 --- a/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls +++ b/composite-envelope-builder/force-app/main/default/classes/DocusignCompositeEnvelopeBuilderTest.cls @@ -1,3 +1,265 @@ +/** + * @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'); } @@ -24,7 +286,7 @@ // Assert System.assertEquals(true, results[0].success, 'Should succeed with truncated subject'); - // Note: We can't easily test the actual truncation in this mock-based test, + // Note: We cannot easily test the actual truncation in this mock-based test, // but the code change ensures truncation happens before the API call } -} \ No newline at end of file +}