Compare commits

..

29 Commits

Author SHA1 Message Date
Paul Huliganga 73da6aed28 test: use SeeAllData=true with pinned record IDs and fix testMultiCopyExpansion UUID 2026-04-06 20:32:16 -04:00
Paul Huliganga d77f14b205 docs: add DocuSign template migration guides (sandbox-to-production, DAL method) and generated PDFs 2026-04-03 11:46:43 -04:00
Paul Huliganga d70cd8a97a docs: add change-set checklist, production deployment guide, and previous design archive 2026-04-03 11:46:32 -04:00
Paul Huliganga 1286925765 docs: update QUICK_START, deployment, and design documentation 2026-04-03 11:46:24 -04:00
Paul Huliganga dece2b6569 feat: add MDAPI deployment package with Apex classes, flow, and package.xml 2026-04-03 11:46:16 -04:00
Paul Huliganga 0b5372a976 feat: add md_to_pdf.py Markdown-to-PDF conversion utility 2026-04-03 11:46:09 -04:00
Paul Huliganga 2eac94f719 test: add SMS, i18n, and multi-copy tests for composite envelope builder 2026-03-25 11:13:21 -04:00
Paul Huliganga e8fb53f476 chore: remove legacy backup Apex file 2026-03-25 10:42:07 -04:00
Paul Huliganga c4f519fdb9 docs(design): add updated design document reflecting V4, SMS, i18n, subject and 5-copy support 2026-03-25 10:21:12 -04:00
Paul Huliganga 3ddf8946f5 docs: update docs for SMS, i18n, subject prefix, and 5-copy support 2026-03-24 16:03:39 -04:00
Paul Huliganga 2e4ce0d80e chore(flow): update Docusign_Envelope_Templates_V4 — include Active version retrieved from org 2026-03-24 15:43:01 -04:00
Paul Huliganga 26f7067cb5 fix(i18n): detect Spanish language variants for flow greeting/signoff 2026-03-24 15:37:38 -04:00
Paul Huliganga 148c965a16 chore(flow): update Docusign_Envelope_Templates_V4 — include SMS phone assignment fix and 5-copy choices 2026-03-24 14:41:19 -04:00
Paul Huliganga a81d5788c7 feat(i18n): add Spanish greeting/signoff when language='es'; update authReleaseFormCopies doc 2026-03-24 13:52:58 -04:00
Paul Huliganga a3ebafe506 feat: prefix envelope subject with 'Docusign: ' 2026-03-24 11:10:56 -04:00
Paul Huliganga 06e2a14de5 feat: update email intro text; allow up to 5 copies of Authorization to Release form; update flows and docs 2026-03-24 10:58:37 -04:00
Paul Huliganga d88834926e docs: document SMS Contact setup requirement and SMS_PLACEHOLDER_EMAIL constant location 2026-03-15 21:22:55 -04:00
Paul Huliganga 82befe6fbd fix(flow): rename InputField to smsPhoneInput to avoid duplicate dev name with variable 2026-03-13 23:21:23 -04:00
Paul Huliganga e6ea5b8554 fix(flow): use same-name InputField for auto-binding to recipientSmsPhone variable 2026-03-13 23:17:32 -04:00
Paul Huliganga a7474e45c8 fix(flow): correct InputField screen component and assignment reference
- Remove storeOutputAutomatically from InputField (not supported on that fieldType)
- Fix assignment reference to use screen-qualified path
  SMS_Phone_Screen.recipientSmsPhone_Input instead of bare recipientSmsPhone_Input
2026-03-13 23:09:34 -04:00
Paul Huliganga 5b68f341ef style: shorten email body divider line from 60 to 40 characters 2026-03-13 16:06:35 -04:00
Paul Huliganga 91ff48d6e0 fix(flow): resolve duplicate developer name on SMS phone screen field
Screen InputField name cannot match a flow variable name. Renamed field to
recipientSmsPhone_Input, added Store_SMS_Phone assignment element to copy
the field value into the recipientSmsPhone variable after the screen.
2026-03-13 11:43:43 -04:00
Paul Huliganga a9768ef553 fix(flow): replace unsupported outputParameters with storeOutputAutomatically on SMS phone input field
InputField screen components do not support <outputParameters>. Replaced with
<storeOutputAutomatically>true</storeOutputAutomatically> and renamed the field
to match the target variable (recipientSmsPhone) so the value is stored directly.
2026-03-13 11:41:15 -04:00
Paul Huliganga faf53d0777 fix(flow): move recipientSmsPhone variable into contiguous variables group in V4
Variable was appended after <choices> at end of file, creating a second
<variables> block and causing 'Element variables is duplicated' deploy error.
Moved into the existing variables group before <choices>.
2026-03-13 11:37:14 -04:00
Paul Huliganga 29442efdc2 fix(flow): consolidate all recordLookups and screens into single groups in V4
Get_Recipient_Contact was in a separate <recordLookups> block before <environments>
and SMS_Phone_Screen was in a separate <screens> block before <environments>,
both causing 'Element ... is duplicated' deploy errors. Moved both into their
respective contiguous element groups alongside the existing V3 elements.
2026-03-13 11:34:07 -04:00
Paul Huliganga eee5289de9 fix(flow): move Is_Recipient_Email_Blank into contiguous decisions group in V4
Salesforce Flow XML requires all elements of the same type to be grouped
together. Is_Recipient_Email_Blank was in a separate <decisions> block after
<recordLookups>, causing 'Element decisions is duplicated' deploy error.
Moved it into the existing decisions group and removed the orphaned block.
2026-03-13 11:29:13 -04:00
Paul Huliganga 3f3fe8dd71 feat: add greeting, dividers, and sign-off to envelope email body
- Prepend 'Hello,' greeting before first template body
- Separate template bodies with a blank line + horizontal rule + blank line
- Append 'Thank you, / Early Intervention Colorado' after last template body
- Greeting + sign-off are included even when no templates supply a body
2026-03-13 11:15:30 -04:00
Paul Huliganga 71a156df78 docs: update to v1.2 with SMS delivery feature documentation
- design.md v1.2: add section 2.5 (SMS delivery via dfsle.Recipient.withSmsDelivery());
  document SMS_PLACEHOLDER_EMAIL constant, resolveRecipients/buildRecipient signatures,
  Flow V4 structure and decision paths; update component diagram and Request class docs
- requirements.md v1.2: add FR-007 (SMS delivery for recipients without email),
  US-006 user story; update constraints and success criteria
- api-reference.md v1.2: add section 11 (SMS delivery via dfsle toolkit);
  document recipientSmsPhone parameter, withSmsDelivery() usage pattern,
  Flow V4 integration steps; renumber Reference Links to section 12
2026-03-13 10:05:42 -04:00
Paul Huliganga e41e43cabd feat(sms): SMS delivery via dfsle withSmsDelivery() for recipients without email
- Add recipientSmsPhone InvocableVariable to DocusignEnvelopeRequest
- Add SMS_PLACEHOLDER_EMAIL constant to DocusignCompositeEnvelopeBuilder
- Update resolveRecipients() and buildRecipient() to accept smsPhone param
- Chain .withSmsDelivery(smsPhone) on recipient when smsPhone is provided
- Substitute placeholder email when recipient has no email and SMS phone given
- Add Flow V4 with Get_Recipient_Contact lookup, Is_Recipient_Email_Blank
  decision, and SMS_Phone_Screen to collect phone for no-email recipients
- V3 left untouched for existing Salesforce deployments
2026-03-13 09:57:28 -04:00
37 changed files with 4029 additions and 1077 deletions

View File

@ -4,23 +4,22 @@
All Apex classes and tests have been created! Here's what you have: All Apex classes and tests have been created! Here's what you have:
### 📦 Code Files Created (6 classes) ### 📦 Code Files Included in This Package
**Main Classes:** **Main Classes:**
1. `DocusignCompositeEnvelopeBuilder.cls` (11.5 KB) - Invocable method for Flow integration 1. `DocusignCompositeEnvelopeBuilder.cls` - Invocable method for Flow integration (uses dfsle toolkit)
2. `DocusignAPIService.cls` (10.5 KB) - REST API service layer 2. `DocusignEnvelopeRequest.cls` - Invocable request parameter object
3. `DocusignCredentials.cls` (5.9 KB) - Credential management 3. `DocusignEnvelopeRequestHandler.cls` - Request validation helper
4. `DocusignEnvelopeResult.cls` - Invocable result object
**Test Classes:** **Test Classes:**
4. `DocusignCompositeEnvelopeBuilderTest.cls` (13.8 KB) - 12 test methods - `DocusignCompositeEnvelopeBuilderTest.cls`
5. `DocusignAPIServiceTest.cls` (11.7 KB) - 14 test methods - `DocusignEnvelopeRequestHandlerTest.cls`
6. `DocusignCredentialsTest.cls` (8.0 KB) - 13 test methods
**Total:** 39 test methods for comprehensive coverage! **Notes on legacy artifacts:**
- The repository contains a legacy `Docusign_Configuration__c` custom object and older REST-helper classes in docs, but the active implementation uses the `dfsle` (DocuSign for Salesforce managed package) toolkit and `dfsle__EnvelopeConfiguration__c` records. The legacy custom setting/object is not required for the current Flow + Apex approach.
**Metadata:** **All `.cls-meta.xml` files** use API version 60/61 as shown in each file.
- Custom Setting: `Docusign_Configuration__c` with `Account_Id__c` and `Base_URL__c` fields
- All `.cls-meta.xml` files (API version 61.0)
--- ---
@ -99,25 +98,18 @@ Pass Rate: 100%
Code Coverage: 85%+ Code Coverage: 85%+
``` ```
### Step 7: Configure Docusign Credentials ### Step 7: DocuSign Integration / Credentials (note)
1. **Create Named Credential** (see docs/deployment-guide.md section 3.1) - The current implementation uses the `dfsle` Apex Toolkit from the DocuSign for Salesforce managed package to send composite envelopes. That toolkit stores and references its templates/configuration using managed-package objects such as `dfsle__EnvelopeConfiguration__c`.
- Setup → Named Credentials → New Named Credential
- Name: `DocusignAPI`
- URL: `https://na3.docusign.net/restapi/v2.1` (or your data center)
2. **Configure Custom Settings** - You do NOT need to create the legacy Named Credential / custom setting (`Docusign_Configuration__c`) for the dfsle-based flow. Ensure the `DocuSign for Salesforce (dfsle)` managed package is installed and its envelope configurations/templates exist in Production.
- Setup → Custom Settings → Docusign Configuration → Manage
- Click "New" (organization-wide default)
- Account Id: `{Your Docusign Account GUID}`
- Base URL: `callout:DocusignAPI`
- Save
3. **Add Remote Site Settings** - If you intentionally plan to use the older REST-based helper classes (legacy), then follow the steps below **only**:
- Setup → Remote Site Settings → New Remote Site 1. Create a Named Credential (Setup → Named Credentials → New)
- Name: `Docusign_API` - Example name: `DocusignAPI`
- URL: `https://na3.docusign.net` - Example URL: `https://na3.docusign.net/restapi/v2.1`
- Active: ✓ 2. (Optional legacy) Configure `Docusign_Configuration__c` custom setting/object with `Account_Id__c` and `Base_URL__c` if your older REST helpers require it.
3. Add Remote Site or appropriate callout permissions for external DocuSign endpoints.
### Step 8: Update Screen Flow ### Step 8: Update Screen Flow
@ -132,6 +124,7 @@ See `docs/deployment-guide.md` section 5 for detailed Flow configuration steps.
- `recordId``{!recordId}` - `recordId``{!recordId}`
- `language``{!SelectedLanguage}` - `language``{!SelectedLanguage}`
- `emailSubject` → "Please review and sign these forms" - `emailSubject` → "Please review and sign these forms"
- Note: The Apex layer will prefix the subject with `Docusign: ` automatically and will truncate the final subject to 100 characters to satisfy Docusign limits.
5. Store outputs: 5. Store outputs:
- `envelopeId``{!EnvelopeId}` - `envelopeId``{!EnvelopeId}`
- `success``{!Success}` - `success``{!Success}`
@ -235,6 +228,12 @@ All docs are in the `docs/` folder:
- Replace the loop that sends individual envelopes - Replace the loop that sends individual envelopes
- Add the Apex Action in place of the loop - Add the Apex Action in place of the loop
**SMS Delivery Setup (Flow V4):**
- Use **Flow V4** (`Docusign_Envelope_Templates_V4`) when recipients may not have an email address
- The `Docusign_Recipient_1__c` field on the Client Case **must be populated** with a Contact — a null lookup causes the recipient to be omitted from the envelope entirely
- To trigger SMS delivery: link the Contact to the case but **leave the Contact's Email field blank** — the flow detects this and prompts the operator for a mobile number
- The placeholder email (`placeholder_email@docusign.com`) substituted by Apex is defined as the constant `SMS_PLACEHOLDER_EMAIL` in `DocusignCompositeEnvelopeBuilder.cls`**update the constant there** if the address ever needs to change
--- ---
## ❓ Need Help? ## ❓ Need Help?

View File

@ -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 <prod-org-alias> -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.

View File

@ -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<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, 5)
: 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, 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<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);
}
}
// 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<dfsle.Recipient> 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<dfsle.Recipient> recipients = new List<dfsle.Recipient>();
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<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 };
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>61.0</apiVersion>
<status>Active</status>
</ApexClass>

View File

