465 lines
23 KiB
OpenEdge ABL
465 lines
23 KiB
OpenEdge ABL
/**
|
|
* @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';
|
|
|
|
// ============================================================
|
|
// DRAFT MODE / SENDER VIEW: When the primary recipient has no email
|
|
// and requiresDraftMode=true, the envelope is created in draft status.
|
|
// Docusign requires an email address on every recipient even in draft
|
|
// mode, so this placeholder satisfies the API requirement.
|
|
// The sender will replace this with the real SMS recipient details
|
|
// manually in the Docusign Sender View before sending.
|
|
// ============================================================
|
|
@TestVisible
|
|
private static final String DRAFT_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<DocusignEnvelopeResult> sendCompositeEnvelope(List<DocusignEnvelopeRequest> requests) {
|
|
List<DocusignEnvelopeResult> results = new List<DocusignEnvelopeResult>();
|
|
|
|
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<String> expandedTemplateIds = new List<String>(req.templateIds);
|
|
Integer copies = (req.authReleaseFormCopies != null && req.authReleaseFormCopies > 1)
|
|
? Math.min(req.authReleaseFormCopies, 3)
|
|
: 1;
|
|
if (copies > 1) {
|
|
// Find which template ID(s) correspond to the multi-copy template
|
|
List<String> multiCopyIds = new List<String>();
|
|
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<String> sortedTemplateIds = new List<String>(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<String, String> templateNames = new Map<String, String>();
|
|
Map<String, String> templateShortNames = new Map<String, String>();
|
|
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<dfsle.Document> documents = new List<dfsle.Document>();
|
|
List<String> docNames = new List<String>();
|
|
// Use a list of label+id pairs to correctly handle duplicate template IDs
|
|
// (e.g. multiple copies of Authorization to Release Information)
|
|
List<String[]> labelIdPairs = new List<String[]>();
|
|
Map<String, Integer> labelCounters = new Map<String, Integer>();
|
|
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<String, String> labelToId = new Map<String, String>();
|
|
for (String[] pair : labelIdPairs) {
|
|
labelToId.put(pair[0], pair[1]);
|
|
}
|
|
List<String> sortedIds = new List<String>();
|
|
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<String, Integer> baseNameCounts = new Map<String, Integer>();
|
|
List<String> baseNameOrder = new List<String>();
|
|
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<String> displayNames = new List<String>();
|
|
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<dfsle.Recipient> recipients = resolveRecipients(req.recordId, draftMode);
|
|
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<String> uniqueTemplateIds = new Set<String>(sortedIds);
|
|
Map<String, String> templateBodies = new Map<String, String>();
|
|
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<String> bodyIdsAdded = new Set<String>();
|
|
List<String> bodyParts = new List<String>();
|
|
for (String templateId : sortedIds) {
|
|
if (templateBodies.containsKey(templateId) && !bodyIdsAdded.contains(templateId)) {
|
|
bodyParts.add(templateBodies.get(templateId));
|
|
bodyIdsAdded.add(templateId);
|
|
}
|
|
}
|
|
String envelopeSubject = combinedName;
|
|
// Truncate subject to 100 characters maximum as required by Docusign
|
|
if (envelopeSubject.length() > 100) {
|
|
envelopeSubject = envelopeSubject.left(97) + '...';
|
|
}
|
|
String envelopeBody = bodyParts.isEmpty() ? '' : String.join(bodyParts, '\n\n');
|
|
myEnvelope = myEnvelope.withEmail(envelopeSubject, envelopeBody);
|
|
|
|
// Send the envelope — or create it as a draft when requiresDraftMode is true.
|
|
//
|
|
// Draft mode is used when the primary recipient has no email address and will
|
|
// need SMS delivery configured manually in the Docusign Sender View.
|
|
// dfsle.EnvelopeService.sendEnvelope(envelope, false) creates the envelope in
|
|
// "created" (draft) status without sending it. We then build the Sender View
|
|
// URL so the flow can present a direct link to the Docusign web console.
|
|
Boolean draftMode = (req.requiresDraftMode == true);
|
|
myEnvelope = dfsle.EnvelopeService.sendEnvelope(myEnvelope, !draftMode);
|
|
|
|
result.envelopeId = String.valueOf(myEnvelope.docuSignId);
|
|
result.success = true;
|
|
result.errorMessage = null;
|
|
|
|
if (draftMode) {
|
|
result.senderViewUrl = buildSenderViewUrl(result.envelopeId);
|
|
logResult(sortedTemplateIds.size(), result.envelopeId,
|
|
'Draft created — Sender View required (' + String.join(displayNames, ', ') + ')', null);
|
|
} else {
|
|
result.senderViewUrl = 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 draftMode When true, a missing email on the primary recipient is allowed —
|
|
* DRAFT_PLACEHOLDER_EMAIL is substituted so the dfsle call succeeds.
|
|
* The sender will update the recipient in the Docusign Sender View.
|
|
* @return List of dfsle.Recipient objects with role mappings
|
|
*/
|
|
private static List<dfsle.Recipient> resolveRecipients(String recordId, Boolean draftMode) {
|
|
// 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<dfsle.Recipient> recipients = new List<dfsle.Recipient>();
|
|
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, false));
|
|
}
|
|
|
|
// 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, draftMode));
|
|
}
|
|
|
|
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
|
|
* @param allowPlaceholderEmail When true and the recipient has no email, substitutes
|
|
* DRAFT_PLACEHOLDER_EMAIL so the dfsle call succeeds.
|
|
* Only set true for the primary recipient in draft mode.
|
|
* @return dfsle.Recipient configured for the role
|
|
*/
|
|
private static dfsle.Recipient buildRecipient(Id recipientId, String roleName, Integer routingOrder, String sourceRecordId, Boolean allowPlaceholderEmail) {
|
|
// 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 (allowPlaceholderEmail == true) {
|
|
// Draft mode — substitute placeholder so the envelope can be created.
|
|
// The sender will update this recipient's details in the Docusign Sender View.
|
|
recipientEmail = DRAFT_PLACEHOLDER_EMAIL;
|
|
} else {
|
|
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 Builds the Docusign Sender View URL for a draft envelope.
|
|
* The URL opens the envelope in the Docusign web console so the sender
|
|
* can configure SMS delivery and send it manually.
|
|
*
|
|
* URL format:
|
|
* Production: https://app.docusign.com/documents/details/{envelopeId}
|
|
* Demo/sandbox: https://app.docusign.net/documents/details/{envelopeId}
|
|
*
|
|
* Base_URL__c on the Docusign_Configuration__c custom setting stores the
|
|
* callout base URL (e.g. https://demo.docusign.net/restapi or
|
|
* https://na3.docusign.net/restapi). We derive the web console host from
|
|
* that so the link always points to the correct environment.
|
|
*
|
|
* @param envelopeId The Docusign envelope ID (GUID) of the draft envelope
|
|
* @return Full Sender View URL string
|
|
*/
|
|
@TestVisible
|
|
private static String buildSenderViewUrl(String envelopeId) {
|
|
// Determine the web console host from Base_URL__c.
|
|
// Base_URL__c looks like: https://demo.docusign.net/restapi
|
|
// or: https://na3.docusign.net/restapi
|
|
// We extract the scheme + host and append the documents/details path.
|
|
String webHost = 'https://app.docusign.com'; // default: production
|
|
try {
|
|
Docusign_Configuration__c config = Docusign_Configuration__c.getInstance();
|
|
if (config != null && String.isNotBlank(config.Base_URL__c)) {
|
|
String baseUrl = config.Base_URL__c.trim();
|
|
// Strip trailing path components to get just scheme://host
|
|
Integer restApiIdx = baseUrl.toLowerCase().indexOf('/restapi');
|
|
if (restApiIdx > 0) {
|
|
baseUrl = baseUrl.left(restApiIdx);
|
|
}
|
|
// Replace API host with web console host:
|
|
// demo.docusign.net → app.docusign.net (sandbox/demo)
|
|
// *.docusign.net → app.docusign.net (other NA/EU pods)
|
|
// app.docusign.com → app.docusign.com (production)
|
|
if (baseUrl.containsIgnoreCase('demo.docusign')) {
|
|
webHost = 'https://app.docusign.net';
|
|
} else if (baseUrl.containsIgnoreCase('.docusign.net')) {
|
|
webHost = 'https://app.docusign.net';
|
|
} else {
|
|
webHost = 'https://app.docusign.com';
|
|
}
|
|
}
|
|
} catch (Exception ex) {
|
|
// If config lookup fails, fall back to production URL
|
|
System.debug(LoggingLevel.WARN, 'Could not read Base_URL__c for Sender View URL: ' + ex.getMessage());
|
|
}
|
|
return webHost + '/documents/details/' + envelopeId;
|
|
}
|
|
|
|
/**
|
|
* @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<String>{
|
|
' - 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<DocusignEnvelopeResult> buildErrorResult(String errorMessage) {
|
|
DocusignEnvelopeResult result = new DocusignEnvelopeResult();
|
|
result.success = false;
|
|
result.errorMessage = errorMessage;
|
|
result.envelopeId = null;
|
|
return new List<DocusignEnvelopeResult>{ result };
|
|
}
|
|
}
|