@ -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<String>{
'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<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{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<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = '001000000ABC123';
// Act
Test.startTest();
List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{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<String> templateIds = new List<String>();
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<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{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<String>{
'01234567-abcd-ef01-2345-6789abcdef01',
'01234567-abcd-ef01-2345-6789abcdef02',
'01234567-abcd-ef01-2345-6789abcdef01' // duplicate
};
req.recordId = '001000000ABC123';
// Act
Test.startTest();
List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{req}
);
Test.stopTest();
// Assert
System.assertEquals(true, results[0].success, 'Should handle duplicates');
}
@isTest
static void testNullRequest() {
// Act
Test.startTest();
List<DocusignEnvelopeResult> 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<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>()
);
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<String>();
req.recordId = '001000000ABC123';
// Act
Test.startTest();
List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{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<String> templateIds = new List<String>();
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<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{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<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = '';
// Act
Test.startTest();
List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{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<String>{'01234567-abcd-ef01-2345-6789abcdef01', ''};
req.recordId = '001000000ABC123';
// Act
Test.startTest();
List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{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<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = '001000000ABC123';
req.emailSubject = 'Custom: Please review and sign';
// Act
Test.startTest();
List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{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<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = '001000000ABC123';
req.emailSubject = null;
// Act
Test.startTest();
List<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{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<String>{
'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<DocusignEnvelopeResult> results =
DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(
new List<DocusignEnvelopeRequest>{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<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = cc.Id;
req.recipientSmsPhone = '+15551234567';
// Act
Test.startTest();
List<DocusignEnvelopeResult> results = DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignEnvelopeRequest>{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<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = '001000000ABC123';
req.language = 'es';
// Act
Test.startTest();
List<DocusignEnvelopeResult> results = DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignEnvelopeRequest>{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<String>{cfg.dfsle__DocuSignId__c};
req.recordId = cc.Id;
req.authReleaseFormCopies = 3;
// Act
Test.startTest();
List<DocusignEnvelopeResult> results = DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignEnvelopeRequest>{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');
}
}

View File

@ -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<String> 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;
}

View File

@ -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');
}
}
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>60.0</apiVersion>
<status>Active</status>
</ApexClass>

View File

@ -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<String>{ '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<String>();
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<String>();
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<String>{ '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<String>{ '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');
}
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>60.0</apiVersion>
<status>Active</status>
</ApexClass>

View File

@ -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;
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>60.0</apiVersion>
<status>Active</status>
</ApexClass>

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<Flow xmlns="http://soap.sforce.com/2006/04/metadata">
<actionCalls>
<name>Send_Composite_Envelope</name>
<label>Send Composite Envelope</label>
<locationX>182</locationX>
<locationY>2498</locationY>
<actionName>DocusignCompositeEnvelopeBuilder</actionName>
<actionType>apex</actionType>
<connector>
<targetReference>Check_Envelope_Result</targetReference>
</connector>
<flowTransactionModel>Automatic</flowTransactionModel>
<inputParameters>
<name>templateIds</name>
<value>
<elementReference>compositeTemplateIds</elementReference>
</value>
</inputParameters>
<inputParameters>
<name>recordId</name>
<value>
<elementReference>recordId</elementReference>
</value>
</inputParameters>
<inputParameters>
<name>language</name>
<value>
<elementReference>Get_Records.Docusign_Envelope_Language__c</elementReference>
</value>
</inputParameters>
<inputParameters>
<name>authReleaseFormCopies</name>
<value>
<elementReference>authReleaseFormCopies</elementReference>
</value>
</inputParameters>
<inputParameters>
<name>recipientSmsPhone</name>
<value>
<elementReference>recipientSmsPhone</elementReference>
</value>
</inputParameters>
<nameSegment>DocusignCompositeEnvelopeBuilder</nameSegment>
<offset>0</offset>
<outputParameters>
<assignToReference>envelopeId</assignToReference>
<name>envelopeId</name>
</outputParameters>
<outputParameters>
<assignToReference>envelopeSuccess</assignToReference>
<name>success</name>
</outputParameters>
<outputParameters>
<assignToReference>envelopeErrorMessage</assignToReference>
<name>errorMessage</name>
</outputParameters>
</actionCalls>
<apiVersion>60.0</apiVersion>
<areMetricsLoggedToDataCloud>false</areMetricsLoggedToDataCloud>
<!-- ...existing flow content... -->
</Flow>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<Package xmlns="http://soap.sforce.com/2006/04/metadata">
<types>
<members>DocusignCompositeEnvelopeBuilder</members>
<members>DocusignCompositeEnvelopeBuilderTest</members>
<members>DocusignEnvelopeRequest</members>
<members>DocusignEnvelopeRequestHandler</members>
<members>DocusignEnvelopeRequestHandlerTest</members>
<members>DocusignEnvelopeResult</members>
<name>ApexClass</name>
</types>
<types>
<members>DocuSign_Envelope_Templates</members>
<name>Flow</name>
</types>
<version>60.0</version>
</Package>

View File

@ -0,0 +1,26 @@
Change Set Checklist — Docusign Composite Envelope Builder
Add these components to your Outbound Change Set in the Sandbox:
- Flow
- Flow (component type: `Flow` / `Flow Definition`)
- `DocuSign_Envelope_Templates` (use the V4 Flow version)
- Apex Classes (component type: `Apex Class`)
- DocusignCompositeEnvelopeBuilder
- DocusignEnvelopeRequest
- DocusignEnvelopeRequestHandler
- DocusignEnvelopeResult
- DocusignCompositeEnvelopeBuilderTest (test class)
- DocusignEnvelopeRequestHandlerTest (test class)
- Apex Triggers: None (this package does not include any triggers)
- Custom Objects / Fields: None required
- Note: The repository contains a leftover `Docusign_Configuration__c` object with `Account_Id__c` and `Base_URL__c`, but the current implementation uses the `dfsle` managed-package objects (`dfsle__EnvelopeConfiguration__c`) and does not reference `Docusign_Configuration__c`. Do not include `Docusign_Configuration__c` unless you explicitly want to preserve it.
Post-deploy checklist in Production:
- Assign permission sets (if any) to users.
- Test Flow run via the relevant record page.
- Run Apex test classes and verify overall code coverage.
- Verify dfsle templates are present in Production (managed-package data/configuration).

View File

@ -132,11 +132,12 @@ sf apex run test --class-names DocusignCompositeEnvelopeBuilderTest --wait 10 --
# Test the request handler # Test the request handler
sf apex run test --class-names DocusignEnvelopeRequestHandlerTest --wait 10 --result-format human --code-coverage --target-org dev-org sf apex run test --class-names DocusignEnvelopeRequestHandlerTest --wait 10 --result-format human --code-coverage --target-org dev-org
# Test the API service # Legacy REST-helper tests (optional)
sf apex run test --class-names DocusignAPIServiceTest --wait 10 --result-format human --code-coverage --target-org dev-org # The REST-based `DocusignAPIService` and `DocusignCredentials` tests are part of a
# legacy implementation and are not required for the current dfsle-based Flow.
# Test the credentials helper # Run these only if you have intentionally included the older REST helper classes in your package:
sf apex run test --class-names DocusignCredentialsTest --wait 10 --result-format human --code-coverage --target-org dev-org # sf apex run test --class-names DocusignAPIServiceTest --wait 10 --result-format human --code-coverage --target-org dev-org
# sf apex run test --class-names DocusignCredentialsTest --wait 10 --result-format human --code-coverage --target-org dev-org
``` ```
PowerShell: PowerShell:
@ -357,14 +358,14 @@ After successful deployment, configure your Salesforce org:
- **Salesforce Record ID** — {!Record.Id} - **Salesforce Record ID** — {!Record.Id}
- **Language** — en or es - **Language** — en or es
- **Email Subject** — Optional custom subject - **Email Subject** — Optional custom subject
- **Authorization to Release Form Copies** — Number of copies (13) for the Authorization to Release Information template; populated by the `authReleaseFormCopies` flow variable (defaults to 1) - **Authorization to Release Form Copies** — Number of copies (15) for the Authorization to Release Information template; populated by the `authReleaseFormCopies` flow variable (defaults to 1)
4. Output Variables: 4. Output Variables:
- **Envelope ID** — Unique Docusign envelope ID - **Envelope ID** — Unique Docusign envelope ID
- **Success** — Boolean (true/false) - **Success** — Boolean (true/false)
- **Error Message** — Error details if creation failed - **Error Message** — Error details if creation failed
5. Save and test the flow 5. Save and test the flow
> **Multi-copy dialog**: If you are using `Docusign_Envelope_Templates_V3`, the flow automatically detects when "Authorization to Release Information" is selected and displays a radio-button screen asking for 1, 2, or 3 copies before sending. No additional Flow configuration is required for this feature. > **Multi-copy dialog**: If you are using `Docusign_Envelope_Templates_V3`, the flow automatically detects when "Authorization to Release Information" is selected and displays a radio-button screen asking for 15 copies before sending. No additional Flow configuration is required for this feature.
--- ---

View File

@ -1,8 +1,8 @@
# API Reference # API Reference
**Project**: Salesforce Composite Envelope Builder **Project**: Salesforce Composite Envelope Builder
**Version**: 1.1 **Version**: 1.2
**Date**: February 23, 2026 (updated March 11, 2026) **Date**: February 23, 2026 (updated March 13, 2026)
--- ---
@ -50,7 +50,7 @@ Accept: application/json
```json ```json
{ {
"status": "sent", "status": "sent",
"emailSubject": "Please review and sign these forms", "emailSubject": "Docusign: Please review and sign these forms",
"compositeTemplates": [ "compositeTemplates": [
{ {
"compositeTemplateId": "1", "compositeTemplateId": "1",
@ -74,6 +74,14 @@ Accept: application/json
} }
``` ```
### 2.5 Email subject & body composition
The Apex layer composes the outgoing email subject and body when building the envelope. Important rules:
- Subject: Prefixed with "Docusign: " to make the source clear to recipients. The subject is truncated to 100 characters to satisfy Docusign limits.
- Body: Consists of a greeting, followed by combined template bodies separated by a visual divider, followed by a sign-off. The system supports English (default) and Spanish; the Flow `language` input accepts locale codes (e.g. `es`, `es-CO`) and common strings like `Spanish`/`Español` to select Spanish greetings/signoffs.
#### Advanced Example (with merge fields) #### Advanced Example (with merge fields)
```json ```json
@ -515,7 +523,108 @@ Body:
--- ---
## 11. Reference Links ## 11. SMS Delivery via dfsle Apex Toolkit (v1.2)
### 11.1 Overview
Rather than calling the Docusign REST API directly for SMS delivery, this project uses the native **dfsle Apex Toolkit** method `dfsle.Recipient.withSmsDelivery()`, which is part of the Docusign for Salesforce managed package already installed in the org.
This approach requires no additional REST endpoints, no extra authentication, and no HTTP callouts beyond what the toolkit already handles.
### 11.2 Salesforce Data Setup Prerequisite
Before SMS delivery can work, the Client Case record must be set up correctly:
| Field | Requirement |
|-------|-------------|
| `Docusign_Recipient_1__c` | **Must be populated** — set to a Contact record |
| Contact `Name` | **Must be populated** — used as the recipient's display name in Docusign |
| Contact `Email` | **Must be blank** — the flow checks this field to route through the SMS path |
> ⚠️ **Important**: If `Docusign_Recipient_1__c` is null (no Contact linked at all), the Apex code skips that recipient entirely and Docusign Recipient #1 will be missing from the envelope. The Contact must exist — only the email needs to be absent.
### 11.3 Placeholder Email Constant
The Docusign API requires an `email` field on every recipient even when SMS delivery is configured. When the recipient contact has no email address, the Apex layer substitutes a placeholder automatically — **no actual email is sent to this address**.
> 🔧 **Configuration**: The placeholder address is defined as a single constant in `DocusignCompositeEnvelopeBuilder.cls`. **This is the only place that needs to be updated if the placeholder address ever changes.**
```apex
// ============================================================
// 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.
// ============================================================
@TestVisible
private static final String SMS_PLACEHOLDER_EMAIL = 'placeholder_email@docusign.com';
```
**Method signature**:
```apex
dfsle.Recipient withSmsDelivery(String phone)
```
**Parameters**:
| Parameter | Type | Description |
|-----------|------|-------------|
| `phone` | `String` | Mobile phone number in E.164 format (e.g. `+15551234567`). International numbers must include the country code. |
**Returns**: A new `dfsle.Recipient` instance with SMS delivery configured (`deliverBySms = true`).
**Important notes**:
- This method sets the delivery method for the signing invitation; it is **not** a 2FA/authentication method.
- The Docusign API still requires an `email` field on every recipient even when SMS delivery is configured. When the recipient contact has no email address, a placeholder (`placeholder_email@docusign.com`) is substituted automatically by the Apex layer. No actual email is sent to this address.
- Only applied to **Docusign Recipient #1**. The Service Coordinator always uses email delivery.
### 11.4 Invocable Action Parameter
The `recipientSmsPhone` input parameter on the `Send_Composite_Envelope` Apex action activates SMS delivery:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `recipientSmsPhone` | `String` | No | Mobile phone number for SMS delivery. When blank (or not provided), the envelope is sent normally by email. E.164 format preferred: `+15551234567`. |
### 11.5 Apex Usage Pattern
```apex
// Resolve email from Contact record
String recipientEmail = contact.Email;
// Substitute placeholder when email is blank and SMS phone is provided.
// SMS_PLACEHOLDER_EMAIL is a constant in DocusignCompositeEnvelopeBuilder.cls —
// update the constant there if the placeholder address ever needs to change.
if (String.isBlank(recipientEmail) && String.isNotBlank(smsPhone)) {
recipientEmail = SMS_PLACEHOLDER_EMAIL;
}
// Build recipient using dfsle toolkit
dfsle.Recipient recipient = dfsle.Recipient.fromSource(
contact.Name,
recipientEmail,
null, // phone param (not used here)
'Docusign Recipient #1', // role name — must match template exactly
new dfsle.Entity(caseRecordId) // source record for merge fields
);
// Enable SMS delivery when phone is provided
if (String.isNotBlank(smsPhone)) {
recipient = recipient.withSmsDelivery(smsPhone);
}
```
### 11.6 Flow V4 Integration
In `Docusign_Envelope_Templates_V4`, the flow checks the recipient contact's email before presenting the send screen:
1. **`Get_Recipient_Contact`** — queries the Contact linked to `Docusign_Recipient_1__c`
2. **`Is_Recipient_Email_Blank`** — if email is null or empty, routes to the phone collection screen; otherwise proceeds normally
3. **`SMS_Phone_Screen`** — collects the mobile number (required text field); stores result in the `recipientSmsPhone` flow variable
4. **`Send_Composite_Envelope`** action — receives `recipientSmsPhone` as an input; when non-blank the Apex layer applies `withSmsDelivery()`
---
## 12. Reference Links
- [Docusign REST API Reference](https://developers.docusign.com/docs/esign-rest-api/reference/) - [Docusign REST API Reference](https://developers.docusign.com/docs/esign-rest-api/reference/)
- [Composite Templates Guide](https://developers.docusign.com/docs/esign-rest-api/how-to/request-signature-composite-template/) - [Composite Templates Guide](https://developers.docusign.com/docs/esign-rest-api/how-to/request-signature-composite-template/)
@ -525,5 +634,14 @@ Body:
--- ---
**Document Version**: 1.0 **Document Version**: 1.2
**Last Updated**: February 23, 2026 **Last Updated**: March 15, 2026
**Change Log**:
| Version | Date | Summary |
|---------|------|---------|
| 1.0 | 2026-02-23 | Initial release |
| 1.1 | 2026-03-11 | No changes (version aligned with design/requirements) |
| 1.2 | 2026-03-13 | Added section 11 — SMS delivery via `dfsle.Recipient.withSmsDelivery()`; documented `recipientSmsPhone` parameter; renumbered Reference Links to section 12 |
| 1.2 | 2026-03-15 | Added 11.2 Contact setup prerequisite; added 11.3 placeholder email constant callout; renumbered 11.311.5 to 11.411.6; updated code example to reference `SMS_PLACEHOLDER_EMAIL` constant |

View File

@ -0,0 +1,88 @@
# Deploying Flow and APEX Code to Salesforce Production
This guide provides step-by-step instructions for moving your Flow and APEX code from a sandbox to a Salesforce production organization **using Salesforce's built-in tools** (no local source code required).
## Prerequisites
- You have access to both the sandbox and production Salesforce orgs.
- You have System Administrator permissions in both orgs.
- The Flow and APEX code are already deployed and tested in the sandbox.
---
## 1. Use Change Sets to Deploy Metadata
### A. Create an Outbound Change Set in Sandbox
1. **Login to your Sandbox**.
2. Go to **Setup** > **Change Sets** > **Outbound Change Sets**.
3. Click **New** to create a new change set (e.g., "Docusign Composite Envelope Builder").
4. Add a description for clarity.
5. Click **Save**.
### B. Add Components to the Change Set
1. In the change set, click **Add** under "Change Set Components".
2. **Add the Flow(s):**
- Component Type: `Flow Definition` or `Flow`.
- Select the active Flow version for the application. For this repository the Flow to include is `Docusign_Envelope_Templates_V4`.
3. **Add APEX Classes:**
- Component Type: `Apex Class`.
- Include the following Apex classes from the package:
- `DocusignCompositeEnvelopeBuilder`
- `DocusignEnvelopeRequest`
- `DocusignEnvelopeRequestHandler`
- `DocusignEnvelopeResult`
- Also include associated test classes:
- `DocusignCompositeEnvelopeBuilderTest`
- `DocusignEnvelopeRequestHandlerTest`
4. **Apex Triggers:**
- This package does not include any Apex Triggers. There are no trigger files in `force-app/main/default/triggers` to add to the change set.
5. **Custom Objects / Fields:**
- There are no custom objects or custom fields that must be deployed for the current implementation. The repository does contain a `Docusign_Configuration__c` object (with `Account_Id__c` and `Base_URL__c`) in `force-app/main/default/objects`, but it is not referenced by the APEX classes or the Flow (the implementation uses the `dfsle` toolkit and `dfsle__EnvelopeConfiguration__c` templates). You do not need to include `Docusign_Configuration__c` or its fields in the change set unless you intentionally want to preserve that leftover metadata.
6. Click **Add to Change Set** after each selection.
### C. Add Profiles/Permission Sets (Optional)
- If your app uses custom permissions, add the relevant profiles or permission sets.
### D. Upload the Change Set to Production
1. In the change set, click **Upload**.
2. Select your production org as the target.
3. Click **Upload**.
---
## 2. Deploy the Change Set in Production
### A. Login to Production
1. **Login to your Production Salesforce org**.
2. Go to **Setup** > **Change Sets** > **Inbound Change Sets**.
### B. Validate and Deploy
1. Find the uploaded change set.
2. Click the change set name to review components.
3. Click **Validate** to run tests and check for errors.
4. If validation passes, click **Deploy**.
5. Monitor deployment status and resolve any errors if needed.
---
## 3. Post-Deployment Steps
- **Assign Permission Sets/Profiles** to users as needed.
- **Test the Flow and APEX functionality** in production.
- **Review Custom Settings/Metadata** for environment-specific values (e.g., API keys, URLs).
---
## Troubleshooting
- If deployment fails, review error messages for missing dependencies or test failures.
- Ensure all referenced components (objects, fields, flows, classes) are included in the change set.
- For large or complex deployments, consider deploying in smaller batches.
---
## References
- [Salesforce Change Sets Documentation](https://help.salesforce.com/s/articleView?id=sf.changesets.htm)
- [Deploy Flows with Change Sets](https://help.salesforce.com/s/articleView?id=sf.flow_deploy.htm)
- [Deploy Apex Code with Change Sets](https://help.salesforce.com/s/articleView?id=sf.code_deploy_changeset.htm)
---
**Contact your Salesforce administrator or partner for additional support if needed.**

View File

@ -0,0 +1,93 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 9 0 R /MediaBox [ 0 0 612 792 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/Contents 10 0 R /MediaBox [ 0 0 612 792 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
>>
endobj
7 0 obj
<<
/Author (anonymous) /CreationDate (D:20260330153955-04'00') /Creator (anonymous) /Keywords () /ModDate (D:20260330153955-04'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (unspecified) /Title (untitled) /Trapped /False
>>
endobj
8 0 obj
<<
/Count 2 /Kids [ 4 0 R 5 0 R ] /Type /Pages
>>
endobj
9 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1874
>>
stream
Gatm<D/\/e&H88.EQFIm7\a(oALIF1^e(#t5dqMg4)BEJS#.T</L>/-PgoU'420o)M&f7Gco+qh'CY@7Si\"R6Qc@'jsNk'DJ!j5kCZ!L=Tn`F)o:kc]Uta^o`=hhH@=^uj*KO*>6H1<%.X2tcJGgbMf"ueiJrgd+DD,1cgUme>7iX3"l'd:<$W`#k:lc;Zeg_Er4i]aAtPn*$_#*[D&@UchnL60nGMn$0./qGStp[jn^TKB&-Q(sM8T9`R9e]\2e_?^CUFc"G[c;nD.3\7,/u`#.3@tWJ]_,=Z.`OJ:P_r79,k#89G%oE66N_2OO):H)N@:F"NXmd`oDW&6/d/@nO(2a)?B%V*'>A)R\]I<`;HOb/!P3^S,T3k<Q%#7Lf<(tE7O9jr%&T[SHjT'RL;_I.*M\iRA_G/]8.a@kNC^N[uEpJ#0=&n[2[8^?STaJ7Ol]Ol:N\WjiGE=s%%$W@,dUOPK@\r`oG5*02]N>4>7nB18QfL/!4`mh_6H=dM+WbBs<:*W>CX:#VW^!G(@7E?u8quGeZ[A-85mm3/pe%,Y(.49I@#B7\H[_'Dt^99dVHDE^hiQB8?>?`.)#I&oJjL9nS/>"9jqJUK2,hCGWiAn^1<(&Em#sR]t,'.Lp2K(lfSZ^'A![KA-i,f*EMB%E#WJ_/CkP+j!;GXTnd/N!l6fBQ^Pcq)2\&?!j'lV]OV;E]G>2_P\SbM#r7/"4uJe>N"3hH][^D<PLr#fD?uIVMj*@k1oTT>r=GW:E8d)%t5+:_d3j`0/Dlg5N?g;#Igu`;g!cR^q(O1LchQlET>s0,J(YrW'_:tp'B/o;2FE5N-T4-%@k-G'RT.,`UbY&K^N195(-gDA.3@[_hLjVmc]Ub65l,kIcOqZ@NAiJ-n=9$1_G/%:ekPkD`dDM[*/\rZM2umG+a"\I"'b]DetL>Js<+?:3<bl]_jFg)dX'?demMM5u#K@`5H8@_'".S$msr?;$D)mVd49]3lMUl]>*m<]@d$Tc-P;@H<p9GeGa4/XNdct@CFG5>?I=0qMn"scF#'dfXU>.Z?'nLRn0's4l_L`-@LJ9g4S_Q%ns!$Rc_456Scm\iPD'f`+_du;^4VH/,ZbRifs!XgbSqFhc2\p*m:kM(1j0kAh;%`eu;jN1$Y@DX,(3+X;t(s;2V=tM"TL7lgH-CR^!scJ+m4ED-T/t%i0<*6mOa4Tk?dRS_EGt7&n#J#lRNLltsdf#$&LR)Gt?PO^#6BP%<0<QX(hTFo/b"B2j.0SUSD;'W7#;:>6M-D77d\YBZH*,EcqCd&#,"hl>VjEuq`a\h]aq<E25Ug]WUnT(DTa<sl1<hM,n"*qn?&D0??\mE31%4$`aA1%_O"!BZaQkF%:\C[BF(gR6s%Jnm/`.sC:]6@hiV'm\&F\qMLDH%=KIBrCtFLBb@!;2j*P4gcoe?0Ph5@op6dhHVBQ:+nsM&X;a\gIX!A/Qj1Jin:uk[CQ-6\HWu)gHt#S*C$[?iH_B['(05K_dag,;lunD'==\I6+@Ku<+o>W'+3kpE_(-U<ij#6Pt*Y_Ur6bU^WZK'`T*40B^<rbI=l;Tjb@=rn7s;.1_/_RO[elPOQNsiJhC?-3'KRHq.@D)Z9kIRid@@O5(r'8I2cGJ&B,Dl^O4N]oCI]+?:_ljZ?ubT![WYPN>VE%Xbp#^#[pIWU:%;%MS5q!A'k<j`g?LhIXI"a'\Z0_0>g>#[PYa/q2Q%p#5d5gZF0-PWq#3tMKq5Ni6;>q%6kQ(b#66r2Y5GAj)u5=&g:2MPiClT2NHBos3:^"IJ@B6WT/nio.<(f=%)/m'na^U;bTA/X&@6;>i6/"M7Zi5Xj3q+a7[\hC<O?*!T:)16ofR%KPiIO4j%M.2giH`^Bn5D$3~>endstream
endobj
10 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1524
>>
stream
Gatm;@8GoC&H0nXR#anYVG3MTkt-(H[_N6,eKO.Z:>2:?-?35X-?^(bYl+A;gIW<VckI$(F]0Z+hO?IaM$IPgo?@61!;NpOd?t62%uUk/^^X[k!N1hXF5>`"h.n&D"u)UdYUJGIgkFcFL!*)!/>mN18ZRG7aTRB7W[',[r)5*C7e5["+Y8)Z_Wa;sHu)(G54l:aI:-u*J=#,7qXB('VC:^fSS)mpl3^'b67o0iUm=Ue:j,^Y,9Ri<>#VA5:2^G?:c;iL<5"hFOcTC,_eu0f@lH6\S@J'rbQY7bo/5JWW^Qt8[!l_[BVF9OUaYpT:sXi:D?(=XLb+Q/a]\0B*66(Z3)=5TH@BDtoj&i)dmR?,bDl`u[fR`_YThVTWkBRmW1bnb@K&U+@rouHk5u7_%_0&li^3`T(n4M8f3PBX!uST=G"DSDAT^<Ol2G[s(uLo52]R1NaFhO*rLh#qO*kbGG23>14iKLR'i*dYTjnlk`!W8dIj]s/CuZe]l!R3!2ct#nY[B4`,ugdkIn,4/[+5/^ciB)YMV"]b^gFV.,XF=icM<HuGmY(2ftTX:^D6:&$f=M#;PGQNI'C#oqhR6"M0R%Mm/XOK1JN4]\Z]baXAdB0aMU=L:("#?XQV72GE@WT'V+_GI;B]r*'C@C<\DUP`i.9%<+=2*l6U5'HdE<hX%H@g"&=7H(L[9ci79)dUQn5V,N#j^S#6,n(NdY,?2,U';]':b*`eq#+;Ib]j\er)nltL=qh>Qa$ok)N]U(]W0PO0dYD.7q^mhcA?OEbrSo!Dt<j\*<p7BAWm!=^un)unr[%#"_7_9I4iQX:$3Q<jsL$eKjGVEmq#&>AEU/>,W2P+YF.'2ZpTVcQs$T+n5R'U&QR7&-(T)K+#bnE1D0SR6h9n.5em2]*c%oTF^ju?GN<p+X(6'W/E.lmGm'u:M*(m1dII`j4W[3\#ZOsV_iM(Y!\lC!RA/,'>6SAR"+O2^\_l9[*6?hWaf-b@;A\/OecM*En\0.KiV?n0bpk_Su?W'?2%Mq[jY/F(R$6\O?5__0E=rf"'$F_giN.=*D@P[oLECAj-Mq+BO3GtNA!m(B8YTXh\SY-G07r+S89jCZjiQ\YYfcS<Y&<Z$HQ9=bn"2@qf3EAg0*)5DJW5LJ_mGFBki49U73?p)PHI1@tf-pA.]KoK-:5Ht')14Wf>km[7/p$eo6e\lM)]9ad;":'1;d^F/]KHLS(TI>'TL+#0=jNG"+rLWQW#KGM4IG\hrF4Fl1hq%/5ZNa[<hWB):p3)AKDYi4ZfQo@'oCk]@TQDB@>3g+Pj8YDeek24uO3qW8o?ro[;dIW"8<-6;=H_-O=@[kIoM:mceii/4C9<A%YD%Cj@5NLGZfufmaQjTpiJsm[6h^=;K1WsZbOGpc]#K2$5JE08A4RK*9eF-?&kc^e;EBc3(Vbr>Jk-?k+'gq"Y^K\kZ_0$7/me0rrrkGu%`Z%EcaBsF4nk,H@%9eg&O""qGBOC^A,Y^WNA(%MT&q0]Z4rShc#/QjM\Zg%#D;_sDZ~>endstream
endobj
xref
0 11
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000000321 00000 n
0000000514 00000 n
0000000708 00000 n
0000000776 00000 n
0000001037 00000 n
0000001102 00000 n
0000003067 00000 n
trailer
<<
/ID
[<ee950e95b19116a21bdbd23f3647697f><ee950e95b19116a21bdbd23f3647697f>]
% ReportLab generated PDF document -- digest (opensource)
/Info 7 0 R
/Root 6 0 R
/Size 11
>>
startxref
4683
%%EOF

View File

@ -212,11 +212,13 @@ Deploying v60.0 metadata to my-sandbox using the v60.0 SOAP API
Status: Succeeded Status: Succeeded
Component Deployed: Component Deployed:
ApexClass DocusignCompositeEnvelopeBuilder ApexClass DocusignCompositeEnvelopeBuilder
ApexClass DocusignAPIService ApexClass DocusignEnvelopeRequest
ApexClass DocusignCredentials ApexClass DocusignEnvelopeRequestHandler
ApexClass DocusignEnvelopeResult
ApexClass DocusignCompositeEnvelopeBuilderTest ApexClass DocusignCompositeEnvelopeBuilderTest
ApexClass DocusignAPIServiceTest ApexClass DocusignEnvelopeRequestHandlerTest
ApexClass DocusignCredentialsTest
Note: The older `DocusignAPIService` / `DocusignCredentials` classes and their tests are part of a legacy REST-based implementation. The active implementation uses the `dfsle` toolkit and does not require those classes; include them only if you intentionally retain the legacy REST helpers.
``` ```
### 4.4 Run Unit Tests ### 4.4 Run Unit Tests

View File

@ -1,199 +1,232 @@
# Design Document # Design — Composite Envelope Builder (Updated)
**Project**: Salesforce Composite Envelope Builder **Project**: Salesforce Composite Envelope Builder
**Version**: 1.1 **Version**: 2.0
**Date**: February 23, 2026 (updated March 11, 2026) **Date**: 2026-03-25
**Author**: Paul Huliganga **Author**: (auto-generated; update author/owner as needed)
--- ---
## 1. Architecture Overview ## 1. Summary
### 1.1 System Context This document describes the current implementation of the Composite Envelope Builder: how Screen Flows, Apex, and the dfsle toolkit collaborate to build and send a single DocuSign envelope composed from multiple DocuSign templates. It reflects the latest code and metadata in the repository (March 2026), including:
``` - Flow V3 (existing, email-only path) and Flow V4 (new, SMS collection path when recipient email is blank)
┌──────────────────┐ - Apex invocable: `DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope` and supporting classes
│ Salesforce User │ - SMS delivery support for recipients without an email (via `dfsle.Recipient.withSmsDelivery()`)
└────────┬─────────┘ - Multi-copy support for the "Authorization to Release Information" template (up to 5 copies)
- Email subject & body composition rules: prefix `Docusign: `, truncation, greeting/signoff handling and Spanish detection
- Key constants: `SMS_PLACEHOLDER_EMAIL`, `MULTI_COPY_TEMPLATE_NAME`
┌─────────────────────────────────────────┐
│ Salesforce Platform │
│ ┌──────────────┐ ┌───────────────┐ │
│ │ Screen Flow │───▶│ Apex Class │ │
│ │ (Template │ │ (Composite │ │
│ │ Selection) │ │ Builder) │ │
│ └──────────────┘ └───────┬───────┘ │
│ │ │
└──────────────────────────────┼───────────┘
▼ HTTPS/REST API
┌──────────────────────┐
│ Docusign REST API │
│ (Composite │
│ Templates) │
└──────────────────────┘
```
### 1.2 Component Architecture This is the canonical design doc for developers, release engineers, and reviewers.
``` ---
┌────────────────────────────────────────────────────────┐
│ Salesforce Org │ ## 2. Architecture Overview
│ │
│ ┌─────────────────────────────────────────────────┐ │ High-level flow:
│ │ Presentation Layer (Screen Flow) │ │
│ │ - Language selection │ │ - User launches Screen Flow (Flow V3 or V4)
│ │ - Template display (checkbox collection) │ │ - User selects language (English/Spanish) and templates
│ │ - Success/error messaging │ │ - Flow optionally collects SMS phone when recipient email is blank (V4)
│ └──────────────────┬──────────────────────────────┘ │ - Flow invokes Apex action `Send Composite Docusign Envelope` with inputs
│ │ │ - Apex builds compound envelope JSON using selected templates (expands multi-copy templates as configured)
│ ┌─────────────────▼──────────────────────────────┐ │ - Apex uses dfsle toolkit to send envelope; returns envelopeId and success/error
│ │ Business Logic Layer (Apex) │ │
│ │ │ │ Components:
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ DocusignCompositeEnvelopeBuilder │ │ │ - Screen Flows: `Docusign_Envelope_Templates_V3` (email path), `Docusign_Envelope_Templates_V4` (collect SMS phone when needed)
│ │ │ - @InvocableMethod entry point │ │ │ - Apex:
│ │ │ - Input validation │ │ │ - `DocusignCompositeEnvelopeBuilder.cls` (invocable entrypoint + implementation)
│ │ │ - Multi-copy template expansion │ │ │ - `DocusignEnvelopeRequest.cls` (invocable request contract)
│ │ │ - Composite JSON construction │ │ │ - `DocusignEnvelopeResult.cls` (invocable result contract)
│ │ │ - Envelope ID return │ │ │ - `DocusignAPIService.cls`, `DocusignCredentials.cls` (service & credential management)
│ │ └──────────────────┬──────────────────────┘ │ │ - DocuSign: Composite Templates API (via dfsle toolkit integration)
│ │ │ │ │
│ │ ┌─────────────────▼──────────────────────┐ │ │ ---
│ │ │ DocusignAPIService │ │ │
│ │ │ - API authentication │ │ │ ## 3. Flow Behavior (V3 and V4)
│ │ │ - HTTP callout construction │ │ │
│ │ │ - Response parsing │ │ │ ### 3.1 Shared behavior (V3 & V4)
│ │ │ - Error handling │ │ │
│ │ └──────────────────┬──────────────────────┘ │ │ - Language selection screen: user selects `English` or `Spanish`.
│ │ │ │ │ - Template selection screen: multi-select list of templates filtered by selected language.
│ │ ┌─────────────────▼──────────────────────┐ │ │ - After selection, a loop scans selected templates to detect the multi-copy template ("Authorization to Release Information").
│ │ │ DocusignCredentials │ │ │ - If detected, a copies screen appears (radio 15) that sets `authReleaseFormCopies`.
│ │ │ - Credential retrieval │ │ │ - Flow gathers `compositeTemplateIds` (selected template IDs) and passes them to Apex action.
│ │ │ - Token management │ │ │
│ │ └─────────────────────────────────────────┘ │ │ ### 3.2 Flow V3
│ └──────────────────────────────────────────────┘ │
│ │ - Used when standard email delivery is expected.
│ ┌──────────────────────────────────────────────┐ │ - No SMS collection screen is present.
│ │ Data Layer │ │ - Behavior is unchanged from previous releases except choices include 15 copies.
│ │ - Named Credential (Docusign API creds) │ │
│ │ - Custom Settings (configuration) │ │ ### 3.3 Flow V4 (SMS-aware)
│ └──────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────┘ - Added pre-send recipient lookup path:
- `Get_Records` (Client_Case__c) → `Get_Recipient_Contact` (Contact lookup)
▼ HTTPS REST API - `Is_Recipient_Email_Blank` decision checks Contact.Email
┌──────────────────────┐ - If email is blank → `SMS_Phone_Screen` (field `smsPhoneInput` required, E.164 format recommended) → `Store_SMS_Phone` assignment assigns to `recipientSmsPhone` variable → continue
│ Docusign Platform │ - If email present → skip SMS_Phone_Screen and continue
└──────────────────────┘ - The `recipientSmsPhone` variable is passed into the Apex action; when non-blank the Apex sets up SMS delivery for Docusign Recipient #1.
- Flow element names to reference in code and docs: `SMS_Phone_Screen`, `smsPhoneInput`, `Store_SMS_Phone`, `recipientSmsPhone`.
Note: Flow metadata must keep element blocks contiguous (assignments, decisions, screens, etc.) to avoid deployment errors. The committed `Docusign_Envelope_Templates_V4.flow-meta.xml` already follows these rules.
---
## 4. Apex Implementation Details
### 4.1 Invocable contract (`DocusignEnvelopeRequest`)
Key input fields (invocable variables):
- `List<String> templateIds` (required) — list of selected DocuSign template IDs
- `String recordId` (required) — Salesforce Client Case Id
- `String language` (optional) — e.g., `en`, `es`, `Spanish`, `Español` (language detection is normalized in Apex)
- `String emailSubject` (optional) — optional override; Apex still prefixes with `Docusign: ` and truncates to 100 chars
- `Integer authReleaseFormCopies` (optional) — 15; counts for Authorization template
- `String recipientSmsPhone` (optional) — when provided, Apex will enable SMS delivery for Docusign Recipient #1
Outputs:
- `String envelopeId`
- `Boolean success`
- `String errorMessage`
### 4.2 Main behavior (`DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope`)
1. Validate inputs; throw friendly errors if invalid.
2. Expand multi-copy templates:
- Find template IDs whose `Name` contains the constant `MULTI_COPY_TEMPLATE_NAME`.
- If `authReleaseFormCopies` > 1, append (copies - 1) additional entries for each matched template ID.
- Cap copies to 5 (Math.min(..., 5)).
3. Build document list (documents = fromTemplate(templateId, label)) and label duplicates as ` (Copy N)` to preserve uniqueness.
4. Build deduplicated display names for subject/body (merge repeated templates into count markers like `X (3)`).
5. Compose envelope subject & body (see section 5 for rules).
6. Resolve recipients via `resolveRecipients(recordId, recipientSmsPhone)`:
- Query Client_Case__c for lookup fields
- For Docusign Recipient #1: if linked Contact has no Email and `recipientSmsPhone` is provided, set recipientEmail to `SMS_PLACEHOLDER_EMAIL` and call `recipient.withSmsDelivery(smsPhone)`.
7. Use dfsle toolkit to build envelope with documents and recipients and call send.
8. Return `envelopeId` and `success`.
### 4.3 Important constants
- `SMS_PLACEHOLDER_EMAIL` (defined in `DocusignCompositeEnvelopeBuilder.cls`): placeholder email used when recipient has no email; required by DocuSign API even for SMS deliveries.
- `MULTI_COPY_TEMPLATE_NAME` (defined in same class): base name used to identify the Authorization to Release Information template (covers English + Spanish variations via LIKE).
### 4.4 i18n detection
- The code normalizes `req.language` to lowercase and treats as Spanish if any of the following are true:
- `lang.startsWith('es')` (e.g. `es`, `es-CO`)
- `lang.contains('spanish')`
- `lang.contains('espa')` (to capture `español` / `espanol`)
- On Spanish detection, Apex uses Spanish greeting/signoff; otherwise English is used.
---
## 5. Email Subject & Body Composition Rules
- Subject:
- Prefix: `Docusign: ` is prepended to the subject to make the source explicit.
- Truncation: After prefixing, subject is truncated to a maximum of 100 characters (Docusign requirement). The code uses `left(97) + '...'` if longer.
- If user supplies `emailSubject`, prefix and truncation still apply.
- Body:
- Built as: GREETING + (template bodies joined by DIVIDER) + SIGNOFF
- DIVIDER: `"\n\n" + '─'.repeat(40) + "\n\n"` (visual separator)
- Greeting/Sign-off: English default; Spanish strings used when language detection matches Spanish variants. Examples:
- English greeting: `Hello,\n\nPlease complete the DocuSign signature request from Early Intervention Colorado.\n\n`
- Spanish greeting: `Hola,\n\nPor favor, firme la solicitud de DocuSign de parte de Intervención Temprana Colorado.\n\n`
---
## 6. Multi-copy Behavior
- Single constant drives detection: `MULTI_COPY_TEMPLATE_NAME = 'Authorization to Release Information'`.
- Flow shows a copies screen when the template is present; operator selects 15 copies.
- Apex expands the templateId list before building documents to include multiple entries; duplicates are intentionally kept so each copy becomes a separate document in the envelope.
- Document labels: duplicates are labeled with ` (Copy N)` so they appear distinct in DocuSign Status.
---
## 7. SMS Delivery Details
- Trigger: Flow V4 routes to `SMS_Phone_Screen` if the Contact linked by `Docusign_Recipient_1__c` has empty `Email`.
- The screen collects `smsPhoneInput` which is assigned to `recipientSmsPhone` variable and passed to Apex.
- In Apex, when `recipientSmsPhone` is non-blank for Docusign Recipient #1:
- If recipient record has no email, set `recipientEmail = SMS_PLACEHOLDER_EMAIL`.
- Call `recipient = recipient.withSmsDelivery(smsPhone)` to enable SMS delivery via dfsle toolkit.
- Service Coordinator recipient always uses email delivery (unchanged).
- Important setup requirement (documented): `Docusign_Recipient_1__c` on the Client Case must be populated with a Contact. The Contact must have `Name` and (for SMS flow) may have blank `Email`. If the Contact is null, the recipient will be omitted and envelope will be missing that signer.
---
## 8. Flows & Metadata Notes
- Flow element naming conventions are important (screen fields bind by their `name`). For the SMS flow, the screen field is `smsPhoneInput` and the assignment must use `<elementReference>smsPhoneInput</elementReference>` (not `ScreenName.smsPhoneInput`).
- Flow metadata must keep element blocks contiguous (screens, assignments, recordLookups, decisions, loops, choices) to avoid Salesforce Flow XML validation failures. The repo's V4 flow file was normalized accordingly.
---
## 9. Tests, Validation & CI
- Unit tests: ensure `DocusignCompositeEnvelopeBuilderTest` covers:
- English and Spanish subject/body composition
- Multi-copy expansion with 15 copies
- SMS flow path: when Contact.Email blank + recipientSmsPhone provided, mailbox placeholder used and `withSmsDelivery` applied
- Error branches and API failures
- CI: add a PR validation job that does:
- sfdx auth to a validation org
- For changed flow or Apex files, run `sfdx force:source:deploy --checkonly` for those components
- Fail PR if check-only returns errors
- Pre-deploy: run check-only deploy to target org before actual deploy. Example local command:
```bash
sfdx force:source:deploy -p force-app/main/default/flows/Docusign_Envelope_Templates_V4.flow-meta.xml,force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls -u dev-org --checkonly --testlevel NoTestRun
``` ```
--- ---
## 2. Detailed Component Design ## 10. Edge Cases and Known Gotchas
### 2.1 DocusignCompositeEnvelopeBuilder (Main Class) - If operator edits a flow in the org and does not retrieve+commit the changes, repo and org diverge. Use mdapi `retrieve` or `sf project retrieve` to pull the authoritative version and commit immediately.
- Flow Builder sometimes reorders or normalizes XML: prefer re-checking contiguity of element blocks after Flow Builder saves.
**Purpose**: Invocable Apex class that combines multiple Docusign templates into a single envelope - Ensure the Flow variable `recipientSmsPhone` is passed to Apex action and that the assignment uses `smsPhoneInput` as elementReference.
- The DocuSign API requires an email field on every recipient even when SMS is used; `SMS_PLACEHOLDER_EMAIL` is central and must be kept updated in Apex if changed.
**Responsibilities**:
- Receive template IDs from Screen Flow
- Validate inputs
- Expand multi-copy templates (e.g. Authorization to Release Information)
- Construct composite template JSON
- Delegate API call to service class
- Return envelope ID to Flow
**Methods**:
```apex
// Flow-invocable entry point
@InvocableMethod(
label='Send Composite Docusign Envelope'
description='Combines multiple templates into one envelope'
)
public static List<Result> sendCompositeEnvelope(List<Request> requests)
// Private helper methods
private static String buildCompositeEnvelopeJSON(
List<String> templateIds,
String recordId,
String language,
Map<String, String> customFields
)
private static void validateInputs(Request req)
private static List<String> sortTemplatesAlphabetically(List<String> templateIds)
```
**Inner Classes**:
```apex
public class Request {
@InvocableVariable(required=true label='Template IDs')
public List<String> templateIds;
@InvocableVariable(required=true label='Salesforce Record ID')
public String recordId;
@InvocableVariable(required=false label='Language')
public String language; // 'en' or 'es'
@InvocableVariable(required=false label='Email Subject')
public String emailSubject;
@InvocableVariable(required=false label='Authorization to Release Form Copies')
public Integer authReleaseFormCopies; // 13; only used when that template is selected
@InvocableVariable(required=false label='Custom Fields')
public Map<String, String> customFields; // For merge fields
}
public class Result {
@InvocableVariable(label='Envelope ID')
public String envelopeId;
@InvocableVariable(label='Success')
public Boolean success;
@InvocableVariable(label='Error Message')
public String errorMessage;
}
```
--- ---
### 2.2 DocusignAPIService (Service Class) ## 11. Deployment Checklist
**Purpose**: Handles all Docusign REST API interactions 1. Confirm `force-app/main/default/flows/Docusign_Envelope_Templates_V4.flow-meta.xml` matches the active version in org (if you intend to deploy active draft)
2. Confirm `force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls` and related classes/tests are committed
3. Run `sfdx force:source:deploy --checkonly` for affected components against a validation org
4. Run Apex tests (or allow CI to run tests during deploy)
5. Deploy to sandbox, perform a manual Flow Builder inspection of V4
6. Activate the desired Flow version if runtime activation is needed
**Responsibilities**: ---
- Construct HTTP requests
- Make callouts to Docusign API
- Parse responses
- Handle errors and retries
- Log API interactions
**Methods**: ## 12. Change Log (high level)
```apex - 2.0 (2026-03-25): Updated design to reflect:
public static String createCompositeEnvelope( - Flow V4 (SMS path) retrieval & Active flow committed
String envelopeJSON, - Spanish greeting/signoff and broadened language detection
DocusignCredentials creds - Envelope subject prefix `Docusign: ` and truncation to 100 chars
) - Increased Authorization multi-copy support to 5 copies
- Flow element and metadata normalization
public static HttpResponse callDocusignAPI( ---
String endpoint,
String method,
String body,
Map<String, String> headers
)
public static String parseEnvelopeId(HttpResponse response) ## 13. Contact & Ownership
- Primary developer: Paul Huliganga (update as appropriate)
- Repo: `composite-envelope-builder`
- For deployment questions: refer to `docs/DEPLOYMENT_AND_TESTING.md`
---
*This document was generated to represent the repository state as of March 25, 2026. Update the change log and ownership fields when you make future changes.*
private static void logAPICall(
HttpRequest req,
HttpResponse res, HttpResponse res,
Long durationMs Long durationMs
) )
@ -272,10 +305,10 @@ The following new elements were added to `Docusign_Envelope_Templates_V3`:
| `Does_Row_Contain_Auth_Release` | Decision | Checks if the current row's `Name` contains `"Authorization to Release Information"` | | `Does_Row_Contain_Auth_Release` | Decision | Checks if the current row's `Name` contains `"Authorization to Release Information"` |
| `Flag_Auth_Release_Selected` | Assignment | Sets `authReleaseTemplateSelected = true` when match is found | | `Flag_Auth_Release_Selected` | Assignment | Sets `authReleaseTemplateSelected = true` when match is found |
| `Is_Auth_Release_Selected` | Decision | After scan loop: routes to copies screen if flag is true, otherwise skips | | `Is_Auth_Release_Selected` | Decision | After scan loop: routes to copies screen if flag is true, otherwise skips |
| `Authorization_Copies_Screen` | Screen | Shows instruction text + radio buttons (1 copy / 2 copies / 3 copies) | | `Authorization_Copies_Screen` | Screen | Shows instruction text + radio buttons (15 copies) |
| `authReleaseFormCopies` | Variable (Number, default 1) | Stores the user's copy-count selection | | `authReleaseFormCopies` | Variable (Number, default 1) | Stores the user's copy-count selection |
| `authReleaseTemplateSelected` | Variable (Boolean, default false) | Flag set during the scan loop | | `authReleaseTemplateSelected` | Variable (Boolean, default false) | Flag set during the scan loop |
| `AuthCopies_1/2/3` | Choices | Radio button options with numeric values 1 / 2 / 3 | | `AuthCopies_1/2/3/4/5` | Choices | Radio button options with numeric values 1 / 2 / 3 / 4 / 5 |
The `authReleaseFormCopies` variable is passed to the Apex Invocable Action as a new input parameter. The `authReleaseFormCopies` variable is passed to the Apex Invocable Action as a new input parameter.
@ -297,7 +330,7 @@ Check_Row_Selection → Scan_For_Auth_Release_Template (loop)
```apex ```apex
@InvocableVariable( @InvocableVariable(
label='Authorization to Release Form Copies' label='Authorization to Release Form Copies'
description='Number of times to include the Authorization to Release Information template (1-3).' description='Number of times to include the Authorization to Release Information template (1-5).'
required=false required=false
) )
global Integer authReleaseFormCopies; global Integer authReleaseFormCopies;
@ -309,7 +342,7 @@ global Integer authReleaseFormCopies;
// Expand multi-copy templates // Expand multi-copy templates
List<String> expandedTemplateIds = new List<String>(req.templateIds); List<String> expandedTemplateIds = new List<String>(req.templateIds);
Integer copies = (req.authReleaseFormCopies != null && req.authReleaseFormCopies > 1) Integer copies = (req.authReleaseFormCopies != null && req.authReleaseFormCopies > 1)
? Math.min(req.authReleaseFormCopies, 3) : 1; ? Math.min(req.authReleaseFormCopies, 5) : 1;
if (copies > 1) { if (copies > 1) {
List<String> multiCopyIds = [ List<String> multiCopyIds = [
SELECT dfsle__DocuSignId__c FROM dfsle__EnvelopeConfiguration__c SELECT dfsle__DocuSignId__c FROM dfsle__EnvelopeConfiguration__c
@ -324,7 +357,131 @@ if (copies > 1) {
} }
``` ```
Duplicate template IDs are intentionally **not deduplicated** when multi-copy is in effect. The label builder appends `" (Copy 2)"` / `" (Copy 3)"` suffixes to keep document labels distinct within the envelope. Duplicate template IDs are intentionally **not deduplicated** when multi-copy is in effect. The label builder appends `" (Copy 2)"` / `" (Copy 3)"` / `" (Copy 4)"` / `" (Copy 5)"` suffixes to keep document labels distinct within the envelope.
---
## 2.5 SMS Delivery Feature (v1.2)
### Overview
When the primary recipient — **Docusign Recipient #1** — has no email address on file, the envelope cannot be delivered via the normal email path. Rather than blocking the send, the system supports SMS delivery using the dfsle Apex Toolkit's native `dfsle.Recipient.withSmsDelivery()` method.
This feature is surfaced through **Flow V4** (`Docusign_Envelope_Templates_V4`), a copy of V3 with an added pre-send phone-collection step. **Flow V3 is unchanged** and continues to be used for cases where the recipient has an email address.
### Key Design Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Toolkit method | `dfsle.Recipient.withSmsDelivery(phone)` | Native dfsle support; no direct REST API needed |
| Email field | `SMS_PLACEHOLDER_EMAIL` constant | Docusign API requires an email on every recipient even for SMS-only delivery; placeholder satisfies this without routing any real email |
| Flow version | New V4; V3 unchanged | Preserves the working deployed flow; allows rollout at the operator's pace |
| Phone collection | Screen in flow (not Apex) | Keeps Apex free of UI logic; phone can also be supplied programmatically by bypassing the screen |
| Recipient scope | Docusign Recipient #1 only | Service Coordinator always has an email; only the primary recipient may lack one |
### Salesforce Data Setup Requirement
For SMS delivery to work, the **Docusign Recipient #1 Contact record must exist** on the Client Case — but with **no email address**. This is the trigger that causes the flow to route through the SMS path.
| Requirement | Detail |
|-------------|--------|
| `Docusign_Recipient_1__c` on Client Case | **Must be populated** with a Contact record |
| Contact's `Email` field | **Must be blank** — the flow checks this to decide whether to collect a phone number |
| Contact's `Name` field | **Must be populated** — Docusign uses this as the recipient's display name in the envelope |
> ⚠️ **Common mistake**: Removing the Contact from `Docusign_Recipient_1__c` entirely (setting it to null) will cause the Apex code to skip that recipient completely — the Docusign Recipient #1 will be missing from the envelope. The Contact must be linked; only the email needs to be absent.
### Configuration: Placeholder Email Constant
A single constant in `DocusignCompositeEnvelopeBuilder.cls` holds the placeholder email address used when the recipient has no real email. **This is the only place that needs to be updated if the placeholder address ever changes.**
```apex
// ============================================================
// 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.
// ============================================================
@TestVisible
private static final String SMS_PLACEHOLDER_EMAIL = 'placeholder_email@docusign.com';
```
### Flow V4 Changes
The following elements were added to `Docusign_Envelope_Templates_V4` (relative to V3):
| Element | Type | Purpose |
|---------|------|---------|
| `Get_Recipient_Contact` | Record Lookup | Queries the Contact record linked to `Docusign_Recipient_1__c`; fetches `Id`, `Email`, `Name` |
| `Is_Recipient_Email_Blank` | Decision | Checks whether `Get_Recipient_Contact.Email` is null or empty |
| `SMS_Phone_Screen` | Screen | Collects the recipient's mobile phone number (E.164, required field); shown only when email is blank |
| `recipientSmsPhone` | Variable (String) | Stores the phone number entered on `SMS_Phone_Screen`; passed to the Apex action |
#### Updated Flow Path (after `Get_Records`)
```
Get_Records → Get_Recipient_Contact → Is_Recipient_Email_Blank
├─ Has Email ──────────────────────────────▶ Is_Language_Selected
└─ No Email → SMS_Phone_Screen (collect phone) ▶ Is_Language_Selected
```
The `recipientSmsPhone` variable is passed to `Send_Composite_Envelope` as a new input parameter. When the recipient has an email the variable is blank and the Apex action behaves identically to V3.
### Apex Changes
**`DocusignEnvelopeRequest.cls`** — new `@InvocableVariable`:
```apex
@InvocableVariable(
label='Recipient SMS Phone'
description='Mobile phone number for SMS delivery when Docusign Recipient #1 has no email.
A placeholder email is substituted automatically. Format: +15551234567 (E.164 preferred).'
required=false
)
global String recipientSmsPhone;
```
**`DocusignCompositeEnvelopeBuilder.cls`** — `resolveRecipients` now accepts `smsPhone` and passes it only to the Docusign Recipient #1 builder:
```apex
private static List<dfsle.Recipient> resolveRecipients(String recordId, String smsPhone) {
// ... query Client_Case__c ...
// Service Coordinator — always email delivery (null smsPhone)
recipients.add(buildRecipient(serviceCoordinatorId, ROLE_SERVICE_COORDINATOR, routingOrder++, recordId, null));
// Docusign Recipient #1 — SMS delivery when smsPhone is provided
recipients.add(buildRecipient(docusignRecipientId, ROLE_DOCUSIGN_RECIPIENT, routingOrder++, recordId, smsPhone));
}
```
`buildRecipient` applies the SMS toolkit method and placeholder email when a phone is supplied:
```apex
private static dfsle.Recipient buildRecipient(
Id recipientId, String roleName, Integer routingOrder,
String sourceRecordId, String smsPhone) {
// ... resolve name/email from Contact or User ...
if (String.isBlank(recipientEmail)) {
if (String.isNotBlank(smsPhone)) {
recipientEmail = SMS_PLACEHOLDER_EMAIL; // satisfy API requirement
} else {
throw new IllegalArgumentException('No email found for ' + roleName + ' ...');
}
}
dfsle.Recipient recipient = dfsle.Recipient.fromSource(
recipientName, recipientEmail, null, roleName, new dfsle.Entity(sourceRecordId)
);
if (String.isNotBlank(smsPhone)) {
recipient = recipient.withSmsDelivery(smsPhone); // dfsle native SMS delivery
}
return recipient;
}
```
--- ---
@ -492,6 +649,28 @@ Map<String, Object> envelope = new Map<String, Object>{
String envelopeJSON = JSON.serialize(envelope); String envelopeJSON = JSON.serialize(envelope);
``` ```
### 4.3 Email subject and body composition
The Apex layer composes the envelope's email subject and body before sending. Key rules:
- Subject: Prefixed with `Docusign: ` to make the source explicit for recipients. The final subject is truncated to 100 characters (Docusign requirement).
- Body: Built as Greeting → template bodies (joined with a visual divider) → Sign-off. The divider is a short visual separator (`\n\n` + 40 `─` characters + `\n\n`).
- Greeting/Sign-off: Support English (default) and Spanish. The Flow's `language` input may be a locale code (`es`, `es-CO`) or a user-friendly string (`Spanish`, `Español`). The code normalizes and accepts common Spanish forms and uses Spanish greeting/signoff when detected.
Example (English):
Greeting: `Hello,\n\nPlease complete the DocuSign signature request from Early Intervention Colorado.\n\n`
Sign-off: `\n\nThank you,\nEarly Intervention Colorado`
Example (Spanish):
Greeting: `Hola,\n\nPor favor, firme la solicitud de DocuSign de parte de Intervención Temprana Colorado.\n\n`
Sign-off: `\n\nGracias,\nIntervención Temprana Colorado`
Note: If the Flow's `language` value is blank or unrecognized, English is used as the default.
--- ---
## 5. API Integration Details ## 5. API Integration Details
@ -748,3 +927,4 @@ Accept: application/json
|---------|------|--------|---------| |---------|------|--------|---------|
| 1.0 | 2026-02-23 | Paul Huliganga | Initial release | | 1.0 | 2026-02-23 | Paul Huliganga | Initial release |
| 1.1 | 2026-03-11 | Paul Huliganga | Added section 2.4 — multi-copy Authorization to Release Information feature; updated component diagram, Request inner class, and sequence diagram | | 1.1 | 2026-03-11 | Paul Huliganga | Added section 2.4 — multi-copy Authorization to Release Information feature; updated component diagram, Request inner class, and sequence diagram |
| 1.2 | 2026-03-13 | Paul Huliganga | Added section 2.5 — SMS delivery via `dfsle.Recipient.withSmsDelivery()` for recipients without email; added Flow V4 description, `SMS_PLACEHOLDER_EMAIL` constant, `recipientSmsPhone` parameter; increased multi-copy support to 5 copies; prefixed envelope subject with `Docusign: ` and truncated to 100 chars; added Spanish greeting/signoff and expanded language detection (e.g., 'es', 'es-CO', 'Spanish', 'Español'). |

View File

@ -0,0 +1,201 @@
# Design Document (previous)
**Project**: Salesforce Composite Envelope Builder
**Version**: 1.2
**Date**: February 23, 2026 (updated March 15, 2026)
**Author**: Paul Huliganga
---
## 1. Architecture Overview
### 1.1 System Context
```
┌──────────────────┐
│ Salesforce User │
└────────┬─────────┘
┌─────────────────────────────────────────┐
│ Salesforce Platform │
│ ┌──────────────┐ ┌───────────────┐ │
│ │ Screen Flow │───▶│ Apex Class │ │
│ │ (Template │ │ (Composite │ │
│ │ Selection) │ │ Builder) │ │
│ └──────────────┘ └───────┬───────┘ │
│ │ │
└──────────────────────────────┼───────────┘
▼ HTTPS/REST API
┌──────────────────────┐
│ Docusign REST API │
│ (Composite │
│ Templates) │
└──────────────────────┘
```
### 1.2 Component Architecture
```
┌────────────────────────────────────────────────────────┐
│ Salesforce Org │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Presentation Layer (Screen Flow) │ │
│ │ - Language selection │ │
│ │ - Template display (checkbox collection) │ │
│ │ - Success/error messaging │ │
│ └──────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌─────────────────▼──────────────────────────────┐ │
│ │ Business Logic Layer (Apex) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ DocusignCompositeEnvelopeBuilder │ │ │
│ │ │ - @InvocableMethod entry point │ │ │
│ │ │ - Input validation │ │ │
│ │ │ - Multi-copy template expansion │ │ │
│ │ │ - SMS delivery (withSmsDelivery) │ │ │
│ │ │ - Composite JSON construction │ │ │
│ │ │ - Envelope ID return │ │ │
│ │ └──────────────────┬──────────────────────┘ │ │
│ │ │ │ │
│ │ ┌─────────────────▼──────────────────────┐ │ │
│ │ │ DocusignAPIService │ │ │
│ │ │ - API authentication │ │ │
│ │ │ - HTTP callout construction │ │ │
│ │ │ - Response parsing │ │ │
│ │ │ - Error handling │ │ │
│ │ └──────────────────┬──────────────────────┘ │ │
│ │ │ │ │
│ │ ┌─────────────────▼──────────────────────┐ │ │
│ │ │ DocusignCredentials │ │ │
│ │ │ - Credential retrieval │ │ │
│ │ │ - Token management │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Data Layer │ │
│ │ - Named Credential (Docusign API creds) │ │
│ │ - Custom Settings (configuration) │ │
│ └──────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────┘
▼ HTTPS REST API
┌──────────────────────┐
│ Docusign Platform │
└──────────────────────┘
```
---
## 2. Detailed Component Design
### 2.1 DocusignCompositeEnvelopeBuilder (Main Class)
**Purpose**: Invocable Apex class that combines multiple Docusign templates into a single envelope
**Responsibilities**:
- Receive template IDs from Screen Flow
- Validate inputs
- Expand multi-copy templates (e.g. Authorization to Release Information)
- Construct composite template JSON
- Delegate API call to service class
- Return envelope ID to Flow
**Methods**:
```apex
// Flow-invocable entry point
@InvocableMethod(
label='Send Composite Docusign Envelope'
description='Combines multiple templates into one envelope'
)
public static List<Result> sendCompositeEnvelope(List<Request> requests)
// Private helper methods
private static String buildCompositeEnvelopeJSON(
List<String> templateIds,
String recordId,
String language,
Map<String, String> customFields
)
private static void validateInputs(Request req)
private static List<String> sortTemplatesAlphabetically(List<String> templateIds)
```
**Inner Classes**:
```apex
public class Request {
@InvocableVariable(required=true label='Template IDs')
public List<String> templateIds;
@InvocableVariable(required=true label='Salesforce Record ID')
public String recordId;
@InvocableVariable(required=false label='Language')
public String language; // 'en' or 'es'
@InvocableVariable(required=false label='Email Subject')
public String emailSubject;
@InvocableVariable(required=false label='Authorization to Release Form Copies')
public Integer authReleaseFormCopies; // 15; only used when that template is selected
@InvocableVariable(required=false label='Recipient SMS Phone')
public String recipientSmsPhone; // E.164 preferred (+15551234567); triggers SMS delivery for Docusign Recipient #1
@InvocableVariable(required=false label='Custom Fields')
public Map<String, String> customFields; // For merge fields
}
public class Result {
@InvocableVariable(label='Envelope ID')
public String envelopeId;
@InvocableVariable(label='Success')
public Boolean success;
@InvocableVariable(label='Error Message')
public String errorMessage;
}
```
---
### 2.2 DocusignAPIService (Service Class)
**Purpose**: Handles all Docusign REST API interactions
**Responsibilities**:
- Construct HTTP requests
- Make callouts to Docusign API
- Parse responses
- Handle errors and retries
- Log API interactions
**Methods**:
```apex
public static String createCompositeEnvelope(
String envelopeJSON,
DocusignCredentials creds
)
public static HttpResponse callDocusignAPI(
String endpoint,
String method,
String body,
Map<String, String> headers
)
public static String parseEnvelopeId(HttpResponse response)
private static void logAPICall(
HttpRequest req,
```

View File

@ -0,0 +1,228 @@
# Design — Composite Envelope Builder (Updated)
**Project**: Salesforce Composite Envelope Builder
**Version**: 2.0
**Date**: 2026-03-25
**Author**: (auto-generated; update author/owner as needed)
---
## 1. Summary
This document describes the current implementation of the Composite Envelope Builder: how Screen Flows, Apex, and the dfsle toolkit collaborate to build and send a single DocuSign envelope composed from multiple DocuSign templates. It reflects the latest code and metadata in the repository (March 2026), including:
- Flow V3 (existing, email-only path) and Flow V4 (new, SMS collection path when recipient email is blank)
- Apex invocable: `DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope` and supporting classes
- SMS delivery support for recipients without an email (via `dfsle.Recipient.withSmsDelivery()`)
- Multi-copy support for the "Authorization to Release Information" template (up to 5 copies)
- Email subject & body composition rules: prefix `Docusign: `, truncation, greeting/signoff handling and Spanish detection
- Key constants: `SMS_PLACEHOLDER_EMAIL`, `MULTI_COPY_TEMPLATE_NAME`
This is the canonical design doc for developers, release engineers, and reviewers.
---
## 2. Architecture Overview
High-level flow:
- User launches Screen Flow (Flow V3 or V4)
- User selects language (English/Spanish) and templates
- Flow optionally collects SMS phone when recipient email is blank (V4)
- Flow invokes Apex action `Send Composite Docusign Envelope` with inputs
- Apex builds compound envelope JSON using selected templates (expands multi-copy templates as configured)
- Apex uses dfsle toolkit to send envelope; returns envelopeId and success/error
Components:
- Screen Flows: `Docusign_Envelope_Templates_V3` (email path), `Docusign_Envelope_Templates_V4` (collect SMS phone when needed)
- Apex:
- `DocusignCompositeEnvelopeBuilder.cls` (invocable entrypoint + implementation)
- `DocusignEnvelopeRequest.cls` (invocable request contract)
- `DocusignEnvelopeResult.cls` (invocable result contract)
- `DocusignAPIService.cls`, `DocusignCredentials.cls` (service & credential management)
- DocuSign: Composite Templates API (via dfsle toolkit integration)
---
## 3. Flow Behavior (V3 and V4)
### 3.1 Shared behavior (V3 & V4)
- Language selection screen: user selects `English` or `Spanish`.
- Template selection screen: multi-select list of templates filtered by selected language.
- After selection, a loop scans selected templates to detect the multi-copy template ("Authorization to Release Information").
- If detected, a copies screen appears (radio 15) that sets `authReleaseFormCopies`.
- Flow gathers `compositeTemplateIds` (selected template IDs) and passes them to Apex action.
### 3.2 Flow V3
- Used when standard email delivery is expected.
- No SMS collection screen is present.
- Behavior is unchanged from previous releases except choices include 15 copies.
### 3.3 Flow V4 (SMS-aware)
- Added pre-send recipient lookup path:
- `Get_Records` (Client_Case__c) → `Get_Recipient_Contact` (Contact lookup)
- `Is_Recipient_Email_Blank` decision checks Contact.Email
- If email is blank → `SMS_Phone_Screen` (field `smsPhoneInput` required, E.164 format recommended) → `Store_SMS_Phone` assignment assigns to `recipientSmsPhone` variable → continue
- If email present → skip SMS_Phone_Screen and continue
- The `recipientSmsPhone` variable is passed into the Apex action; when non-blank the Apex sets up SMS delivery for Docusign Recipient #1.
- Flow element names to reference in code and docs: `SMS_Phone_Screen`, `smsPhoneInput`, `Store_SMS_Phone`, `recipientSmsPhone`.
Note: Flow metadata must keep element blocks contiguous (assignments, decisions, screens, etc.) to avoid deployment errors. The committed `Docusign_Envelope_Templates_V4.flow-meta.xml` already follows these rules.
---
## 4. Apex Implementation Details
### 4.1 Invocable contract (`DocusignEnvelopeRequest`)
Key input fields (invocable variables):
- `List<String> templateIds` (required) — list of selected DocuSign template IDs
- `String recordId` (required) — Salesforce Client Case Id
- `String language` (optional) — e.g., `en`, `es`, `Spanish`, `Español` (language detection is normalized in Apex)
- `String emailSubject` (optional) — optional override; Apex still prefixes with `Docusign: ` and truncates to 100 chars
- `Integer authReleaseFormCopies` (optional) — 15; counts for Authorization template
- `String recipientSmsPhone` (optional) — when provided, Apex will enable SMS delivery for Docusign Recipient #1
Outputs:
- `String envelopeId`
- `Boolean success`
- `String errorMessage`
### 4.2 Main behavior (`DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope`)
1. Validate inputs; throw friendly errors if invalid.
2. Expand multi-copy templates:
- Find template IDs whose `Name` contains the constant `MULTI_COPY_TEMPLATE_NAME`.
- If `authReleaseFormCopies` > 1, append (copies - 1) additional entries for each matched template ID.
- Cap copies to 5 (Math.min(..., 5)).
3. Build document list (documents = fromTemplate(templateId, label)) and label duplicates as ` (Copy N)` to preserve uniqueness.
4. Build deduplicated display names for subject/body (merge repeated templates into count markers like `X (3)`).
5. Compose envelope subject & body (see section 5 for rules).
6. Resolve recipients via `resolveRecipients(recordId, recipientSmsPhone)`:
- Query Client_Case__c for lookup fields
- For Docusign Recipient #1: if linked Contact has no Email and `recipientSmsPhone` is provided, set recipientEmail to `SMS_PLACEHOLDER_EMAIL` and call `recipient.withSmsDelivery(smsPhone)`.
7. Use dfsle toolkit to build envelope with documents and recipients and call send.
8. Return `envelopeId` and `success`.
### 4.3 Important constants
- `SMS_PLACEHOLDER_EMAIL` (defined in `DocusignCompositeEnvelopeBuilder.cls`): placeholder email used when recipient has no email; required by DocuSign API even for SMS deliveries.
- `MULTI_COPY_TEMPLATE_NAME` (defined in same class): base name used to identify the Authorization to Release Information template (covers English + Spanish variations via LIKE).
### 4.4 i18n detection
- The code normalizes `req.language` to lowercase and treats as Spanish if any of the following are true:
- `lang.startsWith('es')` (e.g. `es`, `es-CO`)
- `lang.contains('spanish')`
- `lang.contains('espa')` (to capture `español` / `espanol`)
- On Spanish detection, Apex uses Spanish greeting/signoff; otherwise English is used.
---
## 5. Email Subject & Body Composition Rules
- Subject:
- Prefix: `Docusign: ` is prepended to the subject to make the source explicit.
- Truncation: After prefixing, subject is truncated to a maximum of 100 characters (Docusign requirement). The code uses `left(97) + '...'` if longer.
- If user supplies `emailSubject`, prefix and truncation still apply.
- Body:
- Built as: GREETING + (template bodies joined by DIVIDER) + SIGNOFF
- DIVIDER: `"\n\n" + '─'.repeat(40) + "\n\n"` (visual separator)
- Greeting/Sign-off: English default; Spanish strings used when language detection matches Spanish variants. Examples:
- English greeting: `Hello,\n\nPlease complete the DocuSign signature request from Early Intervention Colorado.\n\n`
- Spanish greeting: `Hola,\n\nPor favor, firme la solicitud de DocuSign de parte de Intervención Temprana Colorado.\n\n`
---
## 6. Multi-copy Behavior
- Single constant drives detection: `MULTI_COPY_TEMPLATE_NAME = 'Authorization to Release Information'`.
- Flow shows a copies screen when the template is present; operator selects 15 copies.
- Apex expands the templateId list before building documents to include multiple entries; duplicates are intentionally kept so each copy becomes a separate document in the envelope.
- Document labels: duplicates are labeled with ` (Copy N)` so they appear distinct in DocuSign Status.
---
## 7. SMS Delivery Details
- Trigger: Flow V4 routes to `SMS_Phone_Screen` if the Contact linked by `Docusign_Recipient_1__c` has empty `Email`.
- The screen collects `smsPhoneInput` which is assigned to `recipientSmsPhone` variable and passed to Apex.
- In Apex, when `recipientSmsPhone` is non-blank for Docusign Recipient #1:
- If recipient record has no email, set `recipientEmail = SMS_PLACEHOLDER_EMAIL`.
- Call `recipient = recipient.withSmsDelivery(smsPhone)` to enable SMS delivery via dfsle toolkit.
- Service Coordinator recipient always uses email delivery (unchanged).
- Important setup requirement (documented): `Docusign_Recipient_1__c` on the Client Case must be populated with a Contact. The Contact must have `Name` and (for SMS flow) may have blank `Email`. If the Contact is null, the recipient will be omitted and envelope will be missing that signer.
---
## 8. Flows & Metadata Notes
- Flow element naming conventions are important (screen fields bind by their `name`). For the SMS flow, the screen field is `smsPhoneInput` and the assignment must use `<elementReference>smsPhoneInput</elementReference>` (not `ScreenName.smsPhoneInput`).
- Flow metadata must keep element blocks contiguous (screens, assignments, recordLookups, decisions, loops, choices) to avoid Salesforce Flow XML validation failures. The repo's V4 flow file was normalized accordingly.
---
## 9. Tests, Validation & CI
- Unit tests: ensure `DocusignCompositeEnvelopeBuilderTest` covers:
- English and Spanish subject/body composition
- Multi-copy expansion with 15 copies
- SMS flow path: when Contact.Email blank + recipientSmsPhone provided, mailbox placeholder used and `withSmsDelivery` applied
- Error branches and API failures
- CI: add a PR validation job that does:
- sfdx auth to a validation org
- For changed flow or Apex files, run `sfdx force:source:deploy --checkonly` for those components
- Fail PR if check-only returns errors
- Pre-deploy: run check-only deploy to target org before actual deploy. Example local command:
```bash
sfdx force:source:deploy -p force-app/main/default/flows/Docusign_Envelope_Templates_V4.flow-meta.xml,force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls -u dev-org --checkonly --testlevel NoTestRun
```
---
## 10. Edge Cases and Known Gotchas
- If operator edits a flow in the org and does not retrieve+commit the changes, repo and org diverge. Use mdapi `retrieve` or `sf project retrieve` to pull the authoritative version and commit immediately.
- Flow Builder sometimes reorders or normalizes XML: prefer re-checking contiguity of element blocks after Flow Builder saves.
- Ensure the Flow variable `recipientSmsPhone` is passed to Apex action and that the assignment uses `smsPhoneInput` as elementReference.
- The DocuSign API requires an email field on every recipient even when SMS is used; `SMS_PLACEHOLDER_EMAIL` is central and must be kept updated in Apex if changed.
---
## 11. Deployment Checklist
1. Confirm `force-app/main/default/flows/Docusign_Envelope_Templates_V4.flow-meta.xml` matches the active version in org (if you intend to deploy active draft)
2. Confirm `force-app/main/default/classes/DocusignCompositeEnvelopeBuilder.cls` and related classes/tests are committed
3. Run `sfdx force:source:deploy --checkonly` for affected components against a validation org
4. Run Apex tests (or allow CI to run tests during deploy)
5. Deploy to sandbox, perform a manual Flow Builder inspection of V4
6. Activate the desired Flow version if runtime activation is needed
---
## 12. Change Log (high level)
- 2.0 (2026-03-25): Updated design to reflect:
- Flow V4 (SMS path) retrieval & Active flow committed
- Spanish greeting/signoff and broadened language detection
- Envelope subject prefix `Docusign: ` and truncation to 100 chars
- Increased Authorization multi-copy support to 5 copies
- Flow element and metadata normalization
---
## 13. Contact & Ownership
- Primary developer: Paul Huliganga (update as appropriate)
- Repo: `composite-envelope-builder`
- For deployment questions: refer to `docs/DEPLOYMENT_AND_TESTING.md`
---
*This document was generated to represent the repository state as of March 25, 2026. Update the change log and ownership fields when you make future changes.*

View File

@ -0,0 +1,185 @@
---
title: Migrate DocuSign templates from Sandbox to Production (Salesforce)
---
# Migrate DocuSign templates from Sandbox to Production
Purpose
- Step-by-step guide to: export templates from DocuSign Demo/Sandbox, import into DocuSign Production, and update (or duplicate) the corresponding Salesforce `dfsle__EnvelopeConfiguration__c` records.
- Includes instructions to create the `Short_Name__c` custom field used by our Apex and Flows.
Prerequisites
- DocuSign admin access (Demo + Production) to export/import templates.
- Salesforce admin access to create fields and run Data Loader / sfdx commands.
- `dfsle` (DocuSign for Salesforce) managed package installed in Production.
- Optional: `sfdx` CLI or Data Loader for bulk changes.
High-level steps
1. Export current `dfsle__EnvelopeConfiguration__c` records from Salesforce (backup).
2. Export templates from DocuSign Demo/Sandbox and import into DocuSign Production.
3. Record the new DocuSign template IDs (GUIDs).
4. Create the `Short_Name__c` custom field on `dfsle__EnvelopeConfiguration__c` (if not present).
5. Prepare CSV mapping old→new template IDs.
6. Update existing `dfsle__EnvelopeConfiguration__c` records (or insert duplicates) with new IDs.
7. Validate by sending test envelopes through the Flow/Apex.
Detailed steps
1) Export existing Salesforce configs (backup)
- Recommended fields to export:
```
Id, Name, dfsle__DocuSignId__c, Short_Name__c, dfsle__EmailMessage__c
```
- sfdx (CSV output):
```bash
sfdx force:data:soql:query -u ORG_ALIAS -q "SELECT Id, Name, dfsle__DocuSignId__c, Short_Name__c, dfsle__EmailMessage__c FROM dfsle__EnvelopeConfiguration__c" --resultformat csv > dfsle_configs_backup.csv
```
- Or use Workbench / Data Loader to export the same fields.
Keep this CSV safe — it is your rollback and audit record.
2) Export templates from DocuSign Demo/Sandbox
- Use DocuSign Admin UI or API to export templates. Options:
- UI: Admin → Templates (select template) → export / download (if available).
- API: Use eSignature REST API `GET /v2.1/accounts/{accountId}/templates/{templateId}` to retrieve template definition; then `POST /v2.1/accounts/{accountId}/templates` to create in production.
- After import, capture the new template GUIDs. You can find a template's ID in its template URL or via the API.
3) Build mapping of old → new template IDs
- Minimal mapping CSV for updating existing records (update by `Id`):
```
Id,dfsle__DocuSignId__c
00Nxxxxxxxxxxxx,AAAAAAAA-BBBB-CCCC-DDDD-111111111111
00Nyyyyyyyyyyyy,22222222-3333-4444-5555-666666666666
```
- Minimal CSV for inserting duplicates (keep old records intact):
```
Name,Short_Name__c,dfsle__DocuSignId__c,dfsle__EmailMessage__c
Invoice Template - EN,INV_EN,AAAAAAAA-BBBB-CCCC-DDDD-111111111111,Please sign the attached invoice
Invoice Template - FR,INV_FR,22222222-3333-4444-5555-666666666666,Veuillez signer la facture
```
4) Create the `Short_Name__c` field on `dfsle__EnvelopeConfiguration__c`
Why: Our Apex and Flow use `Short_Name__c` as a compact identifier. If it does not exist in Production, create it before updating/inserting records.
UI (recommended)
1. In Salesforce Setup go to **Object Manager**.
2. Search for **Envelope Configuration** (DocuSign) or type `dfsle__EnvelopeConfiguration__c` into the quick find.
3. Open **Fields & Relationships****New**.
4. Select **Text** as the field type.
5. Field Label: `Short Name` → Field Name (API): `Short_Name` → Length: `50` (adjust as needed) → Next.
6. Set Field-Level Security (make visible to integration/admin profiles) → Next.
7. Add to Page Layouts if desired → Save.
Notes about managed-package objects
- `dfsle__EnvelopeConfiguration__c` is a managed-package object. You can add subscriber-org custom fields to package objects; the API name will be `Short_Name__c` (no package namespace for the custom field).
- If you cannot create the field via the UI or need to automate it, deploy the field metadata with the Metadata API (MDAPI).
MDAPI example (place under `objects/dfsle__EnvelopeConfiguration__c/fields/Short_Name__c.field-meta.xml`):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>Short_Name__c</fullName>
<label>Short Name</label>
<type>Text</type>
<length>50</length>
<inlineHelpText>Short identifier for templates used by Apex/Flows</inlineHelpText>
</CustomField>
```
Include the field in your `package.xml` and deploy with `sfdx force:mdapi:deploy` or your CI.
5) Apply the mapping to Salesforce
- Update existing records (in-place update keeps references in other automation intact):
```bash
# update by Id
sfdx force:data:bulk:upsert -s dfsle__EnvelopeConfiguration__c -f updates.csv -i Id -u ORG_ALIAS -w 10
```
- Insert duplicates (if you prefer to preserve old configs):
```bash
sfdx force:data:bulk:insert -s dfsle__EnvelopeConfiguration__c -f inserts.csv -u ORG_ALIAS -w 10
```
- Data Loader: use the GUI to map CSV columns to fields and run Update or Insert jobs.
6) Validate & test
- Quick queries:
```bash
# list configs with short names
sfdx force:data:soql:query -u ORG_ALIAS -q "SELECT Id, Name, Short_Name__c, dfsle__DocuSignId__c FROM dfsle__EnvelopeConfiguration__c WHERE Short_Name__c != NULL" --resultformat csv
```
- Send test envelopes:
- Run the Flow that uses the templates from the UI or use the invocable Apex from Developer Console / Execute Anonymous.
- Watch logs / debug to verify the correct `dfsle__DocuSignId__c` is used and envelopes are sent successfully.
Example Apex (execute as anonymous) — adapt to your codebase if method names differ:
```apex
// Construct an invocable-style request object to test a single template
// Replace types/names with your project types if they differ
compositeEnvelope.DocusignEnvelopeRequest r = new compositeEnvelope.DocusignEnvelopeRequest();
r.templateIds = new List<String>{'NEW-TEMPLATE-GUID'};
r.recordId = '001xxxxxxxxxxxx';
System.debug(JSON.serializePretty(r));
// Call the invocable method or trigger the Flow to verify end-to-end behavior
```
7) Rollback / audit
- Keep the original export CSV as your rollback artifact.
- If something goes wrong, re-run Data Loader using the backup CSV to restore previous `dfsle__DocuSignId__c` values.
Troubleshooting & tips
- If an insert/update fails, check field-level security and that your user has API access for the managed-package object.
- Some fields on managed-package objects may be controlled by the package; if metadata deploy fails, create the `Short_Name__c` field via the UI.
- If your Flow or other automation reference specific Config record Ids, prefer updating in-place rather than inserting duplicates.
Appendix
- SOQL to select configs with non-blank `Short_Name__c`:
```sql
SELECT Id, Name, Short_Name__c FROM dfsle__EnvelopeConfiguration__c WHERE Short_Name__c != NULL
```
- Example update CSV (updates.csv):
```
Id,dfsle__DocuSignId__c
00Nxxxxxxxxxxxx,AAAAAAAA-BBBB-CCCC-DDDD-111111111111
00Nyyyyyyyyyyyy,22222222-3333-4444-5555-666666666666
```
- Example insert CSV (inserts.csv):
```
Name,Short_Name__c,dfsle__DocuSignId__c
Invoice Template - EN,INV_EN,AAAAAAAA-BBBB-CCCC-DDDD-111111111111
```
If you want, I can:
- Export the current `dfsle__EnvelopeConfiguration__c` records from an org (provide `ORG_ALIAS`) and create the mapping CSV template for you.
- Create the MDAPI metadata XML for `Short_Name__c` under `deploy/mdapi/objects/dfsle__EnvelopeConfiguration__c/fields/` and add it to `deploy/mdapi/package.xml` for deployment.
---
Last updated: 2026-04-01

View File

@ -0,0 +1,170 @@
---
title: Migrate DocuSign templates from Sandbox to Production using DAL export (Salesforce)
---
# Migrate DocuSign templates from Sandbox to Production — DAL (DocuSign Apps Launcher) export
Purpose
- Use DocuSign's DAL template export/import to preserve Salesforce merge fields embedded in the DocuSign-for-Salesforce templates (`dfsle__EnvelopeConfiguration__c`).
- This document covers exporting templates via DAL from Demo/Sandbox, importing into Production, capturing new template IDs, and updating Salesforce `dfsle__EnvelopeConfiguration__c` records.
- Includes instructions to create the `Short_Name__c` custom field required by our Apex classes and Flow.
Prerequisites
- DocuSign admin access (Demo/Sandbox + Production) with permission to export/import templates via DAL.
- Salesforce admin access to create fields and run Data Loader / `sfdx` commands.
- `dfsle` (DocuSign for Salesforce) managed package installed in Production.
- Optional: `sfdx` CLI or Data Loader for bulk CSV updates.
High-level steps
1. Export the DocuSign templates from Sandbox/Demo using DAL export.
2. Import the DAL package(s) into DocuSign Production.
3. Capture the new DocuSign template IDs for each imported template.
4. Create the `Short_Name__c` custom field on `dfsle__EnvelopeConfiguration__c` (if missing).
5. Prepare CSV mapping old→new template IDs.
6. Update (or insert duplicates of) `dfsle__EnvelopeConfiguration__c` records in Salesforce.
7. Validate by sending test envelopes through the Flow/Apex.
Detailed steps
1) Download templates from the source Salesforce org (DAL)
- Preconditions: you must have Salesforce and DocuSign admin permissions and DAL (DocuSign Apps Launcher v4.5+) installed in the org.
- Steps in the source org:
1. Open the Salesforce App Launcher (grid icon) and select View All.
2. Select DocuSign Apps Launcher.
3. In the left column select eSignature to open the eSignature Configuration page.
4. On the eSignature Configuration page, locate the list of Envelope Templates.
5. Select the checkbox next to each template you want to download.
6. From the Create Template dropdown choose Download Template.
7. The page will refresh and show "Templates downloaded". Save the downloaded file(s) (ZIP) to your device.
- Note: Salesforce prevents downloading templates larger than 4 MB.
- Important: templates that include custom fields created by a specific user may not import cleanly into the destination org unless that creating user exists in the destination. Either add the creating user to the destination org before upload, or plan to rebuild the missing custom fields after import.
2) Import DAL package(s) into DocuSign Production
- In DocuSign Production, go to the Templates (or Template Management) area and use the **Import** or **Upload DAL package** option.
- Upload each DAL package file exported from Sandbox/Demo.
- After import, open each imported template and verify that merge fields (placeholders) are preserved and that the template content looks correct.
3) Capture new template IDs (GUIDs)
- For each imported template record in Production, record the new template ID (GUID). The template ID is often visible in the template URL (e.g., `/templates/{templateId}`) or in the template properties.
- Create a simple mapping list with columns: `OldDocuSignId, NewDocuSignId, TemplateName`.
4) Create `Short_Name__c` on `dfsle__EnvelopeConfiguration__c` (if not present)
Why: Our Apex and Flow expect a `Short_Name__c` field as a compact identifier. Create it before inserting/updating records so `Short_Name__c` values can be included in CSVs and UI pages.
UI steps (recommended):
1. In Salesforce Setup, open **Object Manager**.
2. Search for **Envelope Configuration** (DocuSign) or look up `dfsle__EnvelopeConfiguration__c`.
3. Click **Fields & Relationships****New**.
4. Select **Text****Next**.
5. Label: `Short Name` → API Name will be `Short_Name__c` → Length: `50` → Next.
6. Set Field-Level Security (visible to integration/admin profiles) → Next → Save.
Managed-package note
- Adding custom fields to managed-package objects is supported in subscriber orgs; the custom field's API name will be `Short_Name__c` (no package namespace).
MDAPI (optional automation)
- If you prefer to deploy the field via Metadata API, create a field metadata file:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>Short_Name__c</fullName>
<label>Short Name</label>
<type>Text</type>
<length>50</length>
<inlineHelpText>Short identifier used by Apex and Flow</inlineHelpText>
</CustomField>
```
Place that at `deploy/mdapi/objects/dfsle__EnvelopeConfiguration__c/fields/Short_Name__c.field-meta.xml` and add the file to `deploy/mdapi/package.xml` before an `sfdx force:mdapi:deploy`.
5) Prepare mapping CSV (old → new)
- For updating existing Salesforce records (recommended if Flows reference record Ids):
```
Id,dfsle__DocuSignId__c
00Nxxxxxxxxxxxx,AAAAAAAA-BBBB-CCCC-DDDD-111111111111
00Nyyyyyyyyyyyy,22222222-3333-4444-5555-666666666666
```
- For inserting duplicates (preserve old configs):
```
Name,Short_Name__c,dfsle__DocuSignId__c,dfsle__EmailMessage__c
Invoice Template - EN,INV_EN,AAAAAAAA-BBBB-CCCC-DDDD-111111111111,Please sign the attached invoice
```
6) Update or insert `dfsle__EnvelopeConfiguration__c` records in Salesforce
- Update (in-place):
```bash
# update by Id (bulk upsert)
sfdx force:data:bulk:upsert -s dfsle__EnvelopeConfiguration__c -f updates.csv -i Id -u ORG_ALIAS -w 10
```
- Insert (duplicates):
```bash
sfdx force:data:bulk:insert -s dfsle__EnvelopeConfiguration__c -f inserts.csv -u ORG_ALIAS -w 10
```
- Alternatively use Data Loader / Dataloader.io for GUI-driven imports.
Notes
- If flows or other automation reference the `dfsle__EnvelopeConfiguration__c` record Ids directly, prefer updating `dfsle__DocuSignId__c` in-place.
- If you want to keep previous configs for rollback or comparison, insert duplicates and then re-point any automation to new records as needed.
7) Validate end-to-end
- Query to verify `Short_Name__c` and new DocuSign IDs:
```bash
sfdx force:data:soql:query -u ORG_ALIAS -q "SELECT Id, Name, Short_Name__c, dfsle__DocuSignId__c FROM dfsle__EnvelopeConfiguration__c WHERE Short_Name__c != NULL" --resultformat csv
```
- Trigger the Flow or call the Invocable Apex (in Developer Console or by running the Flow in UI) to send a test envelope and confirm the imported template is used and merge fields populate as expected.
Rollback
- Keep your original export CSV from step 1 as a rollback file. To revert, re-run Data Loader using the backup CSV to restore prior `dfsle__DocuSignId__c` values.
Troubleshooting
- If a bulk insert/update fails, check Field-Level Security, API access for the managed-package object, and required fields enforced by the package.
- If the DAL import does not preserve some merge mappings, capture the problem template and contact DocuSign support — but DAL is the recommended method because it preserves Salesforce-related merge fields.
Appendix
- Example SOQL to list templates with non-blank `Short_Name__c`:
```sql
SELECT Id, Name, Short_Name__c FROM dfsle__EnvelopeConfiguration__c WHERE Short_Name__c != NULL
```
- Example `updates.csv`:
```
Id,dfsle__DocuSignId__c
00Nxxxxxxxxxxxx,AAAAAAAA-BBBB-CCCC-DDDD-111111111111
```
- Example `inserts.csv`:
```
Name,Short_Name__c,dfsle__DocuSignId__c
Invoice Template - EN,INV_EN,AAAAAAAA-BBBB-CCCC-DDDD-111111111111
```
If you want, I can:
- Export the current `dfsle__EnvelopeConfiguration__c` records from an org (provide `ORG_ALIAS`) and prepare `updates.csv`/`inserts.csv` pre-filled with current data.
- Add the MDAPI field metadata for `Short_Name__c` into `deploy/mdapi/objects/dfsle__EnvelopeConfiguration__c/fields/` and update `deploy/mdapi/package.xml` for deployment.
---
Last updated: 2026-04-01

View File

@ -0,0 +1,66 @@
# Migrate DocuSign templates from Sandbox to Production — DAL (DocuSign Apps Launcher) export
# Purpose
- Use DocuSign's DAL template export/import to preserve Salesforce merge fields embedded in the DocuSign-for-Salesforce templates (`dfsle__EnvelopeConfiguration__c`).
- This document covers exporting templates via DAL from Demo/Sandbox, importing into Production, capturing new template IDs, and updating Salesforce `dfsle__EnvelopeConfiguration__c` records.
- Includes instructions to create the `Short_Name__c` custom field required by our Apex classes and Flow.
# Prerequisites
- DocuSign admin access (Demo/Sandbox + Production) with permission to export/import templates via DAL.
- Salesforce admin access to create fields and run Data Loader / `sfdx` commands.
- `dfsle` (DocuSign for Salesforce) managed package installed in Production.
- Optional: `sfdx` CLI or Data Loader for bulk CSV updates.
# High-level steps
1. Export the DocuSign templates from Sandbox/Demo using DAL export.
2. Import the DAL package(s) into DocuSign Production.
3. Capture the new DocuSign template IDs for each imported template.
4. Create the `Short_Name__c` custom field on `dfsle__EnvelopeConfiguration__c` (if missing).
5. Prepare CSV mapping old→new template IDs.
6. Update (or insert duplicates of) `dfsle__EnvelopeConfiguration__c` records in Salesforce.
7. Validate by sending test envelopes through the Flow/Apex.
# Detailed steps
## 1) Download templates from the source Salesforce org
- Preconditions: you must have Salesforce and DocuSign admin permissions and DAL (DocuSign Apps Launcher v4.5+) installed in the org.
### Steps in the source org:
1. Open the Salesforce App Launcher (grid icon) and select View All.
2. Select DocuSign Apps Launcher.
3. In the left column select eSignature to open the eSignature Configuration page.
4. The Templates tab should be showing
5. On the eSignature Configuration page, locate the list of Envelope Templates.
6. Select the checkbox next to each template you want to download.
7. From the Create Template dropdown choose Download Template.
8. The page will refresh and show "Templates downloaded". Save the downloaded file(s) (ZIP) to your device.
- Note: Salesforce prevents downloading templates larger than 4 MB.
- Important: templates that include custom fields created by a specific user may not import cleanly into the destination org unless that creating user exists in the destination. Either add the creating user to the destination org before upload, or plan to rebuild the missing custom fields after import.
## 2) Upload templates to the target Salesforce org
### Steps in the target org:
1. Open the Salesforce App Launcher (grid icon) and select View All.
2. Select DocuSign Apps Launcher.
3. In the left column select eSignature to open the eSignature Configuration page.
4. The Templates tab should be showing
5. From the Create Template dropdown choose Upload Template.
- Upload each Template file exported from the source org.
- After upload, open each template and verify that merge fields (placeholders) are preserved and that the template content looks correct.
## 3) Create `Short_Name__c` on `dfsle__EnvelopeConfiguration__c` (if not present)
Why: Our Apex and Flow expect a `Short_Name__c` field as a compact identifier. Create it before inserting/updating records so `Short_Name__c` values can be included in CSVs and UI pages.
### Steps for adding the Short_Name field:
1. Go to the Docusign Apps Launch eSignature Configuration page (should already be there from last step)
2. Click on Name of the top template (you can select any template). This will bring you into the template
3. Go to top Gear Icon Settings menu and Select "Edit Object"
4. This brings you to the Setup Object Manager for the Docusign Envelope Template
5. Click **Fields & Relationships** in left hand menu, then → **New**.
6. Select **Text****Next**.
7. Label: `Short Name` → API Name will be `Short_Name__c` → Length: `50` → Next.
8. Set Field-Level Security (visible to all profiles) → Next → Save.

View File

@ -0,0 +1,99 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 10 0 R /MediaBox [ 0 0 612 792 ] /Parent 9 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/Contents 11 0 R /MediaBox [ 0 0 612 792 ] /Parent 9 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/PageMode /UseNone /Pages 9 0 R /Type /Catalog
>>
endobj
8 0 obj
<<
/Author (anonymous) /CreationDate (D:20260402234617-04'00') /Creator (anonymous) /Keywords () /ModDate (D:20260402234617-04'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (unspecified) /Title (untitled) /Trapped /False
>>
endobj
9 0 obj
<<
/Count 2 /Kids [ 5 0 R 6 0 R ] /Type /Pages
>>
endobj
10 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1476
>>
stream
Gatm;gJ6d"&:L1SW4*3ti5CX/KK#3I(5^ibM%!1BTOV'ihQbnnDB"tXhs.Wj4!@J\'g`7iC$Ka(e2_@b-M1f;cS,Ir@fbUO)f,`pCTq*fKuF/L0#!_9%bR-E\RSimpY9XQGV0-E";Vl6ro11n-,No?eK^,r-o!5MHD4e!70\f(O&k6\8oY&nIphgDa&)adYWNTc]+e*WQ<`LTFIW?nf+5)*D*hSGKn9P1j^Ea]Gm=%M#[)&c2(_Nr9?s2bI(-)KP(c_W>1@srq_;s&l"k&=_0uB3^N]%"lB(p<RJpDQRr68/e%Z/a-mU:,Y=KM.W7F:i#T:c%@EgNd'O\Ad.)H77IQ7)?24;X`bBuU\/p]gqOY0-Wd9<CAr-Y!K@O__t&c%m9'lec$pA>oE/C1CrYj8"Dc0SA*lAdG/XrsHJ1X&Jt`@FR2^r,cR=7.SO#"-;H3G)aUd.&)j)"hm?+6'.m)U*AlGn(uJb"CrKc1.p*ci%+>",2N1LmX%S6$PB'hi@[aYTPY71/`b`p"U4rD%>Cb@9Q)&*R@_7l].5W>:<?-WG1qoQk4AcJ$TPX?"'R%_Ujm0MYssCOuTrI"eq0eZhSu/X4rXk9USeC<o\CjMRO*j*o11AdV9&V)AFP'I**5:_+@kd*SUi8M_\&"@>IPE:7m+,'fo*PQG)fDJHBE&Q.#M!ODc`KiO"'?^q6ZiaqNAgBaq[a4b_O'Zsub(6[G]:b$iRf"qQ;S-t*N;C)su$HUO1IJ\e^:Vm@%oD%>+>p!Pc4._N6`1]aV.`C/L5Kg4g[f`7>87Qu1*)I01])LFB_^(i[<9<VNPDBA4sMX@1:MdKKK:Uk[tWq?Q\!dJfuVR,!os5\0?OWs$"W"P$7fp\nto#.upp:WAdjQK$@XHs&>V=db[GJ+,H6(ba]g`6djZS",SCSn]`.2oa_riVpo5ND"8*T#:t)e0Dd<,.'\d'.[#1I5P[dMNS$XSqbKjA%9,/u:a/Ue+pRgWacWf*Cb\;nJU'&Ze)9DYq_\SrmXtnlRSt9X?`h7A5Jid`;P2hbCT>N0]Le2h0N+0\Wqp_%g7aA+VAJ@CD%iTL"\S+E8g0MNimNZ=6Q#3SC\K?GL[2^thl:_.@OC/[4/iV;)b[ri7`6l3i_(!Lk9)T+`ZR3&*8;?9?^Z4^:,iVf=dL):pW(5W^C(CS(CRjkeqhV:2FL!Bh6;GtQ5gWu8JN7CS4plOGIVr$_tbHdh#dh&cEq7nmrl$e#jg&f"raEfdNm$bH&KSbk_9qR'=@^\&SllqSIT[bcr#e[<&n7bi=hV3)s,p.]hYqd1EJgTTk@LYG:50W0G&T7Z^:mK8!@i`gtVOre;d@+X:V'MD1qL$%6f!E5@p=Mb`LrP)+VU&20i)?BTP@i]Cc9BX[K_5%B2^2RGgQ/Yu^Z_UTe[tUSP`+ma4+BebkO11@-4=hO)&mheVP)Nj9i>7th.bP/.+t@Vf($]^UU`-rAnDum4Q[.3~>endstream
endobj
11 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1726
>>
stream
Gatm;mn_\!'`Gb\iee7BV;cWn:"mHC`aD,B0Km!1l)><*a\D*G[3OEToY9:kj.u'E#=2.L-BU>QkPdO*-A#b((]"*[-,>9B]YBCJlGFm&..5&.\V?$a]DD?.J)9'FNPYij%&:WU-;3P&,X&Z&?l')na6/W=;Bq\^RIb^'RH7%-_<,tF6D_o#5:O+ITQ7Q(.ho!KR,G7]oW.3s_W3dP]!(8D:O5T?1@2gt$;@SB8/IZmR&MHppd@ml+A3MY!t_P6$'.dV?S_:&Kjn2#n7WB+7m4B3C?^;q2*-7K,fm>B*!.imarl'?B[)CbYfFdYbi9a<#h*?%*k;iT1"U(S^gmX]TcW0ca__\=Ad=PM[]54iGmZPJ3:3*ti@^lWm,>ISqm>tPeX,Nob8t4m6\[+_[Z4lXWiJ(<7\ejn%Yn9/DNr+=U+XgR8uEB5`YD+XM2f](;e,u^krEJcP=^Sho7tm"PL1oEKgSqb6>9(UFh5PBTIk,NMDCru/<-JYaZ57&5jB:7K@q.[Qi^3Xo+XeaKU6Ka;ULF%b5KD$*"nGBA5]K8M(7McMT0[>c(EF*-mdt+d.+n(;'$+;BGq>nq<NC@rO@Ko8%mjQM?]AXe]@.5]8+H9WH&8UiMq#gO+8u3h._kTOpcciIcT]<7(hU#33:r+ZJ6(7Q*t>EmZYfh@LmhGhj->aW<Tq0Sc.(Pgob0&O**$c[sO42H;.Uhr822T2L[CgdG7qVR[O@M_E!#bcjO-M%#0al6mUXn<]F;<SJ;<5ZVm['^=1A4o*flD,ULG\9;SuP3'P3I&8fH;l,Nh=he;^RQ'T]\*40Fa>MgDh9k]/#>ZH=]bc0on.$q6:^1C>E=B29WNull<9RLjnW5OrpDku$kR?Qfsg3n]^W2Z-<?9d;20kk&]TsNWk5Oi^jZ6n.X!g`T;L6/_ZOrZKVc?*gXL5_oHVq.)q=8Br"fU8(_7Y=t=GLlrKj=A_B?WWE$6lNmFA>UA.Pi>#d%(1("TtM,%lE)bp%j+m<ipQN,qUr"`Bt,]S)6HKq#C1sMRbG-^KZ-gE'E]Np>2eFX6+O_E@8"8J:EP,`q'!q@TYKb,Co?%DTtMtl+E[:`_fK`o5&t@eh&>,u:4luWnGb,lVf5;P`E@af^*8nq()gp-2o+c_<5(E/a93o>bC\+bSe3]_<>(HU(OV:73p9u4_4nH8JiZ,C?PZdUCfD(EW<8**L;%,kUqogJb@Xj<Ic_pRr9g'EoY4`"LMiX;$-i'!pdOKcoT-J?KD7N0p</O2p4d=IV#SJ)qOnMB69_C&*hA7Z?Y')\W_2:cP`Q2$;7:ZDF*klt/\iMgb3-`20`%:n'_putE;QA,AOL&rkCTTrggp1:dDq*9:/u*WFP:^V%BdL_6^V+;^J5;d5T7sC[>*#7b<;ER+2b=+Wb,nEmqNe0>h*90e:C_O6Di_!7"tQh]&#<?@C#NR(``;.,3'GVBA_s7K=7/l$K8+H_Y+Q\s'6AV+lKX3pVUVjR9RX9BC>E6ZZ!Xf9YrEEe0XI)0.WIi$iWHNqogU9@Cf`'N.)LD\1G.3*'#Va4QQC__%'@Ug/!I^QX((A_RRG&DRZ`Dopb>Zo<$a6:?VeFaM!F`Ha>;^.*O8kZsL'VMUS50^@NH#o/QVf<=M[Qq9SH*oo7QOkB9/(P2eL6mBY/$-]OjZ?"GS^*r>c&^+KTfSX,`!2(?=u)a4paB4M]mTp8hog^`g#RdK!QTC(CL_7Q=A_!hI~>endstream
endobj
xref
0 12
0000000000 65535 f
0000000061 00000 n
0000000112 00000 n
0000000219 00000 n
0000000331 00000 n
0000000408 00000 n
0000000602 00000 n
0000000796 00000 n
0000000864 00000 n
0000001125 00000 n
0000001190 00000 n
0000002758 00000 n
trailer
<<
/ID
[<74d07b662bedb5b80492225fc042af8b><74d07b662bedb5b80492225fc042af8b>]
% ReportLab generated PDF document -- digest (opensource)
/Info 8 0 R
/Root 7 0 R
/Size 12
>>
startxref
4576
%%EOF

View File

@ -1,8 +1,8 @@
# Requirements Document # Requirements Document
**Project**: Salesforce Composite Envelope Builder **Project**: Salesforce Composite Envelope Builder
**Version**: 1.1 **Version**: 1.2
**Date**: February 23, 2026 (updated March 11, 2026) **Date**: February 23, 2026 (updated March 15, 2026)
**Author**: Paul Huliganga **Author**: Paul Huliganga
--- ---
@ -46,15 +46,29 @@ Replace combination templates with **28 single-form templates** (14 forms × 2 l
#### FR-006: Multiple Copies of Authorization to Release Information #### FR-006: Multiple Copies of Authorization to Release Information
**Priority**: Medium **Priority**: Medium
**Description**: When the "Authorization to Release Information" template (English or Spanish) is selected, users may include 1, 2, or 3 copies of that form in the same envelope **Description**: When the "Authorization to Release Information" template (English or Spanish) is selected, users may include 1, 2, 3, 4, or 5 copies of that form in the same envelope
**Acceptance Criteria**: **Acceptance Criteria**:
- After template selection, if "Authorization to Release Information" is among the selected templates, an additional dialog screen is displayed before sending - After template selection, if "Authorization to Release Information" is among the selected templates, an additional dialog screen is displayed before sending
- The dialog presents a radio-button selection: **1 copy** (default), **2 copies**, **3 copies** - The dialog presents a radio-button selection: **1 copy** (default), **2 copies**, **3 copies**, **4 copies**, **5 copies**
- If the user selects 2 or 3 copies, the template is added to the envelope that many times, each appearing as a distinct document - If the user selects more than 1 copy, the template is added to the envelope that many times (up to 5), each appearing as a distinct document
- Additional copies are labelled with a `(Copy N)` suffix in the envelope document list so they are distinguishable - Additional copies are labelled with a `(Copy N)` suffix in the envelope document list so they are distinguishable
- If "Authorization to Release Information" is not selected, the dialog is skipped entirely and default behaviour is unchanged - If "Authorization to Release Information" is not selected, the dialog is skipped entirely and default behaviour is unchanged
- The template name used for matching is stored in a single constant (`MULTI_COPY_TEMPLATE_NAME`) in the Apex class and a single string value in the Flow decision, making it straightforward to update if the template is renamed - The template name used for matching is stored in a single constant (`MULTI_COPY_TEMPLATE_NAME`) in the Apex class and a single string value in the Flow decision, making it straightforward to update if the template is renamed
#### FR-007: SMS Delivery for Recipients Without Email
**Priority**: Medium
**Description**: When the primary recipient (Docusign Recipient #1) has no email address, the operator must be able to send the envelope via SMS to the recipient's mobile phone
**Acceptance Criteria**:
- The flow (V4) checks whether the recipient contact has an email address after template selection
- If the email is present, the send proceeds normally (identical behaviour to V3)
- If the email is blank, a screen is displayed prompting the operator to enter the recipient's mobile phone number (E.164 format, e.g. `+15551234567`); this field is required
- Upon entering a phone number, the envelope is sent and the recipient receives a Docusign signing invitation via SMS instead of email
- The Service Coordinator recipient is unaffected and always receives delivery by email
- A placeholder email address is substituted automatically in the Apex layer so the Docusign API requirement for an email field is satisfied without routing any real email to the recipient
- The placeholder email is defined as the constant `SMS_PLACEHOLDER_EMAIL` in `DocusignCompositeEnvelopeBuilder.cls` — **this is the single location to update if the placeholder address ever needs to change**
- **Setup requirement**: The `Docusign_Recipient_1__c` field on the Client Case **must be populated** with a Contact record. The Contact must have a `Name` but **no `Email`** — a null lookup (no Contact at all) will cause the recipient to be omitted from the envelope entirely
- This functionality is delivered via **Flow V4** (`Docusign_Envelope_Templates_V4`); Flow V3 is unchanged
#### FR-003: Single Envelope Generation #### FR-003: Single Envelope Generation
**Priority**: High **Priority**: High
**Description**: All selected forms must be combined into ONE envelope **Description**: All selected forms must be combined into ONE envelope
@ -222,11 +236,28 @@ Replace combination templates with **28 single-form templates** (14 forms × 2 l
- Given I have selected the "Authorization to Release Information" template (English or Spanish) along with any other forms - Given I have selected the "Authorization to Release Information" template (English or Spanish) along with any other forms
- When I click "Send" on the template selection screen - When I click "Send" on the template selection screen
- Then a new dialog appears asking "How many copies of this form should be included in the envelope?" - Then a new dialog appears asking "How many copies of this form should be included in the envelope?"
- And the dialog offers radio-button options: 1 copy (pre-selected), 2 copies, 3 copies -- And the dialog offers radio-button options: 1 copy (pre-selected), 2 copies, 3 copies, 4 copies, 5 copies
- When I select 2 copies and click "Next" - When I select 2 copies and click "Next"
- Then the resulting envelope contains 2 copies of the Authorization form plus all other selected forms - Then the resulting envelope contains 2 copies of the Authorization form plus all other selected forms
- And I see the standard success confirmation after sending - And I see the standard success confirmation after sending
### US-006: Send Envelope via SMS When Recipient Has No Email
**As a** Salesforce user
**I want to** send a Docusign envelope to a recipient who has no email address
**So that** I can complete the signing ceremony without requiring the recipient to have an email account
**Acceptance Criteria**:
- Given I am using Flow V4 and the selected Docusign Recipient #1 contact has no email address on file
- When I proceed past template selection
- Then a screen is displayed informing me that the recipient has no email and that the envelope will be delivered via SMS
- And I am prompted to enter the recipient's mobile phone number (required, E.164 format)
- When I enter a valid phone number and click "Next"
- Then the envelope is sent and the recipient receives a signing invitation by SMS
- And the Service Coordinator recipient still receives their invitation by email
- And I see the standard success confirmation after sending
- Given the recipient contact does have an email address
- Then the SMS phone screen is skipped and sending proceeds as normal
### US-004: View Completed Documents ### US-004: View Completed Documents
**As a** Salesforce user **As a** Salesforce user
**I want to** see completed documents attached to the Salesforce record **I want to** see completed documents attached to the Salesforce record
@ -246,6 +277,7 @@ Replace combination templates with **28 single-form templates** (14 forms × 2 l
- **Salesforce Governor Limits**: Must stay within Apex heap size (6 MB synchronous, 12 MB asynchronous), callout limits (100 per transaction), CPU time - **Salesforce Governor Limits**: Must stay within Apex heap size (6 MB synchronous, 12 MB asynchronous), callout limits (100 per transaction), CPU time
- **Docusign API Rate Limits**: Respect Docusign API rate limits (varies by plan) - **Docusign API Rate Limits**: Respect Docusign API rate limits (varies by plan)
- **Template Limit**: Maximum 14 forms per envelope (business rule) - **Template Limit**: Maximum 14 forms per envelope (business rule)
- **SMS Delivery**: Requires the dfsle Apex Toolkit (Docusign for Salesforce managed package) version that supports `dfsle.Recipient.withSmsDelivery()`; an email field must always be populated on each recipient (placeholder used automatically)
### 5.2 Business Constraints ### 5.2 Business Constraints
- **Single Language Per Envelope**: Cannot mix English and Spanish in one envelope - **Single Language Per Envelope**: Cannot mix English and Spanish in one envelope
@ -286,6 +318,7 @@ Replace combination templates with **28 single-form templates** (14 forms × 2 l
- ✅ All selected forms sent in ONE envelope - ✅ All selected forms sent in ONE envelope
- ✅ Documents written back to Salesforce - ✅ Documents written back to Salesforce
- ✅ No pre-built combination templates needed - ✅ No pre-built combination templates needed
- ✅ Recipients without email can receive envelopes via SMS (Flow V4)
### 8.2 Technical Success ### 8.2 Technical Success
- ✅ Code coverage >75% - ✅ Code coverage >75%
@ -331,6 +364,7 @@ The following are explicitly out of scope for the initial release:
|---------|------|--------|---------| |---------|------|--------|---------|
| 1.0 | 2026-02-23 | Paul Huliganga | Initial release | | 1.0 | 2026-02-23 | Paul Huliganga | Initial release |
| 1.1 | 2026-03-11 | Paul Huliganga | Added FR-006 (multi-copy Authorization to Release Information), US-005, updated constraints | | 1.1 | 2026-03-11 | Paul Huliganga | Added FR-006 (multi-copy Authorization to Release Information), US-005, updated constraints |
| 1.2 | 2026-03-13 | Paul Huliganga | Added FR-007 (SMS delivery for recipients without email), US-006; updated constraints and success criteria |
--- ---

View File

@ -31,14 +31,14 @@ global with sharing class DocusignCompositeEnvelopeBuilder {
// ============================================================ // ============================================================
// SMS DELIVERY: Placeholder email used when the primary recipient // SMS DELIVERY: Placeholder email used when the primary recipient
// (Docusign Recipient #1) has no email and SMS delivery is requested. // (Docusign Recipient #1) has no email address and SMS delivery is
// Docusign requires an email on every recipient even when delivery // requested via recipientSmsPhone. Docusign requires an email on
// is via SMS; this constant satisfies that requirement without // every recipient even when dfsle.Recipient.withSmsDelivery() is used;
// routing any actual email. Update this value if your org uses a // this placeholder satisfies that requirement without routing any
// different placeholder address. // actual email — delivery occurs entirely via SMS.
// ============================================================ // ============================================================
@TestVisible @TestVisible
private static final String SMS_FALLBACK_EMAIL = 'placeholder_email@docusign.com'; private static final String SMS_PLACEHOLDER_EMAIL = 'placeholder_email@docusign.com';
@InvocableMethod( @InvocableMethod(
label='Send Composite Docusign Envelope' label='Send Composite Docusign Envelope'
@ -70,7 +70,7 @@ global with sharing class DocusignCompositeEnvelopeBuilder {
// the list now so the deduplication step handles all IDs uniformly. // the list now so the deduplication step handles all IDs uniformly.
List<String> expandedTemplateIds = new List<String>(req.templateIds); List<String> expandedTemplateIds = new List<String>(req.templateIds);
Integer copies = (req.authReleaseFormCopies != null && req.authReleaseFormCopies > 1) Integer copies = (req.authReleaseFormCopies != null && req.authReleaseFormCopies > 1)
? Math.min(req.authReleaseFormCopies, 3) ? Math.min(req.authReleaseFormCopies, 5)
: 1; : 1;
if (copies > 1) { if (copies > 1) {
// Find which template ID(s) correspond to the multi-copy template // Find which template ID(s) correspond to the multi-copy template
@ -225,33 +225,44 @@ global with sharing class DocusignCompositeEnvelopeBuilder {
bodyIdsAdded.add(templateId); bodyIdsAdded.add(templateId);
} }
} }
String envelopeSubject = combinedName; // Prefix the envelope subject so recipients see the source immediately
String envelopeSubject = 'Docusign: ' + combinedName;
// Truncate subject to 100 characters maximum as required by Docusign // Truncate subject to 100 characters maximum as required by Docusign
if (envelopeSubject.length() > 100) { if (envelopeSubject.length() > 100) {
envelopeSubject = envelopeSubject.left(97) + '...'; envelopeSubject = envelopeSubject.left(97) + '...';
} }
String envelopeBody = bodyParts.isEmpty() ? '' : String.join(bodyParts, '\n\n'); // 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); myEnvelope = myEnvelope.withEmail(envelopeSubject, envelopeBody);
// Send the envelope. // Send the envelope
// When a recipient SMS phone is supplied we bypass the dfsle Toolkit entirely
// because it cannot set additionalNotifications for SMS delivery.
// DocusignSmsEnvelopeService posts directly to the Docusign REST API instead.
if (String.isNotBlank(req.recipientSmsPhone)) {
String envelopeId = DocusignSmsEnvelopeService.sendEnvelope(
req.recordId,
sortedIds,
docNames,
displayNames,
envelopeSubject,
envelopeBody,
req.recipientSmsPhone
);
result.envelopeId = envelopeId;
} else {
myEnvelope = dfsle.EnvelopeService.sendEnvelope(myEnvelope, true); myEnvelope = dfsle.EnvelopeService.sendEnvelope(myEnvelope, true);
// Success
result.envelopeId = String.valueOf(myEnvelope.docuSignId); result.envelopeId = String.valueOf(myEnvelope.docuSignId);
}
result.success = true; result.success = true;
result.errorMessage = null; result.errorMessage = null;
@ -281,10 +292,10 @@ global with sharing class DocusignCompositeEnvelopeBuilder {
* @description Resolves recipients from Client_Case__c lookup fields. * @description Resolves recipients from Client_Case__c lookup fields.
* Queries the case record and related contacts to get name/email. * Queries the case record and related contacts to get name/email.
* @param recordId The Client_Case__c record ID * @param recordId The Client_Case__c record ID
* @param smsPhone Optional SMS phone for the primary recipient when they have no email. * @param smsPhone Optional SMS phone for the primary recipient. When provided,
* When non-blank, buildRecipient will substitute SMS_FALLBACK_EMAIL * the Docusign Recipient #1 is configured for SMS delivery via
* for the Docusign Recipient #1 role instead of throwing an error. * dfsle.Recipient.withSmsDelivery() and a placeholder email is
* (Only relevant for the dfsle path the SMS service resolves its own recipients.) * substituted if the recipient has no email address.
* @return List of dfsle.Recipient objects with role mappings * @return List of dfsle.Recipient objects with role mappings
*/ */
private static List<dfsle.Recipient> resolveRecipients(String recordId, String smsPhone) { private static List<dfsle.Recipient> resolveRecipients(String recordId, String smsPhone) {
@ -300,13 +311,13 @@ global with sharing class DocusignCompositeEnvelopeBuilder {
List<dfsle.Recipient> recipients = new List<dfsle.Recipient>(); List<dfsle.Recipient> recipients = new List<dfsle.Recipient>();
Integer routingOrder = 1; Integer routingOrder = 1;
// Recipient 1: Service Coordinator // Recipient 1: Service Coordinator (always email delivery)
Id serviceCoordinatorId = (Id) caseRecord.get(FIELD_SERVICE_COORDINATOR); Id serviceCoordinatorId = (Id) caseRecord.get(FIELD_SERVICE_COORDINATOR);
if (serviceCoordinatorId != null) { if (serviceCoordinatorId != null) {
recipients.add(buildRecipient(serviceCoordinatorId, ROLE_SERVICE_COORDINATOR, routingOrder++, recordId, null)); recipients.add(buildRecipient(serviceCoordinatorId, ROLE_SERVICE_COORDINATOR, routingOrder++, recordId, null));
} }
// Recipient 2: Docusign Recipient #1 // Recipient 2: Docusign Recipient #1 (SMS delivery when smsPhone is provided)
Id docusignRecipientId = (Id) caseRecord.get(FIELD_DOCUSIGN_RECIPIENT); Id docusignRecipientId = (Id) caseRecord.get(FIELD_DOCUSIGN_RECIPIENT);
if (docusignRecipientId != null) { if (docusignRecipientId != null) {
recipients.add(buildRecipient(docusignRecipientId, ROLE_DOCUSIGN_RECIPIENT, routingOrder++, recordId, smsPhone)); recipients.add(buildRecipient(docusignRecipientId, ROLE_DOCUSIGN_RECIPIENT, routingOrder++, recordId, smsPhone));
@ -321,16 +332,17 @@ global with sharing class DocusignCompositeEnvelopeBuilder {
} }
/** /**
* @description Builds a dfsle.Recipient from a Contact/User lookup ID * @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 recipientId The Contact or User record ID
* @param roleName The Docusign template role name * @param roleName The Docusign template role name (must match exactly)
* @param routingOrder Signing order * @param routingOrder Signing order
* @param sourceRecordId The source Client_Case__c record ID * @param sourceRecordId The source Client_Case__c record ID
* @param smsPhone Optional SMS phone number for the Docusign Recipient #1 role. * @param smsPhone Optional phone number for SMS delivery. Null for email delivery.
* When non-blank and the recipient has no email, SMS_FALLBACK_EMAIL
* is substituted so the dfsle Toolkit call can proceed.
* (The actual SMS delivery notification is handled separately by
* DocusignSmsEnvelopeService this path is a safety fallback only.)
* @return dfsle.Recipient configured for the role * @return dfsle.Recipient configured for the role
*/ */
private static dfsle.Recipient buildRecipient(Id recipientId, String roleName, Integer routingOrder, String sourceRecordId, String smsPhone) { private static dfsle.Recipient buildRecipient(Id recipientId, String roleName, Integer routingOrder, String sourceRecordId, String smsPhone) {
@ -354,23 +366,30 @@ global with sharing class DocusignCompositeEnvelopeBuilder {
} }
if (String.isBlank(recipientEmail)) { if (String.isBlank(recipientEmail)) {
if (roleName == ROLE_DOCUSIGN_RECIPIENT && String.isNotBlank(smsPhone)) { if (String.isNotBlank(smsPhone)) {
// Recipient has no email but SMS delivery is requested — substitute // SMS delivery requested — substitute placeholder email so the dfsle
// the placeholder email so the dfsle Toolkit call does not throw. // Toolkit can create the recipient. Actual delivery is via SMS only.
recipientEmail = SMS_FALLBACK_EMAIL; recipientEmail = SMS_PLACEHOLDER_EMAIL;
} else { } else {
throw new IllegalArgumentException('No email found for ' + roleName + ' (' + recipientName + '). ' throw new IllegalArgumentException('No email found for ' + roleName + ' (' + recipientName + '). '
+ 'Please ensure the recipient has a valid email address.'); + 'Please ensure the recipient has a valid email address.');
} }
} }
return dfsle.Recipient.fromSource( dfsle.Recipient recipient = dfsle.Recipient.fromSource(
recipientName, recipientName,
recipientEmail, recipientEmail,
null, // phone (optional) null, // phone (not used — SMS delivery set below)
roleName, // must match template role exactly roleName, // must match template role exactly
new dfsle.Entity(sourceRecordId) // source record for merge fields 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) { private static void logResult(Integer templateCount, String envelopeId, String status, String errorMessage) {

View File

@ -1,251 +0,0 @@
/**
* @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<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)
);
// Build document list from templates (deduplicated and sorted)
List<String> sortedTemplateIds = new List<String>(new Set<String>(req.templateIds));
sortedTemplateIds.sort();
// Query template names for document labels (shows in Docusign Status)
Map<String, String> templateNames = new Map<String, String>();
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);
}
List<dfsle.Document> documents = new List<dfsle.Document>();
List<String> docNames = new List<String>();
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<dfsle.Recipient> recipients = resolveRecipients(req.recordId);
myEnvelope = myEnvelope.withRecipients(recipients);
// Set email subject if provided
if (String.isNotBlank(req.emailSubject)) {
myEnvelope = myEnvelope.withEmail(req.emailSubject, '');
}
// 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<dfsle.Recipient> 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<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));
}
// 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<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 };
}
}

View File

@ -3,9 +3,10 @@
* @author Paul Huliganga * @author Paul Huliganga
* @date 2026-02-25 * @date 2026-02-25
*/ */
@isTest @isTest(SeeAllData=true)
private class DocusignCompositeEnvelopeBuilderTest { private class DocusignCompositeEnvelopeBuilderTest {
@isTest @isTest
static void testSuccessfulCompositeEnvelope() { static void testSuccessfulCompositeEnvelope() {
// Arrange // Arrange
@ -17,7 +18,7 @@ private class DocusignCompositeEnvelopeBuilderTest {
'01234567-abcd-ef01-2345-6789abcdef02', '01234567-abcd-ef01-2345-6789abcdef02',
'01234567-abcd-ef01-2345-6789abcdef03' '01234567-abcd-ef01-2345-6789abcdef03'
}; };
req.recordId = '001000000ABC123'; req.recordId = getTestCaseId();
req.language = 'en'; req.language = 'en';
req.emailSubject = 'Please sign these forms'; req.emailSubject = 'Please sign these forms';
@ -43,7 +44,7 @@ private class DocusignCompositeEnvelopeBuilderTest {
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'}; req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = '001000000ABC123'; req.recordId = getTestCaseId();
// Act // Act
Test.startTest(); Test.startTest();
@ -69,7 +70,7 @@ private class DocusignCompositeEnvelopeBuilderTest {
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = templateIds; req.templateIds = templateIds;
req.recordId = '001000000ABC123'; req.recordId = getTestCaseId();
// Act // Act
Test.startTest(); Test.startTest();
@ -94,7 +95,7 @@ private class DocusignCompositeEnvelopeBuilderTest {
'01234567-abcd-ef01-2345-6789abcdef02', '01234567-abcd-ef01-2345-6789abcdef02',
'01234567-abcd-ef01-2345-6789abcdef01' // duplicate '01234567-abcd-ef01-2345-6789abcdef01' // duplicate
}; };
req.recordId = '001000000ABC123'; req.recordId = getTestCaseId();
// Act // Act
Test.startTest(); Test.startTest();
@ -227,7 +228,7 @@ private class DocusignCompositeEnvelopeBuilderTest {
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'}; req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = '001000000ABC123'; req.recordId = getTestCaseId();
req.emailSubject = 'Custom: Please review and sign'; req.emailSubject = 'Custom: Please review and sign';
// Act // Act
@ -249,7 +250,7 @@ private class DocusignCompositeEnvelopeBuilderTest {
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest(); DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'}; req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = '001000000ABC123'; req.recordId = getTestCaseId();
req.emailSubject = null; req.emailSubject = null;
// Act // Act
@ -278,7 +279,7 @@ private class DocusignCompositeEnvelopeBuilderTest {
'01234567-abcd-ef01-2345-6789abcdef04', '01234567-abcd-ef01-2345-6789abcdef04',
'01234567-abcd-ef01-2345-6789abcdef05' '01234567-abcd-ef01-2345-6789abcdef05'
}; };
req.recordId = '001000000ABC123'; req.recordId = getTestCaseId();
// Act // Act
Test.startTest(); Test.startTest();
@ -291,4 +292,87 @@ private class DocusignCompositeEnvelopeBuilderTest {
// Assert // Assert
System.assertEquals(true, results[0].success, 'Should succeed with truncated subject'); System.assertEquals(true, results[0].success, 'Should succeed with truncated subject');
} }
@isTest
static void testSmsDeliveryPath() {
// Arrange - use existing sandbox case; SMS delivery path exercises withSmsDelivery()
dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = getTestCaseId();
req.recipientSmsPhone = '+15551234567';
// Act
Test.startTest();
List<DocusignEnvelopeResult> results = DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignEnvelopeRequest>{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<String>{'01234567-abcd-ef01-2345-6789abcdef01'};
req.recordId = getTestCaseId();
req.language = 'es';
// Act
Test.startTest();
List<DocusignEnvelopeResult> results = DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignEnvelopeRequest>{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-6789abcdef99',
dfsle__EmailMessage__c = 'Please sign this release.'
);
insert cfg;
dfsle.TestUtils.setMock(new dfsle.ESignatureAPIMock());
DocusignEnvelopeRequest req = new DocusignEnvelopeRequest();
req.templateIds = new List<String>{'01234567-abcd-ef01-2345-6789abcdef99'};
req.recordId = getTestCaseId();
req.authReleaseFormCopies = 3;
// Act
Test.startTest();
List<DocusignEnvelopeResult> results = DocusignCompositeEnvelopeBuilder.sendCompositeEnvelope(new List<DocusignEnvelopeRequest>{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. Error: ' + results[0].errorMessage);
}
private static Id getTestCaseId() {
// Sandbox: a0tPo000010ZsTFIA0 (case 00520074)
// Production fallback: case number 00496136
List<Client_Case__c> cases = [
SELECT Id FROM Client_Case__c
WHERE Id = 'a0tPo000010ZsTFIA0'
OR Name = '00496136'
LIMIT 1
];
if (!cases.isEmpty()) {
return cases[0].Id;
}
throw new TestSetupException(
'No test Client_Case__c record found. '
+ 'Expected sandbox case 00520074 (id a0tPo000010ZsTFIA0) or production case 00496136.'
);
}
class TestSetupException extends Exception {}
} }

View File

@ -35,14 +35,14 @@ global class DocusignEnvelopeRequest {
@InvocableVariable( @InvocableVariable(
label='Authorization to Release Form Copies' label='Authorization to Release Form Copies'
description='Number of times to include the Authorization to Release Information template (1-3). Only used when that template is selected.' description='Number of times to include the Authorization to Release Information template (1-5). Only used when that template is selected.'
required=false required=false
) )
global Integer authReleaseFormCopies; global Integer authReleaseFormCopies;
@InvocableVariable( @InvocableVariable(
label='Recipient SMS Phone' label='Recipient SMS Phone'
description='Mobile phone number for SMS delivery when the primary recipient (Docusign Recipient #1) has no email address. E.164 format preferred (e.g. +15551234567). When provided, the envelope is sent via direct Docusign REST API instead of the dfsle Toolkit so that additionalNotifications/SMS delivery can be set.' 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 required=false
) )
global String recipientSmsPhone; global String recipientSmsPhone;

View File

@ -1,414 +0,0 @@
/**
* @description Sends a Docusign composite envelope via direct REST API call when the
* primary recipient (Docusign Recipient #1) does not have an email address
* and requires SMS delivery instead.
*
* The dfsle Apex Toolkit does not support the Docusign
* additionalNotifications/secondaryDeliveryMethod property needed for
* SMS-only recipients, so this service bypasses the toolkit and calls
* the Docusign REST API directly.
*
* Prerequisites:
* - Named Credential "DocusignAPI" must be configured in the org.
* - Docusign account must have SMS delivery enabled.
* - Docusign_Configuration__c custom setting must have Account_Id__c populated.
*
* @author Paul Huliganga
* @date 2026-02-25
*/
global with sharing class DocusignSmsEnvelopeService {
// Named Credential for Docusign REST API callouts
@TestVisible
private static final String NAMED_CREDENTIAL = 'callout:DocusignAPI';
// Endpoint path template — {0} is replaced with the accountId
@TestVisible
private static final String ENVELOPES_PATH = '/accounts/{0}/envelopes';
// Dummy email used for SMS-only recipients. Docusign requires an email address
// on every recipient even when delivery is via SMS. This placeholder satisfies
// that requirement without routing any actual email.
@TestVisible
private static final String SMS_PLACEHOLDER_EMAIL = 'placeholder_email@docusign.com';
// Country code assumed when the caller supplies a bare 10-digit US number.
// The flow enforces E.164 format (e.g. +15551234567) so this is a safety fallback.
private static final String DEFAULT_COUNTRY_CODE = '1';
/**
* @description Builds and sends a composite Docusign envelope with SMS delivery for
* the primary recipient. Mirrors the logic in DocusignCompositeEnvelopeBuilder
* but constructs the JSON payload manually so that additionalNotifications
* can be included on the recipient object.
*
* @param recordId The Client_Case__c record ID (used to look up recipients)
* @param sortedIds Template IDs in sorted order (may contain duplicates for multi-copy)
* @param docNames Document labels in same order as sortedIds
* @param displayNames Deduplicated display labels for email subject / body
* @param envelopeSubject Email subject line (already formatted and truncated)
* @param envelopeBody Email body text (already formatted and deduplicated)
* @param smsPhone Mobile phone number for SMS delivery (E.164 preferred, e.g. +15551234567)
* @return Docusign envelope ID string on success
* @throws IllegalArgumentException when recipients or configuration data are missing
* @throws CalloutException on HTTP errors
*/
global static String sendEnvelope(
String recordId,
List<String> sortedIds,
List<String> docNames,
List<String> displayNames,
String envelopeSubject,
String envelopeBody,
String smsPhone
) {
// ─── Fetch Docusign account ID from custom setting ───────────────────────────
Docusign_Configuration__c config = Docusign_Configuration__c.getInstance();
if (config == null || String.isBlank(config.Account_Id__c)) {
throw new IllegalArgumentException(
'Docusign_Configuration__c is not configured. '
+ 'Please set Account_Id__c in the Docusign Configuration custom setting.'
);
}
String accountId = config.Account_Id__c;
// ─── Resolve recipients from Client_Case__c ───────────────────────────────────
RecipientInfo serviceCoordinator = null;
RecipientInfo docusignRecipient = null;
String query = 'SELECT Id, Service_Coordinator__c, Docusign_Recipient_1__c '
+ 'FROM Client_Case__c WHERE Id = :recordId LIMIT 1';
Client_Case__c caseRecord = Database.query(query);
Id scId = (Id) caseRecord.get('Service_Coordinator__c');
Id drId = (Id) caseRecord.get('Docusign_Recipient_1__c');
if (scId != null) {
serviceCoordinator = resolveRecipient(scId, 'Service Coordinator', 1, null, false);
}
if (drId != null) {
// SMS phone provided — substitute placeholder email for the Docusign Recipient
docusignRecipient = resolveRecipient(drId, 'Docusign Recipient #1', 2, smsPhone, true);
}
if (serviceCoordinator == null && docusignRecipient == null) {
throw new IllegalArgumentException(
'No recipients found on the Client Case record. '
+ 'Please ensure Service Coordinator and Docusign Recipient #1 are populated.'
);
}
// ─── Build JSON body ─────────────────────────────────────────────────────────
String jsonBody = buildEnvelopeJson(
sortedIds,
docNames,
envelopeSubject,
envelopeBody,
serviceCoordinator,
docusignRecipient,
recordId
);
// ─── Call the Docusign REST API ───────────────────────────────────────────────
String endpoint = NAMED_CREDENTIAL
+ String.format(ENVELOPES_PATH, new List<Object>{ accountId });
HttpRequest httpReq = new HttpRequest();
httpReq.setEndpoint(endpoint);
httpReq.setMethod('POST');
httpReq.setHeader('Content-Type', 'application/json');
httpReq.setHeader('Accept', 'application/json');
httpReq.setBody(jsonBody);
Http http = new Http();
HttpResponse httpResp = http.send(httpReq);
Integer statusCode = httpResp.getStatusCode();
String responseBody = httpResp.getBody();
if (statusCode == 200 || statusCode == 201) {
// Parse envelopeId from response
Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(responseBody);
Object envelopeIdObj = responseMap.get('envelopeId');
if (envelopeIdObj == null) {
throw new CalloutException(
'Docusign API returned success but no envelopeId in response: ' + responseBody
);
}
return String.valueOf(envelopeIdObj);
} else {
// Try to extract a meaningful error message from the response JSON
String errorDetail = parseDocusignError(responseBody);
throw new CalloutException(
'Docusign API returned HTTP ' + statusCode + ': ' + errorDetail
);
}
}
// ─────────────────────────────────────────────────────────────────────────────────
// PRIVATE HELPERS
// ─────────────────────────────────────────────────────────────────────────────────
/**
* @description Resolves a recipient's name and email from a Contact or User record.
* When applySmsPlaceholder is true and the record has no email,
* the SMS_PLACEHOLDER_EMAIL constant is used instead.
*/
private static RecipientInfo resolveRecipient(
Id recipientId,
String roleName,
Integer routingOrder,
String smsPhone,
Boolean applySmsPlaceholder
) {
String objectType = recipientId.getSObjectType().getDescribe().getName();
String name;
String email;
if (objectType == 'Contact') {
Contact c = [SELECT Id, Name, Email FROM Contact WHERE Id = :recipientId LIMIT 1];
name = c.Name;
email = c.Email;
} else if (objectType == 'User') {
User u = [SELECT Id, Name, Email FROM User WHERE Id = :recipientId LIMIT 1];
name = u.Name;
email = u.Email;
} else {
throw new IllegalArgumentException(
'Unsupported recipient type: ' + objectType + '. Expected Contact or User.'
);
}
if (String.isBlank(email)) {
if (applySmsPlaceholder) {
email = SMS_PLACEHOLDER_EMAIL;
} else {
throw new IllegalArgumentException(
'No email found for ' + roleName + ' (' + name + '). '
+ 'Please ensure the recipient has a valid email address.'
);
}
}
RecipientInfo info = new RecipientInfo();
info.name = name;
info.email = email;
info.roleName = roleName;
info.routingOrder = routingOrder;
info.smsPhone = applySmsPlaceholder ? smsPhone : null;
return info;
}
/**
* @description Constructs the full envelope JSON payload for the Docusign REST API.
* Uses compositeTemplates so that all selected templates are merged into
* a single envelope, matching what the dfsle Toolkit does internally.
*
* Docusign compositeTemplates reference:
* https://developers.docusign.com/docs/esign-rest-api/reference/envelopes/envelopes/create/
*/
@TestVisible
private static String buildEnvelopeJson(
List<String> sortedIds,
List<String> docNames,
String envelopeSubject,
String envelopeBody,
RecipientInfo serviceCoordinator,
RecipientInfo docusignRecipient,
String sourceRecordId
) {
// Build the recipients object (shared across all compositeTemplates)
// Each recipient gets a unique recipientId (sequential string integer).
List<Object> signers = new List<Object>();
Integer recipientIdCounter = 1;
if (serviceCoordinator != null) {
signers.add(buildSignerMap(serviceCoordinator, String.valueOf(recipientIdCounter++)));
}
if (docusignRecipient != null) {
signers.add(buildSignerMap(docusignRecipient, String.valueOf(recipientIdCounter++)));
}
Map<String, Object> recipientsMap = new Map<String, Object>{
'signers' => signers
};
// Build compositeTemplates array — one entry per document (template ID + label)
List<Object> compositeTemplates = new List<Object>();
for (Integer i = 0; i < sortedIds.size(); i++) {
String templateId = sortedIds[i];
String docLabel = docNames[i];
Map<String, Object> serverTemplate = new Map<String, Object>{
'sequence' => '1',
'templateId' => templateId
};
// Inline template overrides — set the document label and wire up role assignments
List<Object> inlineRecipientSigners = new List<Object>();
Integer irCounter = 1;
if (serviceCoordinator != null) {
inlineRecipientSigners.add(new Map<String, Object>{
'recipientId' => String.valueOf(irCounter++),
'roleName' => serviceCoordinator.roleName,
'name' => serviceCoordinator.name,
'email' => serviceCoordinator.email,
'routingOrder' => String.valueOf(serviceCoordinator.routingOrder)
});
}
if (docusignRecipient != null) {
Map<String, Object> drSigner = new Map<String, Object>{
'recipientId' => String.valueOf(irCounter++),
'roleName' => docusignRecipient.roleName,
'name' => docusignRecipient.name,
'email' => docusignRecipient.email,
'routingOrder' => String.valueOf(docusignRecipient.routingOrder)
};
// Add SMS additionalNotifications for this recipient
if (String.isNotBlank(docusignRecipient.smsPhone)) {
String[] phoneParts = parsePhone(docusignRecipient.smsPhone);
drSigner.put('additionalNotifications', new List<Object>{
new Map<String, Object>{
'secondaryDeliveryMethod' => 'SMS',
'phoneNumber' => new Map<String, Object>{
'countryCode' => phoneParts[0],
'number' => phoneParts[1]
}
}
});
}
inlineRecipientSigners.add(drSigner);
}
Map<String, Object> inlineTemplate = new Map<String, Object>{
'sequence' => '2',
'recipients' => new Map<String, Object>{
'signers' => inlineRecipientSigners
},
'document' => new Map<String, Object>{
'name' => docLabel,
'documentId' => String.valueOf(i + 1),
'transformPdfFields' => 'true'
}
};
compositeTemplates.add(new Map<String, Object>{
'serverTemplates' => new List<Object>{ serverTemplate },
'inlineTemplates' => new List<Object>{ inlineTemplate },
'recipients' => recipientsMap
});
}
Map<String, Object> envelopeDefinition = new Map<String, Object>{
'status' => 'sent',
'emailSubject' => envelopeSubject,
'emailBlurb' => envelopeBody,
'compositeTemplates' => compositeTemplates
};
return JSON.serialize(envelopeDefinition);
}
/**
* @description Builds the signer map for a recipient in the envelope-level recipients object.
* Adds additionalNotifications for SMS recipients.
*/
private static Map<String, Object> buildSignerMap(RecipientInfo info, String recipientId) {
Map<String, Object> signer = new Map<String, Object>{
'recipientId' => recipientId,
'name' => info.name,
'email' => info.email,
'roleName' => info.roleName,
'routingOrder' => String.valueOf(info.routingOrder)
};
if (String.isNotBlank(info.smsPhone)) {
String[] phoneParts = parsePhone(info.smsPhone);
signer.put('additionalNotifications', new List<Object>{
new Map<String, Object>{
'secondaryDeliveryMethod' => 'SMS',
'phoneNumber' => new Map<String, Object>{
'countryCode' => phoneParts[0],
'number' => phoneParts[1]
}
}
});
}
return signer;
}
/**
* @description Parses a phone number into [countryCode, nationalNumber].
* Accepts E.164 format (+15551234567) or a bare 10-digit number.
* Returns [DEFAULT_COUNTRY_CODE, strippedNumber] for bare numbers.
*
* @param phone Raw phone string from the flow input
* @return String array: index 0 = country code, index 1 = national number (digits only)
*/
@TestVisible
private static String[] parsePhone(String phone) {
if (String.isBlank(phone)) {
return new String[]{ DEFAULT_COUNTRY_CODE, '' };
}
String stripped = phone.replaceAll('[^\\d+]', ''); // keep digits and leading +
if (stripped.startsWith('+')) {
// E.164: determine country code length heuristically
// We only support 1-digit (+1) and 2-digit (+XX) country codes for now
String digits = stripped.substring(1); // remove leading +
if (digits.length() == 11 && digits.startsWith('1')) {
// +1XXXXXXXXXX (North American Numbering Plan)
return new String[]{ '1', digits.substring(1) };
} else if (digits.length() == 12) {
// +XXXXXXXXXXXX (2-digit country code + 10 digit number)
return new String[]{ digits.left(2), digits.substring(2) };
} else {
// Fall back: treat first digit(s) as country code not supported; use full digits
return new String[]{ DEFAULT_COUNTRY_CODE, digits };
}
}
// Bare digits — assume DEFAULT_COUNTRY_CODE
return new String[]{ DEFAULT_COUNTRY_CODE, stripped };
}
/**
* @description Attempts to extract a human-readable error message from a Docusign
* REST API error response JSON.
* @param responseBody Raw HTTP response body
* @return Error string with errorCode and message if available, otherwise raw body
*/
private static String parseDocusignError(String responseBody) {
if (String.isBlank(responseBody)) {
return '(no response body)';
}
try {
Map<String, Object> errorMap = (Map<String, Object>) JSON.deserializeUntyped(responseBody);
String errorCode = errorMap.containsKey('errorCode') ? String.valueOf(errorMap.get('errorCode')) : null;
String message = errorMap.containsKey('message') ? String.valueOf(errorMap.get('message')) : null;
if (String.isNotBlank(errorCode) || String.isNotBlank(message)) {
return (errorCode != null ? '[' + errorCode + '] ' : '') + (message != null ? message : '');
}
} catch (Exception ex) {
// Non-JSON response — return raw body below
}
return responseBody.left(500); // cap at 500 chars to avoid log spam
}
// ─────────────────────────────────────────────────────────────────────────────────
// INNER CLASSES
// ─────────────────────────────────────────────────────────────────────────────────
/**
* @description Lightweight holder for resolved recipient data.
*/
private class RecipientInfo {
String name;
String email;
String roleName;
Integer routingOrder;
String smsPhone; // null for email recipients; phone string for SMS recipients
}
}

View File

@ -35,12 +35,6 @@
<elementReference>authReleaseFormCopies</elementReference> <elementReference>authReleaseFormCopies</elementReference>
</value> </value>
</inputParameters> </inputParameters>
<inputParameters>
<name>recipientSmsPhone</name>
<value>
<elementReference>recipientSmsPhone</elementReference>
</value>
</inputParameters>
<nameSegment>DocusignCompositeEnvelopeBuilder</nameSegment> <nameSegment>DocusignCompositeEnvelopeBuilder</nameSegment>
<offset>0</offset> <offset>0</offset>
<outputParameters> <outputParameters>
@ -185,7 +179,7 @@
<name>Is_Language_Selected</name> <name>Is_Language_Selected</name>
<label>Is Language Selected?</label> <label>Is Language Selected?</label>
<locationX>611</locationX> <locationX>611</locationX>
<locationY>458</locationY> <locationY>242</locationY>
<defaultConnector> <defaultConnector>
<targetReference>Language_Not_Added_Screen</targetReference> <targetReference>Language_Not_Added_Screen</targetReference>
</defaultConnector> </defaultConnector>
@ -231,109 +225,6 @@
<label>Yes</label> <label>Yes</label>
</rules> </rules>
</decisions> </decisions>
<recordLookups>
<name>Get_Recipient_Contact</name>
<label>Get Recipient Contact</label>
<locationX>611</locationX>
<locationY>242</locationY>
<assignNullValuesIfNoRecordsFound>true</assignNullValuesIfNoRecordsFound>
<connector>
<targetReference>Is_Recipient_Email_Blank</targetReference>
</connector>
<filterLogic>and</filterLogic>
<filters>
<field>Id</field>
<operator>EqualTo</operator>
<value>
<elementReference>Get_Records.Docusign_Recipient_1__c</elementReference>
</value>
</filters>
<getFirstRecordOnly>true</getFirstRecordOnly>
<object>Contact</object>
<queriedFields>Id</queriedFields>
<queriedFields>Email</queriedFields>
<storeOutputAutomatically>true</storeOutputAutomatically>
</recordLookups>
<decisions>
<name>Is_Recipient_Email_Blank</name>
<label>Is Recipient Email Blank?</label>
<locationX>611</locationX>
<locationY>350</locationY>
<defaultConnector>
<targetReference>Is_Language_Selected</targetReference>
</defaultConnector>
<defaultConnectorLabel>Has Email - Continue</defaultConnectorLabel>
<rules>
<name>Recipient_Has_No_Email</name>
<conditionLogic>or</conditionLogic>
<conditions>
<leftValueReference>Get_Recipient_Contact.Email</leftValueReference>
<operator>IsNull</operator>
<rightValue>
<booleanValue>true</booleanValue>
</rightValue>
</conditions>
<conditions>
<leftValueReference>Get_Recipient_Contact.Email</leftValueReference>
<operator>EqualTo</operator>
<rightValue>
<stringValue></stringValue>
</rightValue>
</conditions>
<connector>
<targetReference>SMS_Required_Screen</targetReference>
</connector>
<label>No Email - SMS Required</label>
</rules>
</decisions>
<screens>
<name>SMS_Required_Screen</name>
<label>SMS Delivery Required</label>
<locationX>842</locationX>
<locationY>458</locationY>
<allowBack>false</allowBack>
<allowFinish>true</allowFinish>
<allowPause>false</allowPause>
<connector>
<targetReference>Is_Language_Selected</targetReference>
</connector>
<fields>
<name>SmsRequiredNotice</name>
<fieldText>&lt;p&gt;⚠️ The primary recipient &lt;strong&gt;({!Get_Records.Docusign_Recipient_1__c})&lt;/strong&gt; does not have an email address on file.&lt;/p&gt;&lt;p&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;The DocuSign envelope will be delivered via &lt;strong&gt;SMS text message&lt;/strong&gt; instead. Please enter the recipient&apos;s mobile phone number below.&lt;/p&gt;&lt;p&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;Include the country code in E.164 format, e.g. &lt;strong&gt;+15551234567&lt;/strong&gt; for a US number.&lt;/p&gt;</fieldText>
<fieldType>DisplayText</fieldType>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<fields>
<name>recipientSmsPhone_Input</name>
<dataType>String</dataType>
<fieldText>Mobile Phone Number</fieldText>
<fieldType>InputField</fieldType>
<helpText>Enter the recipient&apos;s mobile phone number in E.164 format (e.g. +15551234567). The country code and + prefix are required for international numbers.</helpText>
<isRequired>true</isRequired>
<outputParameters>
<assignToReference>recipientSmsPhone</assignToReference>
<name>value</name>
</outputParameters>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>6</stringValue>
</width>
</styleProperties>
</fields>
<nextOrFinishButtonLabel>Next</nextOrFinishButtonLabel>
<showFooter>true</showFooter>
<showHeader>true</showHeader>
</screens>
<environments>Default</environments> <environments>Default</environments>
<interviewLabel>Docusign Envelope Templates V3 {!$Flow.CurrentDateTime}</interviewLabel> <interviewLabel>Docusign Envelope Templates V3 {!$Flow.CurrentDateTime}</interviewLabel>
<label>Docusign Envelope Templates V3</label> <label>Docusign Envelope Templates V3</label>
@ -424,7 +315,7 @@
<locationY>134</locationY> <locationY>134</locationY>
<assignNullValuesIfNoRecordsFound>false</assignNullValuesIfNoRecordsFound> <assignNullValuesIfNoRecordsFound>false</assignNullValuesIfNoRecordsFound>
<connector> <connector>
<targetReference>Get_Recipient_Contact</targetReference> <targetReference>Is_Language_Selected</targetReference>
</connector> </connector>
<filterLogic>and</filterLogic> <filterLogic>and</filterLogic>
<filters> <filters>
@ -438,7 +329,6 @@
<object>Client_Case__c</object> <object>Client_Case__c</object>
<queriedFields>Id</queriedFields> <queriedFields>Id</queriedFields>
<queriedFields>Docusign_Envelope_Language__c</queriedFields> <queriedFields>Docusign_Envelope_Language__c</queriedFields>
<queriedFields>Docusign_Recipient_1__c</queriedFields>
<storeOutputAutomatically>true</storeOutputAutomatically> <storeOutputAutomatically>true</storeOutputAutomatically>
</recordLookups> </recordLookups>
<screens> <screens>
@ -537,6 +427,8 @@
<choiceReferences>AuthCopies_1</choiceReferences> <choiceReferences>AuthCopies_1</choiceReferences>
<choiceReferences>AuthCopies_2</choiceReferences> <choiceReferences>AuthCopies_2</choiceReferences>
<choiceReferences>AuthCopies_3</choiceReferences> <choiceReferences>AuthCopies_3</choiceReferences>
<choiceReferences>AuthCopies_4</choiceReferences>
<choiceReferences>AuthCopies_5</choiceReferences>
<dataType>Number</dataType> <dataType>Number</dataType>
<defaultSelectedChoiceReference>AuthCopies_1</defaultSelectedChoiceReference> <defaultSelectedChoiceReference>AuthCopies_1</defaultSelectedChoiceReference>
<fieldText>Number of Copies</fieldText> <fieldText>Number of Copies</fieldText>
@ -746,13 +638,6 @@
<booleanValue>false</booleanValue> <booleanValue>false</booleanValue>
</value> </value>
</variables> </variables>
<variables>
<name>recipientSmsPhone</name>
<dataType>String</dataType>
<isCollection>false</isCollection>
<isInput>false</isInput>
<isOutput>false</isOutput>
</variables>
<choices> <choices>
<name>AuthCopies_1</name> <name>AuthCopies_1</name>
<choiceText>1 copy</choiceText> <choiceText>1 copy</choiceText>
@ -777,4 +662,20 @@
<numberValue>3.0</numberValue> <numberValue>3.0</numberValue>
</value> </value>
</choices> </choices>
<choices>
<name>AuthCopies_4</name>
<choiceText>4 copies</choiceText>
<dataType>Number</dataType>
<value>
<numberValue>4.0</numberValue>
</value>
</choices>
<choices>
<name>AuthCopies_5</name>
<choiceText>5 copies</choiceText>
<dataType>Number</dataType>
<value>
<numberValue>5.0</numberValue>
</value>
</choices>
</Flow> </Flow>

View File

@ -0,0 +1,812 @@
<?xml version="1.0" encoding="UTF-8"?>
<Flow xmlns="http://soap.sforce.com/2006/04/metadata">
<actionCalls>
<name>Send_Composite_Envelope</name>
<label>Send Composite Envelope</label>
<locationX>182</locationX>
<locationY>2498</locationY>
<actionName>DocusignCompositeEnvelopeBuilder</actionName>
<actionType>apex</actionType>
<connector>
<targetReference>Check_Envelope_Result</targetReference>
</connector>
<flowTransactionModel>Automatic</flowTransactionModel>
<inputParameters>
<name>templateIds</name>
<value>
<elementReference>compositeTemplateIds</elementReference>
</value>
</inputParameters>
<inputParameters>
<name>recordId</name>
<value>
<elementReference>recordId</elementReference>
</value>
</inputParameters>
<inputParameters>
<name>language</name>
<value>
<elementReference>Get_Records.Docusign_Envelope_Language__c</elementReference>
</value>
</inputParameters>
<inputParameters>
<name>authReleaseFormCopies</name>
<value>
<elementReference>authReleaseFormCopies</elementReference>
</value>
</inputParameters>
<inputParameters>
<name>recipientSmsPhone</name>
<value>
<elementReference>recipientSmsPhone</elementReference>
</value>
</inputParameters>
<nameSegment>DocusignCompositeEnvelopeBuilder</nameSegment>
<offset>0</offset>
<outputParameters>
<assignToReference>envelopeId</assignToReference>
<name>envelopeId</name>
</outputParameters>
<outputParameters>
<assignToReference>envelopeSuccess</assignToReference>
<name>success</name>
</outputParameters>
<outputParameters>
<assignToReference>envelopeErrorMessage</assignToReference>
<name>errorMessage</name>
</outputParameters>
</actionCalls>
<apiVersion>60.0</apiVersion>
<areMetricsLoggedToDataCloud>false</areMetricsLoggedToDataCloud>
<assignments>
<name>Add_Template_ID</name>
<label>Add Template ID</label>
<locationX>270</locationX>
<locationY>2306</locationY>
<assignmentItems>
<assignToReference>compositeTemplateIds</assignToReference>
<operator>Add</operator>
<value>
<elementReference>Build_Template_ID_Collection.dfsle__DocuSignId__c</elementReference>
</value>
</assignmentItems>
<connector>
<targetReference>Build_Template_ID_Collection</targetReference>
</connector>
</assignments>
<assignments>
<name>Flag_Auth_Release_Selected</name>
<label>Flag Authorization to Release Template Selected</label>
<locationX>270</locationX>
<locationY>1514</locationY>
<assignmentItems>
<assignToReference>authReleaseTemplateSelected</assignToReference>
<operator>Assign</operator>
<value>
<booleanValue>true</booleanValue>
</value>
</assignmentItems>
<connector>
<targetReference>Scan_For_Auth_Release_Template</targetReference>
</connector>
</assignments>
<assignments>
<name>Store_Auth_Release_Copies</name>
<label>Store Authorization to Release Copies Selection</label>
<locationX>50</locationX>
<locationY>2006</locationY>
<assignmentItems>
<assignToReference>authReleaseFormCopies</assignToReference>
<operator>Assign</operator>
<value>
<elementReference>authReleaseFormCopies_Radio</elementReference>
</value>
</assignmentItems>
<connector>
<targetReference>Build_Template_ID_Collection</targetReference>
</connector>
</assignments>
<assignments>
<name>Store_SMS_Phone</name>
<label>Store SMS Phone Number</label>
<locationX>479</locationX>
<locationY>566</locationY>
<assignmentItems>
<assignToReference>recipientSmsPhone</assignToReference>
<operator>Assign</operator>
<value>
<elementReference>smsPhoneInput</elementReference>
</value>
</assignmentItems>
<connector>
<targetReference>Is_Language_Selected</targetReference>
</connector>
</assignments>
<choices>
<name>AuthCopies_1</name>
<choiceText>1 copy</choiceText>
<dataType>Number</dataType>
<value>
<numberValue>1.0</numberValue>
</value>
</choices>
<choices>
<name>AuthCopies_2</name>
<choiceText>2 copies</choiceText>
<dataType>Number</dataType>
<value>
<numberValue>2.0</numberValue>
</value>
</choices>
<choices>
<name>AuthCopies_3</name>
<choiceText>3 copies</choiceText>
<dataType>Number</dataType>
<value>
<numberValue>3.0</numberValue>
</value>
</choices>
<choices>
<name>AuthCopies_4</name>
<choiceText>4 copies</choiceText>
<dataType>Number</dataType>
<value>
<numberValue>4.0</numberValue>
</value>
</choices>
<choices>
<name>AuthCopies_5</name>
<choiceText>5 copies</choiceText>
<dataType>Number</dataType>
<value>
<numberValue>5.0</numberValue>
</value>
</choices>
<decisions>
<name>Check_Envelope_Result</name>
<label>Check Envelope Result</label>
<locationX>182</locationX>
<locationY>2606</locationY>
<defaultConnector>
<targetReference>Error_Screen</targetReference>
</defaultConnector>
<defaultConnectorLabel>Default Outcome</defaultConnectorLabel>
<rules>
<name>Envelope_Sent_Successfully</name>
<conditionLogic>and</conditionLogic>
<conditions>
<leftValueReference>envelopeSuccess</leftValueReference>
<operator>EqualTo</operator>
<rightValue>
<booleanValue>true</booleanValue>
</rightValue>
</conditions>
<connector>
<targetReference>Success_Screen</targetReference>
</connector>
<label>Envelope Sent Successfully</label>
</rules>
</decisions>
<decisions>
<name>Check_Row_Selection</name>
<label>Check Row Selection</label>
<locationX>380</locationX>
<locationY>1190</locationY>
<defaultConnector>
<targetReference>Row_not_selected</targetReference>
</defaultConnector>
<defaultConnectorLabel>Default Outcome</defaultConnectorLabel>
<rules>
<name>Is_Row_Selected</name>
<conditionLogic>and</conditionLogic>
<conditions>
<leftValueReference>data.firstSelectedRow.Id</leftValueReference>
<operator>IsNull</operator>
<rightValue>
<booleanValue>false</booleanValue>
</rightValue>
</conditions>
<connector>
<targetReference>Scan_For_Auth_Release_Template</targetReference>
</connector>
<label>Is Row Selected?</label>
</rules>
</decisions>
<decisions>
<name>Does_Row_Contain_Auth_Release</name>
<label>Does This Row Contain Authorization to Release Info?</label>
<locationX>402</locationX>
<locationY>1406</locationY>
<defaultConnector>
<targetReference>Scan_For_Auth_Release_Template</targetReference>
</defaultConnector>
<defaultConnectorLabel>No</defaultConnectorLabel>
<rules>
<name>Row_Is_Auth_Release_Template</name>
<conditionLogic>and</conditionLogic>
<conditions>
<leftValueReference>Scan_For_Auth_Release_Template.Name</leftValueReference>
<operator>Contains</operator>
<rightValue>
<stringValue>Authorization to Release Information</stringValue>
</rightValue>
</conditions>
<connector>
<targetReference>Flag_Auth_Release_Selected</targetReference>
</connector>
<label>Yes</label>
</rules>
</decisions>
<decisions>
<name>Is_Auth_Release_Selected</name>
<label>Is Authorization to Release Info Selected?</label>
<locationX>182</locationX>
<locationY>1790</locationY>
<defaultConnector>
<targetReference>Build_Template_ID_Collection</targetReference>
</defaultConnector>
<defaultConnectorLabel>No - Proceed</defaultConnectorLabel>
<rules>
<name>Auth_Release_Template_Found</name>
<conditionLogic>and</conditionLogic>
<conditions>
<leftValueReference>authReleaseTemplateSelected</leftValueReference>
<operator>EqualTo</operator>
<rightValue>
<booleanValue>true</booleanValue>
</rightValue>
</conditions>
<connector>
<targetReference>Authorization_Copies_Screen</targetReference>
</connector>
<label>Yes - Ask for copies</label>
</rules>
</decisions>
<decisions>
<name>Is_Language_Selected</name>
<label>Is Language Selected?</label>
<locationX>611</locationX>
<locationY>758</locationY>
<defaultConnector>
<targetReference>Language_Not_Added_Screen</targetReference>
</defaultConnector>
<defaultConnectorLabel>Default Outcome</defaultConnectorLabel>
<rules>
<name>Language_Selected</name>
<conditionLogic>and</conditionLogic>
<conditions>
<leftValueReference>Get_Records.Docusign_Envelope_Language__c</leftValueReference>
<operator>IsNull</operator>
<rightValue>
<booleanValue>false</booleanValue>
</rightValue>
</conditions>
<connector>
<targetReference>Language_Warning_Screen</targetReference>
</connector>
<label>Language Selected?</label>
</rules>
</decisions>
<decisions>
<name>Is_Recipient_Email_Blank</name>
<label>Is Recipient Email Blank?</label>
<locationX>611</locationX>
<locationY>350</locationY>
<defaultConnector>
<targetReference>Is_Language_Selected</targetReference>
</defaultConnector>
<defaultConnectorLabel>Has Email - Continue</defaultConnectorLabel>
<rules>
<name>Recipient_Has_No_Email</name>
<conditionLogic>or</conditionLogic>
<conditions>
<leftValueReference>Get_Recipient_Contact.Email</leftValueReference>
<operator>IsNull</operator>
<rightValue>
<booleanValue>true</booleanValue>
</rightValue>
</conditions>
<conditions>
<leftValueReference>Get_Recipient_Contact.Email</leftValueReference>
<operator>EqualTo</operator>
<rightValue>
<stringValue></stringValue>
</rightValue>
</conditions>
<connector>
<targetReference>SMS_Phone_Screen</targetReference>
</connector>
<label>No Email - Collect SMS Phone</label>
</rules>
</decisions>
<environments>Default</environments>
<interviewLabel>Docusign Envelope Templates V4 {!$Flow.CurrentDateTime}</interviewLabel>
<label>Docusign Envelope Templates V4</label>
<loops>
<name>Build_Template_ID_Collection</name>
<label>Build Template ID Collection</label>
<locationX>182</locationX>
<locationY>2198</locationY>
<collectionReference>data.selectedRows</collectionReference>
<iterationOrder>Asc</iterationOrder>
<nextValueConnector>
<targetReference>Add_Template_ID</targetReference>
</nextValueConnector>
<noMoreValuesConnector>
<targetReference>Send_Composite_Envelope</targetReference>
</noMoreValuesConnector>
</loops>
<loops>
<name>Scan_For_Auth_Release_Template</name>
<label>Scan For Authorization to Release Template</label>
<locationX>182</locationX>
<locationY>1298</locationY>
<collectionReference>data.selectedRows</collectionReference>
<iterationOrder>Asc</iterationOrder>
<nextValueConnector>
<targetReference>Does_Row_Contain_Auth_Release</targetReference>
</nextValueConnector>
<noMoreValuesConnector>
<targetReference>Is_Auth_Release_Selected</targetReference>
</noMoreValuesConnector>
</loops>
<processMetadataValues>
<name>BuilderType</name>
<value>
<stringValue>LightningFlowBuilder</stringValue>
</value>
</processMetadataValues>
<processMetadataValues>
<name>CanvasMode</name>
<value>
<stringValue>AUTO_LAYOUT_CANVAS</stringValue>
</value>
</processMetadataValues>
<processMetadataValues>
<name>OriginBuilderType</name>
<value>
<stringValue>LightningFlowBuilder</stringValue>
</value>
</processMetadataValues>
<processType>Flow</processType>
<recordLookups>
<name>DocuSign_Envelope_Templates</name>
<label>DocuSign Envelope Templates</label>
<locationX>380</locationX>
<locationY>974</locationY>
<assignNullValuesIfNoRecordsFound>false</assignNullValuesIfNoRecordsFound>
<connector>
<targetReference>Envelope_template_records</targetReference>
</connector>
<filterLogic>and</filterLogic>
<filters>
<field>Envelope_Template_Language__c</field>
<operator>EqualTo</operator>
<value>
<elementReference>Get_Records.Docusign_Envelope_Language__c</elementReference>
</value>
</filters>
<filters>
<field>Short_Name__c</field>
<operator>IsNull</operator>
<value>
<booleanValue>false</booleanValue>
</value>
</filters>
<getFirstRecordOnly>false</getFirstRecordOnly>
<object>dfsle__EnvelopeConfiguration__c</object>
<queriedFields>Id</queriedFields>
<queriedFields>Name</queriedFields>
<queriedFields>dfsle__DocuSignId__c</queriedFields>
<sortField>Name</sortField>
<sortOrder>Asc</sortOrder>
<storeOutputAutomatically>true</storeOutputAutomatically>
</recordLookups>
<recordLookups>
<name>Get_Recipient_Contact</name>
<label>Get Recipient Contact</label>
<locationX>611</locationX>
<locationY>242</locationY>
<assignNullValuesIfNoRecordsFound>false</assignNullValuesIfNoRecordsFound>
<connector>
<targetReference>Is_Recipient_Email_Blank</targetReference>
</connector>
<filterLogic>and</filterLogic>
<filters>
<field>Id</field>
<operator>EqualTo</operator>
<value>
<elementReference>Get_Records.Docusign_Recipient_1__c</elementReference>
</value>
</filters>
<getFirstRecordOnly>true</getFirstRecordOnly>
<object>Contact</object>
<queriedFields>Id</queriedFields>
<queriedFields>Email</queriedFields>
<queriedFields>Name</queriedFields>
<storeOutputAutomatically>true</storeOutputAutomatically>
</recordLookups>
<recordLookups>
<name>Get_Records</name>
<label>Get Records</label>
<locationX>611</locationX>
<locationY>134</locationY>
<assignNullValuesIfNoRecordsFound>false</assignNullValuesIfNoRecordsFound>
<connector>
<targetReference>Get_Recipient_Contact</targetReference>
</connector>
<filterLogic>and</filterLogic>
<filters>
<field>Id</field>
<operator>EqualTo</operator>
<value>
<elementReference>recordId</elementReference>
</value>
</filters>
<getFirstRecordOnly>true</getFirstRecordOnly>
<object>Client_Case__c</object>
<queriedFields>Id</queriedFields>
<queriedFields>Docusign_Envelope_Language__c</queriedFields>
<queriedFields>Docusign_Recipient_1__c</queriedFields>
<storeOutputAutomatically>true</storeOutputAutomatically>
</recordLookups>
<screens>
<name>Authorization_Copies_Screen</name>
<label>Authorization to Release Info - Number of Copies</label>
<locationX>50</locationX>
<locationY>1898</locationY>
<allowBack>true</allowBack>
<allowFinish>true</allowFinish>
<allowPause>false</allowPause>
<backButtonLabel>Back</backButtonLabel>
<connector>
<targetReference>Store_Auth_Release_Copies</targetReference>
</connector>
<fields>
<name>AuthCopiesHeader</name>
<fieldText>&lt;p&gt;The &lt;strong&gt;Authorization to Release Information&lt;/strong&gt; form was selected.&lt;/p&gt;&lt;p&gt;How many copies of this form should be included in the envelope?&lt;/p&gt;</fieldText>
<fieldType>DisplayText</fieldType>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<fields>
<name>authReleaseFormCopies_Radio</name>
<choiceReferences>AuthCopies_1</choiceReferences>
<choiceReferences>AuthCopies_2</choiceReferences>
<choiceReferences>AuthCopies_3</choiceReferences>
<choiceReferences>AuthCopies_4</choiceReferences>
<choiceReferences>AuthCopies_5</choiceReferences>
<dataType>Number</dataType>
<defaultSelectedChoiceReference>AuthCopies_1</defaultSelectedChoiceReference>
<fieldText>Number of Copies</fieldText>
<fieldType>RadioButtons</fieldType>
<inputsOnNextNavToAssocScrn>UseStoredValues</inputsOnNextNavToAssocScrn>
<isRequired>true</isRequired>
<scale>0</scale>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<nextOrFinishButtonLabel>Next</nextOrFinishButtonLabel>
<showFooter>true</showFooter>
<showHeader>true</showHeader>
</screens>
<screens>
<name>Envelope_template_records</name>
<label>Envelope template records</label>
<locationX>380</locationX>
<locationY>1082</locationY>
<allowBack>true</allowBack>
<allowFinish>true</allowFinish>
<allowPause>false</allowPause>
<backButtonLabel>Back</backButtonLabel>
<connector>
<targetReference>Check_Row_Selection</targetReference>
</connector>
<fields>
<name>data</name>
<dataTypeMappings>
<typeName>T</typeName>
<typeValue>dfsle__EnvelopeConfiguration__c</typeValue>
</dataTypeMappings>
<extensionName>flowruntime:datatable</extensionName>
<fieldType>ComponentInstance</fieldType>
<inputParameters>
<name>label</name>
<value>
<stringValue>Select Templates for Composite Envelope</stringValue>
</value>
</inputParameters>
<inputParameters>
<name>selectionMode</name>
<value>
<stringValue>MULTI_SELECT</stringValue>
</value>
</inputParameters>
<inputParameters>
<name>minRowSelection</name>
<value>
<numberValue>0.0</numberValue>
</value>
</inputParameters>
<inputParameters>
<name>tableData</name>
<value>
<elementReference>DocuSign_Envelope_Templates</elementReference>
</value>
</inputParameters>
<inputParameters>
<name>columns</name>
<value>
<stringValue>[{&quot;apiName&quot;:&quot;Name&quot;,&quot;guid&quot;:&quot;column-6d57&quot;,&quot;editable&quot;:false,&quot;hasCustomHeaderLabel&quot;:true,&quot;customHeaderLabel&quot;:&quot;Envelope Template Name&quot;,&quot;wrapText&quot;:true,&quot;order&quot;:0,&quot;label&quot;:&quot;Name&quot;,&quot;type&quot;:&quot;text&quot;}]</stringValue>
</value>
</inputParameters>
<inputsOnNextNavToAssocScrn>UseStoredValues</inputsOnNextNavToAssocScrn>
<isRequired>true</isRequired>
<storeOutputAutomatically>true</storeOutputAutomatically>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<nextOrFinishButtonLabel>Send</nextOrFinishButtonLabel>
<showFooter>true</showFooter>
<showHeader>true</showHeader>
</screens>
<screens>
<name>Error_Screen</name>
<label>Error Screen</label>
<locationX>314</locationX>
<locationY>2714</locationY>
<allowBack>true</allowBack>
<allowFinish>true</allowFinish>
<allowPause>false</allowPause>
<backButtonLabel>Back</backButtonLabel>
<fields>
<name>ErrorDisplayMessage</name>
<fieldText>&lt;p&gt;&lt;span style=&quot;font-size: 16px; color: rgb(255, 0, 0);&quot;&gt;❌ Failed to send composite envelope.&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Error:&lt;/strong&gt; {!envelopeErrorMessage}&lt;/p&gt;&lt;p&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;Please try again or contact your administrator.&lt;/p&gt;</fieldText>
<fieldType>DisplayText</fieldType>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<showFooter>true</showFooter>
<showHeader>false</showHeader>
</screens>
<screens>
<name>Language_Not_Added_Screen</name>
<label>Language Not Added Screen</label>
<locationX>842</locationX>
<locationY>866</locationY>
<allowBack>false</allowBack>
<allowFinish>true</allowFinish>
<allowPause>false</allowPause>
<fields>
<name>LanguageNotSelected</name>
<fieldText>&lt;p&gt;The &lt;strong&gt;DocuSign Envelope Language&lt;/strong&gt; is not populated on the record. Please add the language first and then proceed.&lt;/p&gt;</fieldText>
<fieldType>DisplayText</fieldType>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<showFooter>true</showFooter>
<showHeader>true</showHeader>
</screens>
<screens>
<name>Language_Warning_Screen</name>
<label>Language Warning Screen</label>
<locationX>380</locationX>
<locationY>866</locationY>
<allowBack>false</allowBack>
<allowFinish>true</allowFinish>
<allowPause>false</allowPause>
<connector>
<targetReference>DocuSign_Envelope_Templates</targetReference>
</connector>
<fields>
<name>LangWarningText</name>
<fieldText>&lt;p&gt;The current selected language is &lt;strong&gt;{!Get_Records.Docusign_Envelope_Language__c}. &lt;/strong&gt;On the next screen you will be able to see form names of {!Get_Records.Docusign_Envelope_Language__c} language only. If you want to switch the language, please go back to record and select another language form &lt;strong&gt;DocuSign Envelope Language&lt;/strong&gt;.&lt;/p&gt;</fieldText>
<fieldType>DisplayText</fieldType>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<nextOrFinishButtonLabel>Next</nextOrFinishButtonLabel>
<showFooter>true</showFooter>
<showHeader>true</showHeader>
</screens>
<screens>
<name>Row_not_selected</name>
<label>Row not selected</label>
<locationX>578</locationX>
<locationY>1298</locationY>
<allowBack>true</allowBack>
<allowFinish>true</allowFinish>
<allowPause>false</allowPause>
<backButtonLabel>Back</backButtonLabel>
<fields>
<name>ErrorMessage</name>
<fieldText>&lt;p&gt;&lt;strong style=&quot;background-color: rgb(255, 255, 255); color: rgb(68, 68, 68);&quot;&gt;&lt;em&gt;You have not selected any of the forms. Please go back and select the form first and then proceed.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;</fieldText>
<fieldType>DisplayText</fieldType>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<showFooter>true</showFooter>
<showHeader>false</showHeader>
</screens>
<screens>
<name>SMS_Phone_Screen</name>
<label>Recipient Mobile Phone for SMS Delivery</label>
<locationX>479</locationX>
<locationY>458</locationY>
<allowBack>false</allowBack>
<allowFinish>true</allowFinish>
<allowPause>false</allowPause>
<connector>
<targetReference>Store_SMS_Phone</targetReference>
</connector>
<fields>
<name>SMS_Instructions_Text</name>
<fieldText>&lt;p&gt;The selected recipient ({!Get_Recipient_Contact.Name}) does not have an email address on file. The envelope will be delivered via &lt;strong&gt;SMS text message&lt;/strong&gt; instead.&lt;/p&gt;&lt;p&gt;Please enter the recipient&amp;apos;s mobile phone number in E.164 format (e.g. &lt;strong&gt;+15551234567&lt;/strong&gt;).&lt;/p&gt;</fieldText>
<fieldType>DisplayText</fieldType>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<fields>
<name>smsPhoneInput</name>
<dataType>String</dataType>
<fieldText>Recipient Mobile Phone Number</fieldText>
<fieldType>InputField</fieldType>
<inputsOnNextNavToAssocScrn>UseStoredValues</inputsOnNextNavToAssocScrn>
<isRequired>true</isRequired>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<nextOrFinishButtonLabel>Next</nextOrFinishButtonLabel>
<showFooter>true</showFooter>
<showHeader>true</showHeader>
</screens>
<screens>
<name>Success_Screen</name>
<label>Success Screen</label>
<locationX>50</locationX>
<locationY>2714</locationY>
<allowBack>false</allowBack>
<allowFinish>true</allowFinish>
<allowPause>false</allowPause>
<fields>
<name>SuccessMessage</name>
<fieldText>&lt;p&gt;&lt;span style=&quot;font-size: 16px; color: rgb(0, 128, 0);&quot;&gt;✅ Composite envelope sent successfully!&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;br&gt;&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Envelope ID:&lt;/strong&gt; {!envelopeId}&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Templates combined:&lt;/strong&gt; All selected templates were merged into a single envelope.&lt;/p&gt;</fieldText>
<fieldType>DisplayText</fieldType>
<styleProperties>
<verticalAlignment>
<stringValue>top</stringValue>
</verticalAlignment>
<width>
<stringValue>12</stringValue>
</width>
</styleProperties>
</fields>
<showFooter>true</showFooter>
<showHeader>false</showHeader>
</screens>
<start>
<locationX>485</locationX>
<locationY>0</locationY>
<connector>
<targetReference>Get_Records</targetReference>
</connector>
</start>
<status>Active</status>
<variables>
<name>authReleaseFormCopies</name>
<dataType>Number</dataType>
<isCollection>false</isCollection>
<isInput>false</isInput>
<isOutput>false</isOutput>
<scale>0</scale>
<value>
<numberValue>1.0</numberValue>
</value>
</variables>
<variables>
<name>authReleaseTemplateSelected</name>
<dataType>Boolean</dataType>
<isCollection>false</isCollection>
<isInput>false</isInput>
<isOutput>false</isOutput>
<value>
<booleanValue>false</booleanValue>
</value>
</variables>
<variables>
<name>compositeTemplateIds</name>
<dataType>String</dataType>
<isCollection>true</isCollection>
<isInput>false</isInput>
<isOutput>false</isOutput>
</variables>
<variables>
<name>envelopeErrorMessage</name>
<dataType>String</dataType>
<isCollection>false</isCollection>
<isInput>false</isInput>
<isOutput>false</isOutput>
</variables>
<variables>
<name>envelopeId</name>
<dataType>String</dataType>
<isCollection>false</isCollection>
<isInput>false</isInput>
<isOutput>false</isOutput>
</variables>
<variables>
<name>envelopeSuccess</name>
<dataType>Boolean</dataType>
<isCollection>false</isCollection>
<isInput>false</isInput>
<isOutput>false</isOutput>
</variables>
<variables>
<name>recipientSmsPhone</name>
<dataType>String</dataType>
<isCollection>false</isCollection>
<isInput>false</isInput>
<isOutput>false</isOutput>
</variables>
<variables>
<name>recordId</name>
<dataType>String</dataType>
<isCollection>false</isCollection>
<isInput>true</isInput>
<isOutput>false</isOutput>
</variables>
</Flow>

View File

@ -0,0 +1,192 @@
#!/usr/bin/env python3
import sys
from pathlib import Path
import textwrap
import re
try:
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
from reportlab.lib.units import inch
from reportlab.lib import colors
except Exception as e:
print('Missing reportlab:', e)
sys.exit(2)
def wrap_text(text, max_chars):
return textwrap.wrap(text, width=max_chars)
def draw_wrapped(c, text, x, y, font_name, font_size, max_width, indent=0):
# Estimate chars per line roughly
approx_char_width = font_size * 0.55
max_chars = max(20, int((max_width - indent) / approx_char_width))
wrapped = wrap_text(text, max_chars)
line_height = font_size * 1.25
for line in wrapped:
c.drawString(x + indent, y, line)
y -= line_height
return y
#!/usr/bin/env python3
import sys
from pathlib import Path
import textwrap
try:
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
from reportlab.lib.units import inch
from reportlab.lib import colors
except Exception as e:
print('Missing reportlab:', e)
sys.exit(2)
def wrap_text(text, max_chars):
return textwrap.wrap(text, width=max_chars)
def draw_wrapped(c, text, x, y, font_name, font_size, max_width, indent=0):
# Estimate chars per line roughly
approx_char_width = font_size * 0.55
max_chars = max(20, int((max_width - indent) / approx_char_width))
wrapped = wrap_text(text, max_chars)
line_height = font_size * 1.25
for line in wrapped:
c.drawString(x + indent, y, line)
y -= line_height
return y
if len(sys.argv) < 3:
print('Usage: md_to_pdf.py input.md output.pdf')
sys.exit(1)
in_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
if not in_path.exists():
print('Input file not found:', in_path)
sys.exit(1)
text = in_path.read_text(encoding='utf-8')
lines = text.splitlines()
c = canvas.Canvas(str(out_path), pagesize=letter)
width, height = letter
left = inch * 0.75
right = inch * 0.75
top = height - inch * 0.75
bottom = inch * 0.75
max_width = width - left - right
# default fonts
REGULAR = 'Helvetica'
BOLD = 'Helvetica-Bold'
MONO = 'Courier'
y = top
in_code = False
code_font_size = 8
para_font_size = 10
for raw in lines:
line = raw.rstrip('\n')
if line.strip() == '':
y -= para_font_size * 0.6
if y < bottom:
c.showPage()
y = top
continue
# Code fence toggle
if line.strip().startswith('```'):
in_code = not in_code
if in_code:
y -= 6
else:
y -= 6
if y < bottom:
c.showPage()
y = top
continue
if in_code:
c.setFont(MONO, code_font_size)
# draw code line with small left indent
y = draw_wrapped(c, line, left, y, MONO, code_font_size, max_width, indent=10)
c.setFont(REGULAR, para_font_size)
if y < bottom:
c.showPage()
y = top
continue
# Headings
if line.lstrip().startswith('#'):
hashes, _, rest = line.partition(' ')
level = hashes.count('#')
text = rest.strip()
# add extra space BEFORE top-level headings so they don't butt against previous paragraph
if level == 1:
y -= para_font_size * 1.8
elif level == 2:
y -= para_font_size * 0.8
else:
y -= para_font_size * 0.6
if level == 1:
font_size = 18
font = BOLD
elif level == 2:
font_size = 14
font = BOLD
else:
font_size = 12
font = BOLD
c.setFont(font, font_size)
y = draw_wrapped(c, text, left, y, font, font_size, max_width)
# modest spacing after headings
y -= para_font_size * 0.6
c.setFont(REGULAR, para_font_size)
if y < bottom:
c.showPage()
y = top
continue
# Horizontal rule
if line.strip() in ('---', '***', '___'):
c.setStrokeColor(colors.black)
c.setLineWidth(1)
y -= 6
c.line(left, y, left + max_width, y)
y -= 12
if y < bottom:
c.showPage()
y = top
continue
# Lists
stripped = line.lstrip()
if stripped.startswith(('- ', '* ', '+ ')) or stripped[:2].isdigit() and stripped[2:].startswith('. '):
bullet = ''
content = stripped[2:].strip() if not stripped[0].isdigit() else stripped.split('.', 1)[1].strip()
c.setFont(REGULAR, para_font_size)
# draw bullet and wrapped content with indent
c.drawString(left, y, bullet)
y = draw_wrapped(c, content, left + 12, y, REGULAR, para_font_size, max_width, indent=0)
if y < bottom:
c.showPage()
y = top
continue
# Paragraph
c.setFont(REGULAR, para_font_size)
y = draw_wrapped(c, line, left, y, REGULAR, para_font_size, max_width)
if y < bottom:
c.showPage()
y = top
c.save()
print('Wrote PDF:', out_path)